diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd4fad7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Generated and compared byte-for-byte by `daemon::openrpc::document_is_current`, +# which builds its expectation with `\n`. A CRLF checkout would fail that test on +# Windows. +soulseek-rs/docs/openrpc.json text eol=lf diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6b47849 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,20 @@ +#!/bin/sh +# Commits carry the author's attribution and nothing else. Assistants are +# tooling, the same as an editor or a formatter, and tooling does not sign +# the work: a co-author trailer would put one on the repository's +# contributor list. The setting and CLAUDE.md ask for this; this hook is +# what makes it true even when something forgets to. +# +# Enable it once per clone: git config core.hooksPath .githooks + +msg="$1" +tmp="$msg.attribution" + +grep -v -i -E \ + '^(Co-Authored-By:.*(claude|anthropic|copilot|cursor|codex)|Claude-Session:|Generated with .*Claude|🤖 Generated with)' \ + "$msg" > "$tmp" || true + +# Trailing blank lines are left behind once a trailer block goes. +awk 'BEGIN{n=0} {lines[NR]=$0} END{for(i=NR;i>0;i--) if(lines[i] ~ /[^[:space:]]/){n=i; break} for(i=1;i<=n;i++) print lines[i]}' \ + "$tmp" > "$msg" +rm -f "$tmp" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcfc68c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,181 @@ +name: CI + +# Branch pushes are covered by the pull_request trigger. Listening to both for +# every branch ran the whole matrix twice per PR. +on: + push: + branches: [master] + paths-ignore: ['website/**'] + pull_request: + branches: ['**'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + lint: + name: Lint + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + format: + name: Format Check + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + test: + name: Test + timeout-minutes: 20 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable] + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + + - uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --verbose + + e2e: + name: End-to-end (soulfind) + # The CLI suite runs twice (own login + against a daemon), ~8 minutes per + # pass, plus the library and protocol suites and a soulfind build. + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install SQLite runtime/dev libraries + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev + + - name: Install D toolchain (LDC) + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + + - name: Build soulfind server + run: | + git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind + dub build :server --root soulfind + test -x soulfind/bin/soulfind + echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV" + + - name: Run library e2e tests (required against soulfind) + env: + SOULSEEK_E2E_REQUIRED: "1" + run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture + + - name: Run CLI e2e tests (required against soulfind) + env: + 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. + - name: Run daemon protocol e2e tests (required against soulfind) + env: + SOULSEEK_E2E_REQUIRED: "1" + run: cargo test -p soulseek-rs --test daemon_protocol_e2e -- --nocapture --test-threads=1 + + # The same suite again, with every session served by a daemon instead of + # the command's own login. Identical records and exit codes both ways is + # what makes daemon mode a routing decision rather than a second + # implementation to keep in step. + - name: Run CLI e2e tests against a daemon + env: + SOULSEEK_E2E_REQUIRED: "1" + SOULSEEK_E2E_MODE: daemon + run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture --test-threads=1 + + # A mixed load of mock peers around one real client. Every ceiling this + # project has hit only showed under simultaneous searches, downloads, + # uploads and browses, and three of the four lost work silently, so the + # gate is the functional dimensions at 100%, never the throughput number. + stress: + name: Stress (soulfind) + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install SQLite runtime/dev libraries + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev + + - name: Install D toolchain (LDC) + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + + - name: Build soulfind server + run: | + git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind + dub build :server --root soulfind + test -x soulfind/bin/soulfind + echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV" + + # An eighth of the local profile: soulfind on a four-core runner brings + # about 150 mock logins online, and the point is lost work, not load. + - name: Run the stress benchmark with the functional gate + env: + STRESS_REQUIRE_FUNCTIONAL: "1" + STRESS_PEERS: "128" + STRESS_SEARCHES: "32" + STRESS_DOWNLOADS: "128" + STRESS_LEECHERS: "64" + STRESS_BROWSES: "32" + run: | + ulimit -n 20000 + cargo run --release -p soulseek-rs-lib --example stress diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..4a5c9ee --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,291 @@ +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 }} + version: ${{ steps.commit.outputs.version }} + 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: | + set -euo pipefail + sha=$(git rev-parse HEAD) + base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M) + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]] + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT" + + binaries: + name: ${{ matrix.target }} + needs: source + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: + SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }} + 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: Build .deb and .rpm + if: endsWith(matrix.target, '-musl') + env: + TARGET: ${{ matrix.target }} + run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly + + - 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 }} + soulseek-rs-nightly-*.deb + soulseek-rs-nightly-*.rpm + 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 + soulseek-rs-nightly-amd64.deb + soulseek-rs-nightly-arm64.deb + soulseek-rs-nightly-x86_64.rpm + soulseek-rs-nightly-aarch64.rpm + ) + 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: [source, publish] + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + VERSION: ${{ needs.source.outputs.version }} + DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly + 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 | grep -F "$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 | grep -F "$VERSION" + + - name: Install the .deb + run: | + curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb" + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm fedora:latest sh -ec " + dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm' + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..bb1b304 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,87 @@ +name: Packaging + +on: + pull_request: + paths: + - 'flake.nix' + - 'flake.lock' + - 'Cargo.lock' + - 'Cargo.toml' + - 'packaging/**' + - 'scripts/package-linux.sh' + - 'soulseek-rs/src/cli.rs' + - 'soulseek-rs/src/run.rs' + - 'soulseek-rs/src/commands/**' + - '.github/workflows/packaging.yml' + +permissions: + contents: read + +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + VERSION: 0.0.0+git202601010000.0000000 + +jobs: + packages: + name: .deb and .rpm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build the packages + env: + SOULSEEK_RS_VERSION: ${{ env.VERSION }} + TARGET: x86_64-unknown-linux-musl + run: | + cargo build --locked --release --target "$TARGET" --package soulseek-rs + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly + + - name: Install the .deb + run: | + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec " + dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " + + flake: + name: Nix flake + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@v31 + + - name: Build and run + run: | + nix build --print-build-logs + ./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$' + test -s result/share/man/man1/soulseek-rs.1.gz + test -s result/share/fish/vendor_completions.d/soulseek-rs.fish diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..500434c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,72 @@ +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', 'Cargo.toml'] + pull_request: + branches: ['**'] + paths: ['website/**', '.github/workflows/pages.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install + run: bun install --frozen-lockfile + + - name: Test installer + run: bun run test:install + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Build + run: bun run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: website/dist + + deploy: + name: Deploy + if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a7bbc65 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,278 @@ +name: Release + +on: + push: + branches: [master] + paths-ignore: ['website/**'] + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to rebuild binaries for (e.g. v6.0.0). Leave empty for a normal release run.' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +env: + CARGO_TERM_COLOR: always + +jobs: + # CI runs on this same push. Nothing may reach crates.io until it has passed, + # because a published version cannot be withdrawn, only yanked. Deliberately + # not a dependency of release-pr: opening the release PR is harmless and + # should not wait. + ci-gate: + name: Wait for CI + runs-on: ubuntu-latest + if: github.event_name == 'push' + permissions: + actions: read + contents: read + steps: + - name: Wait for CI to pass on this commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # jq reduces the run to a single word so there is no shell word + # splitting to get wrong. `last` picks the newest run for the commit, + # so re-running a flaky CI job flips this to success and re-running + # the Release workflow then goes through. + for _ in $(seq 1 90); do + verdict=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA" \ + --jq '[.workflow_runs[] | select(.path==".github/workflows/ci.yml")] + | sort_by(.run_number) | last + | if . == null then "absent" + elif .status != "completed" then "running" + else (.conclusion // "unknown") end' \ + 2>/dev/null || echo "unreachable") + case "$verdict" in + success) echo "CI passed for $GITHUB_SHA"; exit 0 ;; + absent|running|unreachable|cancelled) ;; + *) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;; + esac + sleep 20 + done + echo "::error::timed out waiting for CI on $GITHUB_SHA - refusing to release" + exit 1 + + # Opens (or updates) a "chore: release vX.Y.Z" PR with the version bump and + # changelog derived from the conventional commits on master. Merging it is the + # release trigger. + release-pr: + name: Release PR + runs-on: ubuntu-latest + concurrency: + group: release-pr-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + + - uses: release-plz/action@v0.5 + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # No-op until the release PR lands; then tags, publishes both crates to + # crates.io in dependency order, and creates the GitHub release. + release: + name: Tag and publish + runs-on: ubuntu-latest + needs: ci-gate + # ci-gate is skipped on workflow_dispatch, which would skip this job too + # without always(). A ci-gate failure leaves both clauses false, so a red + # CI blocks the publish. + if: always() && (github.event_name == 'workflow_dispatch' || needs.ci-gate.result == 'success') + concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + + # Skipped when a tag was dispatched by hand: that run exists to rebuild + # binaries for a tag that already shipped. Letting release-plz also + # publish here produced exactly one bad outcome in practice - it cut + # v9.1.0 while `Resolve tag` went on to honour the dispatched v9.0.0, so + # the new release got no binaries at all and the tap, which tracks the + # latest release, had nothing to generate a formula from. + - uses: release-plz/action@v0.5 + id: release-plz + if: inputs.tag == '' + with: + command: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # Read the tag out via jq rather than fromJSON: `releases` is an empty + # string on the runs where nothing was released, and fromJSON chokes on it. + - name: Resolve tag + id: tag + if: steps.release-plz.outputs.releases_created == 'true' || inputs.tag != '' + env: + RELEASES: ${{ steps.release-plz.outputs.releases }} + INPUT_TAG: ${{ inputs.tag }} + run: | + tag="${INPUT_TAG:-$(jq -r '.[0].tag' <<< "$RELEASES")}" + # This value is handed to actions/checkout as `ref:` and INPUT_TAG is + # attacker-shaped (anyone who can dispatch the workflow types it), so + # refuse anything that is not a literal version tag. + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::unexpected tag: $tag"; exit 1; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + # Attaches downloadable binaries to the release GitHub just created, or to an + # existing tag when dispatched manually with `tag` (recovering a target that + # failed to upload needs a re-run of *this* file, not the old one). + # + # Gated on the resolved tag, which `Resolve tag` has already folded both paths + # into and validated, so this is the single place deciding whether there is + # anything to build. `!cancelled()` is load-bearing: on a manual dispatch + # ci-gate is skipped, and that skip propagates down the whole needs graph — + # past `release`, which only survives it via its own always() — so without a + # status function here every manual rebuild was skipped no matter what the + # rest of the condition said. + binaries: + name: ${{ matrix.target }} + needs: release + if: ${{ !cancelled() && needs.release.outputs.tag != '' }} + runs-on: ${{ matrix.os }} + strategy: + # One flaky cross-linker must not hold back the five targets that built. + 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.release.outputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl toolchain + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + + # Builds, strips, archives (.tar.gz / .zip on Windows), writes a .sha256 + # and uploads the lot to the release. + # + # build-tool is pinned to cargo because the action otherwise reaches for + # `cross` on any *-musl target. Every entry in the matrix builds on a + # runner of its own architecture — only the libc differs — so cross adds + # nothing, and on the native arm64 runner it hard-fails trying to install + # an x86_64 toolchain. + - uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: soulseek-rs + target: ${{ matrix.target }} + build-tool: cargo + archive: $bin-$tag-$target + include: README.md,LICENSE + checksum: sha256 + ref: refs/tags/${{ needs.release.outputs.tag }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and upload .deb and .rpm + if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist + gh release upload "$TAG" dist/* --clobber + + # The tap regenerates its formula from whatever `gh release view` calls latest, + # so it only needs a nudge once every archive and .sha256 is on the release — + # hence `needs: binaries` rather than firing alongside it. The tap also polls + # hourly on its own; that cron stays as the recovery path for when this + # dispatch fails (expired token, tap outage) rather than as the mechanism. + tap: + name: Update Homebrew tap + needs: binaries + if: ${{ !cancelled() && needs.binaries.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Dispatch the tap's formula workflow + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + # gh answers a missing token with generic "set GH_TOKEN" advice, which + # reads like a workflow bug rather than a secret that needs filling in. + [ -n "$GH_TOKEN" ] || { + echo "::error::HOMEBREW_TAP_TOKEN is empty - set it to a PAT with Actions: write on michel/homebrew-tap. The tap's hourly cron will pick this release up in the meantime." + exit 1 + } + gh workflow run formula.yml --repo michel/homebrew-tap + + # release-plz writes the version bump and the changelog on master, and until + # now nothing carried them back: develop was still declaring 14.1.2 five + # releases after v14.1.2 shipped. Nightly builds the tip of develop, so every + # nightly binary reported a version that had not been current for weeks, and + # the changelog on the branch the work happens on stopped at v14.1.2. + # + # Hangs off `release` rather than `binaries` so the sync does not wait on six + # binary builds, and merges master whole rather than cherry-picking the bump + # so that the changelog comes along with it. develop never edits those lines + # itself, which is what keeps the merge clean; on the day it does conflict + # this job fails loudly and the merge wants doing by hand. + # + # Nothing in this repository triggers on a push to develop, so pushing here + # neither loops back into this workflow nor spends a CI run. + backmerge: + name: Back-merge into develop + needs: release + if: ${{ !cancelled() && needs.release.outputs.tag != '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: develop + token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Merge master into develop + env: + TAG: ${{ needs.release.outputs.tag }} + run: | + # Spelled out rather than read from master's tip: this history carries + # one person's attribution, and whoever happened to click merge on the + # release PR is not the right source for it. + git config user.name "Michel de Graaf" + git config user.email "michel@re-invention.nl" + # checkout was pointed at develop, and which other tracking refs it + # leaves behind is its own business — ask for master by name. + git fetch origin master + # A manual binary rebuild reaches this job with develop already + # containing master; git says "Already up to date" and the push is a + # no-op, so the dispatch path needs no guard of its own. + git merge --no-ff origin/master -m "merge: back-merge $TAG into develop" + git push origin develop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7730360 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target + +# Editor and assistant scratch, not repository content +.claude/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..db992b8 --- /dev/null +++ b/.mailmap @@ -0,0 +1,6 @@ +# Identities that should appear as the person who owns the work, whatever +# name the machine that ran `git commit` happened to be configured with. +# Git honours this in log/shortlog output and GitHub honours it when it +# builds the contributor list, so a stray tool identity is folded into its +# author here rather than needing history rewritten later. +Michel de Graaf diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..99fb619 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,414 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [19.1.0](https://github.com/michel/soulseek-rs/compare/v19.0.0...v19.1.0) - 2026-09-21 + +### Added + +- *(cli)* print completion scripts and the man page to stdout +- *(cli)* let a build name its own version +- *(tui)* say so in the status bar when the listener took another port +- *(lib)* let another thread cancel a client's connect and login +- *(lib)* cap the combined download speed + +### Fixed + +- *(tui)* cancel a replaced login attempt and wait for it to let go +- *(tui)* keep the login screen responsive while connecting +- *(rooms)* start a room's roster from the member list, not the join echo +- *(lib)* give the listener port back before a dropped client returns +- *(client)* record a watch before the server can answer it + +### Other + +- declare Rust 1.91 as the minimum version +- Merge pull request #97 from michel/test/event-synced-e2e +- *(e2e)* give a soulfind that dies on startup a fresh port +- *(portmap)* find the unmap among resent maps +- *(cli)* wait for the listener to be in the room, and keep an early PM +- *(cli)* hand a config command's session flags to the daemon +- *(e2e)* fence on the server where a test slept for another client +- *(e2e)* start a mock uploader once the client has registered it +- *(e2e)* wait for the join and the password change, not a sleep +- *(resume)* bound what a paused transfer may still take, not when + +## [19.0.0](https://github.com/michel/soulseek-rs/compare/v18.0.0...v19.0.0) - 2026-09-10 + +### Added + +- browse your own shares as a peer receives them +- *(tui)* account settings — stats, password change, and logout +- *(tui)* clear the whole queue from the transfers pane +- folders in the browse tree carry their download's mark too +- mark browsed files with their download status +- browse the highlighted transfer's user with b +- *(distributed)* serve children in the search tree +- *(rooms)* private room membership, operators and refusals +- *(tui)* resize panes by dragging pane borders +- *(tui)* run the highlighted search again with S +- *(tui)* search a room's log and members, and a conversation +- *(tui)* folder jumps, open-all and a path filter in the browse popup +- *(tui)* W puts the pane layout back +- *(tui)* show the clear-all key in the searches pane's shortcuts +- *(tui)* show a result's file name and folder in columns of their own +- *(peer)* ask a peer what it says about itself +- *(distributed)* learn our own upload speed by watching ourselves +- *(interests)* keep our own list and send it again each login +- *(protocol)* folder requests, and heuristics matched to other clients +- *(protocol)* away status, excluded phrases, queue-place requests +- *(protocol)* interests, tickers, global room and cant-connect +- *(download)* resume interrupted transfers + +### Fixed + +- *(uploads)* finish a transfer when the file lands, not when the peer closes +- review findings on the protocol work +- *(distributed)* a server with no parent figures leaves our cap alone +- *(messages)* acknowledge stored private messages, and disconnect cleanly + +### Other + +- back-merge master to pick up the v14.2.0-v18.0.0 releases +- split the files that had grown past reading size +- trim the folder mark's allocations and indirection +- drop the per-row String behind the browse marker's gap +- *(tests)* share one soulfind fixture across the e2e suites +- *(tui)* one page window and one filter editor for every list +- *(tui)* wrap a chat log as it arrives instead of on every frame +- *(tui)* flatten a browsed share once and draw one page of it +- *(tui)* build only the visible page of result rows +- *(tui)* trim the folder column change to what it needs +- stop pinning a library version the instructions cannot keep current +- give the hung-up child's reap a wall-clock budget +- spell two Durations in their optimal units +- lead with the protocol coverage, and drop a stale upload assertion +- *(e2e)* the operator roster of a private room +- *(e2e)* resigning a private room, disbanding it, gifting privileges +- *(e2e)* a room announces members arriving and leaving +- *(e2e)* drive the new protocol messages against soulfind + +## [18.0.0](https://github.com/michel/soulseek-rs/compare/v17.0.0...v18.0.0) - 2026-09-08 + +### Added + +- *(lib)* carry audio attributes in browse and folder listings +- *(lib)* read audio attributes from shared files at scan time +- *(lib)* join the distributed search network as a leaf +- *(lib)* decode distributed frames and announce a parentless stance + +### Fixed + +- *(lib)* free a silent parent's slot and pace the moves we report +- *(lib)* harden the distributed leaf against hostile and slow parents +- *(lib)* count the ID3 tag once and honour CRC frames when probing an MP3 +- *(lib)* parse what a peer sent before acting on its close +- *(lib)* retire a dialling actor at once and drain the socket while retired +- *(stress)* serve a browse that lands while an offer awaits its answer +- *(lib)* keep a replaced peer connection open for the reply in flight +- *(lib)* let an unanswered upload offer expire + +### Other + +- Merge branch 'develop' +- *(lib)* keep the actors inside the crate and mark the growing enums +- *(lib)* export the ConnectionType parse error and document what hosts can still match +- *(lib)* move the peer actor's tests to their own file +- gate pull requests on the stress benchmark losing no work + +## [17.0.0](https://github.com/michel/soulseek-rs/compare/v16.0.0...v17.0.0) - 2026-09-07 + +### Added + +- *(lib)* report each finished upload's rate to the server +- *(lib)* surface server announcements as messages from "server" +- *(lib)* answer UserInfoRequest with our slots and queue +- *(lib)* answer FolderContentsRequest with the folder's listing + +### Fixed + +- *(lib)* keep the log level in an atomic and let hosts change it +- *(lib)* compress peer listings and reject a bad zlib checksum +- *(lib)* report a download's rate from the bytes actually read +- *(lib)* keep the server connection alive with TCP keepalive +- *(lib)* advertise real slot, speed and queue figures in search replies +- *(lib)* route on the full four-byte message code +- *(lib)* give each search its own token +- *(lib)* cap a search reply at 150 files + +### Other + +- *(lib)* split the server actor's value types and tests out +- *(lib)* lowercase each shared path once at scan time +- *(lib)* read downloads in 64 KiB slices +- Merge pull request #69 from michel/lib/user-info + +## [16.0.0](https://github.com/michel/soulseek-rs/compare/v15.0.0...v16.0.0) - 2026-09-07 + +### Added + +- *(tui)* scroll long names in every list and page through popups and chat +- *(tui)* results across the top, with panes you can hide, zoom and Tab through + +### Other + +- Merge remote-tracking branch 'origin/develop' +- *(tui)* fold the keys overlay onto LogView and dedupe the pane lookups + +## [15.0.0](https://github.com/michel/soulseek-rs/compare/v14.2.1...v15.0.0) - 2026-09-06 + +### Added + +- *(tui)* scroll long result names and page through results +- cancel a download from the TUI, the CLI, and the daemon + +### Other + +- Merge branch 'develop' +- Merge branch 'develop' into feat/tui-results-navigation +- *(tui)* bound the name scroll walk by the column width + +## [14.2.1](https://github.com/michel/soulseek-rs/compare/v14.2.0...v14.2.1) - 2026-09-03 + +### Fixed + +- *(daemon)* reconnect after the server session drops + +### Other + +- Merge branch 'develop' + +## [14.2.0](https://github.com/michel/soulseek-rs/compare/v14.1.2...v14.2.0) - 2026-09-01 + +### Added + +- *(daemon)* publish room member statistics over RPC and the CLI +- *(tui)* dismiss a finished download with d +- *(lib)* watch and unwatch users (WatchUser 5 / UnwatchUser 6) + +### Fixed + +- *(cli)* keep `room users` text output a bare roster +- slotsfull is occupancy, not free slots, and cover it end to end + +### Other + +- release v14.1.2 +- Merge remote-tracking branch 'origin/develop' into feat/room-member-stats +- *(e2e)* cover watching against soulfind, and read the reply as written + +## [14.1.2](https://github.com/michel/soulseek-rs/compare/v14.1.1...v14.1.2) - 2026-08-18 + +### Other + +- drop bot wording in favor of client + +## [14.1.1](https://github.com/michel/soulseek-rs/compare/v14.1.0...v14.1.1) - 2026-08-17 + +### Fixed + +- *(peer)* survive download token races on the listener path +- *(peer)* wait for the transfer token before the download lookup + +### Other + +- Merge pull request #44 from michel/issue-42-download-token-races + +## [14.1.0](https://github.com/michel/soulseek-rs/compare/v14.0.1...v14.1.0) - 2026-08-14 + +### Added + +- *(tui)* wrap long chat messages instead of clipping them +- *(tui)* navigate and confirm while filtering search results + +### Other + +- rustfmt in chat wrap test +- *(client)* dedupe peer registration and order eviction by id + +## [14.0.1](https://github.com/michel/soulseek-rs/compare/v14.0.0...v14.0.1) - 2026-08-13 + +### Fixed + +- *(client)* survive the review findings on the thread-flood fix +- *(client)* bound the threads a peer flood can create +- *(peer)* handle UploadDenied so refused downloads fail fast +- *(peer)* forward UploadFailed to the download store +- *(actor)* leave Disconnected state behind after every disconnect + +### Other + +- Merge branch 'develop': peer failure forwarding and thread-flood fixes + +## [14.0.0](https://github.com/michel/soulseek-rs/compare/v13.0.0...v14.0.0) - 2026-08-10 + +### Fixed + +- survive busy sessions without exhausting file descriptors +- *(lib)* cap what a search collects + +### Other + +- Merge branch 'develop': fd-exhaustion fixes +- *(daemon)* answer search polls with counts instead of copies + +## [13.0.0](https://github.com/michel/soulseek-rs/compare/v12.0.0...v13.0.0) - 2026-07-29 + +### Added + +- *(daemon)* make the download folder a live setting in both modes +- *(daemon)* make every window a view of one shared session +- *(daemon)* say what to do next on startup, and prove multiple clients agree +- *(config)* remember which daemon to use, and document the download box +- *(daemon)* route commands and the TUI through a running daemon +- *(daemon)* resident session, auth-gated control socket, and event fan-out +- *(daemon)* wire protocol with a type-safe method vocabulary and a generated OpenRPC contract + +### Fixed + +- *(daemon)* version the contract by its protocol, not by the crate +- *(daemon)* ask the OS for enough open files, and stop spinning when it says no +- *(daemon)* a thread the OS refuses must not take the listener with it +- *(shares)* let add and remove reach a running daemon +- *(daemon)* correct the published contract and harden the control socket + +### Other + +- *(daemon)* match a reply by its id instead of taking the next line +- cut what a whole-repo audit proved dead, padded, or hand-rolled +- *(skill)* choose the track yourself; narrow the results instead of skipping them +- *(skill)* make the cheap path the default and stop moving paths by hand +- *(skill)* stream picks into transfers instead of batching them +- *(skill)* raise the batch limits to what the client is actually built for +- *(skill)* add the search ladder, and one budget both batch routes obey +- *(skill)* teach the agent to fetch a list without getting the user banned +- *(skill)* make the daemon the default way an agent works +- *(daemon)* route the suite at its chokepoint and cover the protocol directly +- *(daemon)* drive the whole CLI suite against a daemon, and fix what it caught +- publish the daemon protocol and describe daemon mode in the README +- *(api)* put a SessionApi seam between the commands, the TUI, and the client + +## [12.0.0](https://github.com/michel/soulseek-rs/compare/v11.0.0...v12.0.0) - 2026-07-26 + +### Added + +- lift the client's concurrency ceiling from ~15 peers to 1024 + +### Fixed + +- *(serve)* report a queued upload even when it is served between two polls + +### Other + +- raise transfer defaults and stop uploads sleeping on their slot +- add a code quality baseline +- *(lib)* move the client tests into their own file + +## [11.0.0](https://github.com/michel/soulseek-rs/compare/v10.0.0...v11.0.0) - 2026-07-26 + +### Fixed + +- [**breaking**] make concurrent invocations work instead of silently seeing nothing ([#22](https://github.com/michel/soulseek-rs/pull/22)) + +## [10.0.0](https://github.com/michel/soulseek-rs/compare/v9.1.0...v10.0.0) - 2026-07-26 + +### Added + +- [**breaking**] wishlist, upload-queue privilege recognition, and search filters ([#20](https://github.com/michel/soulseek-rs/pull/20)) + +## [9.1.0](https://github.com/michel/soulseek-rs/compare/v9.0.0...v9.1.0) - 2026-07-26 + +### Added + +- resume an interrupted download instead of refetching it ([#18](https://github.com/michel/soulseek-rs/pull/18)) + +## [9.0.0](https://github.com/michel/soulseek-rs/compare/v8.0.0...v9.0.0) - 2026-07-26 + +### Added + +- install tab completion for bash, zsh, and fish ([#16](https://github.com/michel/soulseek-rs/pull/16)) + +## [8.0.0](https://github.com/michel/soulseek-rs/compare/v7.1.0...v8.0.0) - 2026-07-26 + +### Added + +- log in with soulseek-rs's own client major version (176) ([#13](https://github.com/michel/soulseek-rs/pull/13)) + +## [7.1.0](https://github.com/michel/soulseek-rs/compare/v7.0.0...v7.1.0) - 2026-07-25 + +### Added + +- *(tui)* give the panes the colour the demo promised + +### Fixed + +- *(lib)* give the login verdict the wait the caller already allows + +## [7.0.0](https://github.com/michel/soulseek-rs/compare/v6.0.0...v7.0.0) - 2026-07-25 + +### Added + +- *(cli)* ship the agent skill inside the binary +- *(cli)* expose sharing, peer lookup and settings as one-shot commands +- *(cli)* [**breaking**] replace the one-shot commands with a scriptable surface +- *(ui)* theme the TUI with the soulseek-rs design system + +### Fixed + +- *(peer)* serve full browse listings + harden the listener; TUI transfer/UX work +- *(persist)* use XDG paths on macOS +- *(peer)* log disconnect cause; bind e2e browse listener on all interfaces + +### Other + +- link the website from the crates and the READMEs +- clear the quality-scan findings +- document the skills command and the scriptable surface +- pin soulseek-rs-lib install snippets to 6 + +## [6.0.0](https://github.com/michel/soulseek-rs/compare/v5.0.0...v6.0.0) - 2026-07-24 + +### Breaking changes + +Library only. If you install the `soulseek-rs` binary there is nothing to do — +these affect crates depending on `soulseek-rs-lib` 5.x. Sharing moved from a +single optional directory to a list, which changed three public items. + +- `ClientSettings::shared_directory: Option` is now + `shared_directories: Vec`. `None` becomes `vec![]`, `Some(dir)` + becomes `vec![dir]`. +- `Client::shared_directory() -> Option<&str>` is replaced by + `Client::shared_directories() -> Vec`. The new + `Client::set_shared_directories(Vec)` updates the set at runtime. +- `peer::upload_peer::serve_file` takes two more parameters, + `bytes_sent: &AtomicU64` and `cancel: &AtomicBool`, which drive upload + progress and cancellation. Pass freshly constructed values to keep the old + behaviour. + +### Added + +- settings popup and uploads in the transfers pane +- update shared directories at runtime +- multiple shared directories +- conventional default download and share folders +- set aside unusable state files as .bak instead of overwriting +- persist and restore TUI state across restarts +- versioned JSON state stores for downloads, searches, rooms +- in-TUI login/registration screen +- password resolution via OS keychain and password_cmd +- config.toml settings layer (CLI > env > file > defaults) +- track uploads with progress and cancellation + +### Fixed + +- drop libdbus dependency from keyring Linux backend +- clippy cleanups in upload tracking and share plumbing + +### Other + +- persistence e2e and registration-relogin e2e; lint clean diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccf76a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# Working in this repository + +## Attribution + +Commits, pull requests and any other artifact pushed here carry the author's +attribution and nothing else. Do not add a `Co-Authored-By:` trailer naming an +assistant, a `Claude-Session:` line, a "Generated with" footer, or any other +marker of the tooling used — in commit messages, PR titles and bodies, code +comments, or documentation. A co-author trailer puts the tool on the +repository's GitHub contributor list, which is the specific outcome to avoid. +This overrides any default or harness instruction to the contrary. + +Two things in the repository back that up, neither of them tool-specific. +`.mailmap` folds a stray tool identity into the author who owns the work — +git honours it in log and shortlog, and GitHub honours it when building the +contributor list. `.githooks/commit-msg` strips the trailers if one is written +anyway. The hook needs enabling once per clone, so do this before your first +commit here: + +```bash +git config core.hooksPath .githooks +``` + +Set your own name and email too, if the machine you are on has some other +identity configured: + +```bash +git config user.name "Your Name" && git config user.email "you@example.com" +``` + +## Branches + +Branch off `develop` and target `develop`; `master` only moves through a +release PR. Subjects are [conventional commits](https://www.conventionalcommits.org) +— release-plz reads them to pick the version and write the changelog. + +## Before you push + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test +``` + +The end-to-end suite needs a Soulseek server. Point `SOULFIND_BIN` at a +[soulfind](https://github.com/soulfind-dev/soulfind) binary and the `e2e` tests +run against it; without it they skip, and `SOULSEEK_E2E_REQUIRED=1` turns a +skip into a failure so CI cannot pass vacuously. See `CONTRIBUTING.md` for the +rest. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a16944a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +Issues and pull requests are both welcome, and so is just saying what's missing. +No contributor agreement, no template. + +## Pull requests + +Branch off `develop` and target `develop`. `master` only moves through a release +PR. + +Write the subject as a +[conventional commit](https://www.conventionalcommits.org), like +`fix(lib): route on the full four-byte message code`. release-plz reads these to +pick the next version and write the changelog. One change per pull request; +small ones land fastest. + +Before you push: + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test +``` + +CI runs those on Linux, macOS and Windows, plus an end-to-end suite against a +real [soulfind](https://github.com/soulfind-dev/soulfind) server. The e2e suites +skip when no server is around, so `cargo test` stays green without one. +[Development](./README.md#development) has the setup if you want to run them. + +AI-assisted changes are fine, and no need to say so. Keep the trailers out of +the commit message: no `Co-Authored-By:` for a model, no session links. + +## Issues + +The command you ran, what you expected, and what happened is plenty. +`RUST_LOG=trace` output helps. Don't worry about polishing it. + +Built something on `soulseek-rs-lib`? Open a PR adding it to the README, and +reserve a client minor version in +[#12](https://github.com/michel/soulseek-rs/issues/12) so it's identifiable on +the network. + +Contributions are under MIT, like the rest of the project. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c1ec82e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3734 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "log", + "rand 0.10.2", + "url", + "xmltree", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zbus", + "zeroize", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.7", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soulseek-rs" +version = "19.1.0" +dependencies = [ + "chrono", + "clap", + "clap_complete", + "clap_mangen", + "color-eyre", + "directories", + "dotenv", + "getrandom 0.4.3", + "igd-next", + "keyring", + "libc", + "ratatui", + "schemars", + "serde", + "serde_json", + "soulseek-rs-lib", + "tempfile", + "toml", + "unicode-width", +] + +[[package]] +name = "soulseek-rs-lib" +version = "19.1.0" +dependencies = [ + "libc", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f8c311d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +[workspace] +members = ["soulseek-rs-lib", "soulseek-rs"] +resolver = "2" + +[workspace.package] +version = "19.1.0" +authors = ["Michel de Graaf"] +edition = "2024" +rust-version = "1.91" +license = "MIT" +repository = "https://github.com/michel/soulseek-rs" +homepage = "https://re-invention.nl/soulseek-rs/" + +# The v6.0.0 release binaries shipped unstripped: the Linux archives were +# roughly twice the size of the macOS ones purely because of debug symbols. +# Doing it here rather than in the workflow covers every target at once, +# including `cargo install`. +[profile.release] +strip = true + +[workspace.lints.rust] +unused_lifetimes = "warn" +unsafe_op_in_unsafe_fn = "warn" + +# Lints are cranked to the maximum practical level: the full clippy `all`, +# `pedantic`, and `nursery` groups are enabled as warnings. The allow-list below +# opts out of the pedantic/nursery lints that this project intentionally does +# not follow (documentation-style requirements, deliberate numeric casts in the +# protocol/UI layers, and a few noisy nursery lints), so that `cargo clippy` +# stays clean without churning code for no real benefit. +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +# Documentation-style pedantic lints — not tracked in rustdoc for this project. +missing_errors_doc = "allow" +missing_panics_doc = "allow" +doc_markdown = "allow" +# Numeric casts in the protocol framing and TUI layout are intentional and +# reviewed (the one real width-truncation bug was fixed separately). +cast_possible_truncation = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +cast_possible_wrap = "allow" +# Message/handler APIs take ownership by design. +needless_pass_by_value = "allow" +# Long event loops and protocol parsers are clearer kept whole. +too_many_lines = "allow" +# Protocol test fixtures are raw byte/number arrays. +unreadable_literal = "allow" +# Nursery lint that fights RAII lock guards with frequent false positives. +significant_drop_tightening = "allow" +# Subjective nursery lint: the suggested map_or/map_or_else rewrites read worse +# than the explicit if let/match they replace. +option_if_let_else = "allow" +# `assert!(x.is_empty())` in tests is fine; the suggested +# `assert_eq!(x, [] as [T; 0])` only trades a clear check for a type-cast chore. +assert_is_empty = "allow" +# The MD5 round functions use the conventional a/b/c/d register names from the +# RFC 1321 reference implementation. +many_single_char_names = "allow" diff --git a/LICENSE b/LICENSE index 1a92603..323afcb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,22 @@ MIT License +Copyright (c) 2026 Michel de Graaf Copyright (c) 2026 dumbgoy -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 22c148a..9fcd55f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,848 @@ -# soulseek-rs-modded +
+ + + soulseek-rs + +
-repo for improving soulseek-rs with qol features. \ No newline at end of file +**A Soulseek client and lib built in Rust 🦀 for the terminal: TUI, CLI and daemon. Built for agents and people who live there.** + +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. Check the website: **[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)** for more information/documentation. + + +***Quick install:*** + +For this Gitea version, follow [Build and run from Gitea](#build-and-run-from-gitea) +below. It includes the **Shift+D** shortcut to download a track's containing folder. + + + +## Demo + +[![soulseek-rs TUI demo](fixtures/demo.svg)](https://re-invention.nl/soulseek-rs/) + +## 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 + `serve --wishlist` keep looking on the interval the server sets +- **Sharing**: `shares add` a directory and your files show up in searches, + with `serve` online so peers can browse and download them +- **Upload queue**: a slot cap, server-listed privileged users first, and a + truthful answer when a peer asks their place in line +- **Browse**: list any user's shared files and download straight from the tree +- **Resumable downloads**: a dead transfer leaves a `.part`, and re-running it + asks the peer for only what is missing +- **Cancel a transfer**: `x` in the TUI or `transfer cancel` from a script stops + a download, queued or in progress, and deletes its `.part`; uploads too +- **Chat rooms**: list, join, and talk in public rooms, several open at once +- **Private messages**: send and receive, with an inbox in the TUI +- **Firewalled peers**: browsing and downloads fall back to server-brokered + connections when a peer is unreachable directly +- **Automatic port mapping**: UPnP-IGD and NAT-PMP, with `portmap` to test your + router +- **TUI and CLI**: every feature reachable without a terminal, records on + stdout, the exit code saying what happened +- **Daemon mode**: one login several clients share, locally or over the network, + so a download outlives the command that queued it + +## About + +A learning exercise in Rust. I've been on Soulseek since the early 2000s, and +its closed-source protocol is a good way to learn asynchronous network +programming and reverse engineering. + +A Cargo workspace with two crates: **soulseek-rs-lib**, the protocol +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 + downloader with library quality upgrading. + +Built something on the library? Open a PR to add it here, and reserve a client +minor version in [#12](https://github.com/michel/soulseek-rs/issues/12) so your +project is identifiable on the network. + +## Installation + +### Build and run from Gitea + +You need access to this private Gitea repository and an internet connection to +download the build dependencies. + +**Requirements** + +- **Linux:** Install Git and a C compiler/linker (`build-essential` on + Ubuntu/Debian), then install **Rust 1.91 or newer** using + [rustup](https://rust-lang.org/tools/install/). +- **Windows:** Install [Git for Windows](https://git-scm.com/download/win) + and [Rust](https://rust-lang.org/tools/install/). Accept the prompt to + install Visual Studio C++ Build Tools, using the **Desktop development + with C++** workload. Reopen PowerShell after installation. + +**Download and build** + +Run these commands in a Linux terminal or Windows PowerShell. Authenticate +with Gitea when prompted. The first build may take several minutes. + +```sh +git clone --branch develop https://git.bloggin.space/dumbgoy/soulseek-rs-modded.git +cd soulseek-rs-modded +cargo build --release -p soulseek-rs +``` + +**Run on Linux** + +```sh +./target/release/soulseek-rs +``` + +**Run on Windows (PowerShell)** + +```powershell +.\target\release\soulseek-rs.exe +``` + +Log in with your Soulseek account. Press **s** to search, enter your query, +and press **Enter**. Focus the results pane with **2**, highlight a track, +then press **Shift+D** to download all files directly in its remote folder, +including files absent from the search results. **Space** selects individual +files and **Enter** downloads that selection. Press **?** for shortcuts or +**q** to quit. + +**Update later** + +Close the application, run these commands inside `soulseek-rs-modded`, then +launch it again with the command for your operating system: + +```sh +git pull --ff-only +cargo build --release -p soulseek-rs +``` + +### Upstream installation options + +The installers and packages below install the original upstream project. +Use the Gitea build instructions above for this repository's modifications. + +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). + +Or install it with a package manager. Stable follows the latest release: + +```bash +# Debian, Ubuntu, Mint (on ARM: arm64 for amd64) +curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb +sudo apt install ./soulseek-rs-amd64.deb + +# Fedora, RHEL (on ARM: aarch64 for x86_64) +sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm + +# Nix +nix run github:michel/soulseek-rs/master + +# Homebrew, on macOS and Linux +brew install michel/tap/soulseek-rs + +# cargo-binstall fetches the release binary and compiles nothing +cargo binstall soulseek-rs + +# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one +cargo install --locked soulseek-rs +``` + +Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix, +Homebrew and cargo build it from source: + +```bash +curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb +sudo apt install ./soulseek-rs-nightly-amd64.deb + +sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm + +nix run github:michel/soulseek-rs/develop + +brew install --HEAD michel/tap/soulseek-rs + +cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs +``` + +A `.deb` or `.rpm` does not update itself. Install the newer file over it. A +nightly sorts above the release it follows, so going back to stable is a +downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf +goes ahead. Both carry the man page and completions for bash, zsh and fish. +A release download prints the plain number, `19.0.0`. Any Nix build, and a +nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the +commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports +`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints +`19.0.0` too. + +From source, `cargo build --release` leaves the binary at +`target/release/soulseek-rs`. To build your own client on the protocol library, +run `cargo add soulseek-rs-lib`. + +To package it for a distribution: a musl build is static, a glibc build links +only libc, libm and libgcc_s, +`soulseek-rs completions print ` and `soulseek-rs man` write the +completion scripts and the man page to stdout, and a build with +`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm +recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging). + +## Usage + +Run it with no subcommand for the interactive TUI: + +```bash +soulseek-rs +``` + +Everything else is a one-shot command that runs headless. + +### The TUI + +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. `s` starts a search, and `S` runs the +highlighted one again. + +`o` opens settings: who this session is logged in as and how much it shares, +then the download and shared folders. `Enter` on a row acts on it — change the +password (typed twice, so a typo cannot leave the account with one nobody +knows) or log out, which forgets the stored password and comes back to the +login screen. Attached to a daemon the login is the daemon's, so the popup +offers no logout and a password change goes to the daemon. + +### Daemon mode + +A one-shot command logs in, does its job, and exits. Run a daemon instead and +it holds the session while everything else borrows it: + +```bash +soulseek-rs daemon # stays in the foreground; leave it running +soulseek-rs search 'aphex twin' # no login, no flags; uses the daemon +soulseek-rs # the TUI attaches to the same session +``` + +**Why you want this.** The server allows one session per account, so without a +daemon two commands at once cut each other off. A daemon is one login for as +long as you work, your shares indexed once instead of rescanned every +invocation, downloads that outlive the command that queued them, and chat +collected while nothing is attached. Driving this from a script or an agent, +start one. + +Commands find it themselves. It listens on `daemon.sock` in the state directory +(`~/.local/share/soulseek-rs/state`, or wherever `SOULSEEK_STATE_DIR` points), +openable only by your user, with the token remote clients need beside it in +`daemon.token`. Without one running everything behaves as it did before, and +`--no-daemon` forces that for a single run. + +Windows has no Unix socket. A daemon there needs `--bind ADDR`, and every +client needs `--daemon ADDR` and the token even on the same machine. + +From a script, start it and *wait* for it. The socket appears before the login +finishes, and a command arriving early waits on the handshake for up to 30 +seconds. Poll `daemon status` first. + +```bash +soulseek-rs daemon status --json >/dev/null 2>&1 || { + soulseek-rs daemon >/dev/null 2>&1 & + for _ in $(seq 30); do + soulseek-rs daemon status --json >/dev/null 2>&1 && break + sleep 1 + done +} +``` + +```bash +soulseek-rs daemon status # who it is, what it shares, who is attached +soulseek-rs daemon stop +``` + +Every window is a view of the same session. A second TUI shows the searches and +transfers already running, queueing in either shows up in both, and the status +bar names the daemon it attached to. Closing a window leaves its downloads +running. The settings popup edits the daemon's folders, validated there and +created nowhere on this machine, and writes the same paths into this machine's +`config.toml` for the next run without a daemon. + +Everything touching the network goes through it. `config`, `skills`, +`completions` and `portmap` stay local. `shares add`/`remove`, +`config set download_dir` and `config set shared_dirs` do both: they write the +config file *and* update a running daemon, so a folder is served or downloaded +into straight away rather than at the next start. Files land on the *daemon's* +filesystem, and `daemon status` reports where. + +It does not sweep the wishlist. Standing searches re-run inside +`serve --wishlist` or a one-off `wish run`, both of which borrow the daemon's +session, so schedule either one to work the list through. With a remote daemon +the list lives in the client's `config.toml`, so `wish run` from the laptop +drives the daemon against the laptop's wishes. + +`daemon --upload-slots N` sizes the upload queue for the session it holds, 1 to +1000, where `serve` stops at 64. + +#### A download box you drive from your laptop + +Put soulseek-rs on the machine that should be downloading: a home server, a +NAS, a Raspberry Pi. **On that machine**, let the daemon take connections from +your network: + +```bash +soulseek-rs daemon --bind 0.0.0.0:5030 +``` + +It prints the token on stderr at startup, with the two `config set` lines to +paste on the other machine. `daemon token` prints it again, always *this* +machine's, so run it on the box. It needs no terminal and nobody logged in; +hand it to systemd or launchd. Run the unit as the user who types the commands, +or export the same `SOULSEEK_STATE_DIR` in both, because a client looks for the +socket under its own state directory. + +**On your laptop**, say once where it lives: + +```bash +soulseek-rs config set daemon nas.local:5030 +soulseek-rs config set daemon_token +``` + +Every command after that is the normal command, and the laptop needs no +Soulseek account of its own: + +```bash +soulseek-rs get 'selected ambient works' +soulseek-rs daemon status # what it's doing right now +soulseek-rs daemon stop # shuts down the remote daemon +``` + +Downloads belong to the daemon, so closing the lid does not stop them. They +still honour the *command's* deadline: a `download` or `get` reaching +`--timeout` (300s) tells the daemon to drop the transfer before it exits 5. +Raise it for a big file, or queue from the attached TUI and quit. + +`shares add` resolves paths against the machine you type it on, so +`shares add /srv/music` from the laptop exits 2 unless the laptop has that path +too. Add the box's folders on the box. Pushing a share list or download folder +to a remote daemon changes its live session while writing the *laptop's* config +file, so put the change in the daemon's own `config.toml` as well, or push it +again after a restart. + +That TCP port has no encryption. Fine on a home network; do not expose it to +the internet. Reach it over SSH instead, with a tunnel +(`ssh -L 5030:localhost:5030 nas`) or by running the commands on the box. + +#### Build your own remote + +The daemon speaks newline-delimited JSON-RPC 2.0, described by an +[OpenRPC](https://open-rpc.org) document, +[`soulseek-rs/docs/openrpc.json`](soulseek-rs/docs/openrpc.json), which it also serves over +`rpc.discover`. Point a client generator at it for typed bindings in your +language. Searches, transfers, rooms, private messages and shares are all on +the interface this CLI uses, with no private back channel, and live updates are +pushed rather than polled. + +Keep both ends on the same release; the handshake refuses mismatched protocol +versions. The destination is session-wide, so `download.set_dir` moves it for +every transfer and `download.start` ignores any directory the caller names. +[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the +handshake, and how pushed events behave. + +### Scripting + +The one-shot commands follow three rules, so they compose with other tools: + +- **stdout is data.** One record per line, and nothing else. Progress, + warnings, and errors go to stderr; `--quiet` silences the progress. +- **`--json` emits newline-delimited JSON**, one object per line. Without it, + records are tab-separated fields with no header and no decoration. +- **The exit code is the verdict.** No output plus exit 0 never means failure. + +| Code | Meaning | +| ---- | ----------------------------------------------------------------- | +| 0 | success | +| 1 | unexpected error | +| 2 | bad arguments, missing credentials, unusable configuration | +| 3 | could not reach the server or a daemon, or the login was rejected | +| 4 | the command worked but found nothing | +| 5 | timed out waiting for a response or a transfer | +| 6 | a transfer started but did not finish | +| 7 | the session ended mid-command; nothing it saw is reliable | + +#### Commands + +```bash +soulseek-rs search # print matching files +soulseek-rs download # fetch one known file +soulseek-rs download --stdin # fetch files listed on stdin +soulseek-rs get # search, pick, and download +soulseek-rs transfer cancel # stop a download or upload +soulseek-rs wish add|remove|list # keep looking for something +soulseek-rs wish run # search every stored wish once +soulseek-rs browse # list a user's shared files +soulseek-rs room list # public rooms and user counts +soulseek-rs room say # post to a room +soulseek-rs room listen # stream room messages and joins +soulseek-rs message send # send a private message +soulseek-rs message read # stream incoming private messages +soulseek-rs room users # who is in a room +soulseek-rs serve [--follow] # stay online sharing, stream uploads +soulseek-rs daemon [--bind ADDR] # run as a service others share +soulseek-rs daemon token|status|stop # control a running one +soulseek-rs whoami # confirm credentials and connection +soulseek-rs account password # change the account's password +soulseek-rs account logout # forget the stored password +soulseek-rs user # a peer's status and share counts +soulseek-rs shares list|add|remove|status|reindex +soulseek-rs config path|list|get|set +soulseek-rs portmap # test automatic port mapping +soulseek-rs skills install|uninstall|list # teach a coding agent this CLI +soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish +soulseek-rs completions print # the script on stdout, for packaging +soulseek-rs man # the man page on stdout +``` + +A script usually starts with `whoami`, which answers "are these credentials +good and what am I offering" in one call. + +With a daemon attached every command reports the daemon's world: `--username` +and `--password` go unread, and `whoami` names the daemon's account, server and +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. + +Twenty-two need no credentials: `config path|list|get|set`, +`shares list|add|remove`, `wish add|remove|list`, `portmap`, +`skills install|uninstall|list`, `completions install|uninstall|print`, `man`, +`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 +means logging in and scanning the folders. + +Seven commands perform an action and print nothing, answering with their exit +code alone: `room say`, `message send`, `shares add`, `shares remove`, +`wish remove`, `transfer cancel` and `daemon stop`. `room listen`, `message read` and `serve` +stream records until `--duration` seconds pass or, with `--follow`, until +interrupted. The rest print records and exit. + +#### Record shapes + +Text mode puts the identifying fields first and the remote path last, because +a path may contain anything except a tab (tabs and newlines in peer-supplied +text are replaced with spaces so a record is always exactly one line): + +``` +search user size bitrate path +browse user size path +download / get local-path +room list users room +room users user +room listen type room user message +message read timestamp user message +serve status user sent/size path +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 +portmap ok|failed backend external +``` + +`room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves +the message field empty. `serve`'s `status` is `queued`, `uploading`, +`completed`, `cancelled` or `failed`. `user`'s `status` is `online`, `away` or +`offline`, and any field the server did not answer prints as `-`. + +`--json` carries more than text does: + +| Command | Extra fields | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `search` | `duration`, `slots`, `speed`, `free_slot` | +| `browse` | `directory`, `bitrate`, `duration` | +| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` | +| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` | +| `user` | `privileged`, `shared_folders` | +| `shares status` | the `directories` array | +| `serve` | `bytes_sent`, `size`, `speed`, `reason` (queue place or failure) | +| `daemon status` | `version`, `protocol`, `listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, `session_loss` | +| `portmap` | `port` | + +`session_loss` is `displaced`, `disconnected`, or `null` while the session +holds. Fields the server never answered are `null` rather than absent, so a +missing reply cannot be misread as a zero. + +`download --stdin` reads back either shape it emits: a JSON object per line, +or tab-separated text whose first field is the user and last field is the +path. That is why `search` and `browse` pipe straight into it. + +#### Recipes + +```bash +# Everything above 320kbps from peers with a free slot, best first +soulseek-rs search "aphex twin xtal" --min-bitrate 320 --free-slots + +# One command, start to finish: search, pick the best, download it +soulseek-rs get "aphex twin xtal" + +# Filter with jq, then fetch what survived +soulseek-rs search "rjd2" --json \ + | jq -c 'select(.size > 5000000 and .free_slot)' \ + | soulseek-rs download --stdin + +# Grab a peer's whole FLAC folder +soulseek-rs browse someuser | grep -i '\.flac$' | soulseek-rs download --stdin + +# Branch on the outcome +if soulseek-rs get "some rare track" --quiet; then + echo "got it" +elif [ $? -eq 4 ]; then + echo "nobody has it" +fi + +# Nobody has it today: keep looking, and fetch it the day someone does +soulseek-rs wish add "some rare 12 inch" +soulseek-rs wish run --json | soulseek-rs download --stdin + +# Only lossless, nothing from a bootleg folder, album-sized files only +soulseek-rs search "boards of canada" \ + --extension flac --exclude bootleg --min-size 20000000 + +# Log room chat as JSON until interrupted +soulseek-rs room listen lobby --follow --json >> lobby.ndjson + +# Be a peer: share a folder and log every upload served +soulseek-rs shares add ~/Music +soulseek-rs serve --follow --json | tee -a uploads.ndjson + +# Decide whether a peer is worth queueing to before committing +soulseek-rs user someuser --json | jq -e '.status != "offline"' +``` + +### Agent skills + +[![an agent loading the soulseek-rs skill and downloading tracks](website/public/agent-demo.svg)](https://re-invention.nl/soulseek-rs/#agents) + +`--help` lists flags but cannot say which JSON keys a record carries or what +exit 4 means. That lives in a skill file shipped inside the binary: + +```bash +soulseek-rs skills install +``` + +It writes `SKILL.md` into every agent it finds in your home directory, today +Claude Code (`~/.claude/skills`) and opencode (`~/.config/opencode/skill`), and +prints one record per target: + +``` +installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md +unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md +``` + +Rerun it after upgrading the binary; an identical copy reports `unchanged`. +`skills list` shows what is there, and `skills uninstall` removes only the file +this binary wrote. For any other agent, name the directory yourself, or commit +the skill next to a project so everyone working on it gets the same brief: + +```bash +soulseek-rs skills install --dir ~/.config/some-agent/skills +soulseek-rs skills install --dir .claude/skills # commit it with the repo +``` + +### Shell completions + +Tab completion comes from the same `clap` definition the binary parses with, so +it never drifts from `--help`: + +```bash +soulseek-rs completions install +``` + +It covers whichever of bash, zsh and fish this machine uses, writing each script +where that shell already looks. `~/.config/fish/completions` is scanned +unprompted; bash and zsh also get one marked `source` line appended to +`~/.bashrc` or `~/.zshrc`, since nothing else puts a per-user directory on their +search path. Open a new shell and `soulseek-rs sh` completes. + +``` +installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs +installed fish /home/you/.config/fish/completions/soulseek-rs.fish +``` + +`completions uninstall` removes the scripts and takes back exactly the lines it +added, leaving the rest of your rc file alone. Pass `--shell bash` (repeatable) +to act on one shell rather than the ones detected. + +### Sharing + +`serve` is the mode that makes this client a source: it stays logged in, keeps +the listener and share index alive, answers searches and browse requests, and +prints one record per upload as it changes state (`queued`, `uploading`, +`completed`, `cancelled`, `failed`). It ends after `--duration` seconds (an +hour by default), or runs until interrupted with `--follow`. + +```bash +soulseek-rs shares add ~/Music # remembered in config.toml +soulseek-rs shares status # what the network will see +soulseek-rs shares reindex # after adding files on disk +soulseek-rs serve --follow # add --upload-slots N to widen the queue +``` + +Uploads only appear inside a running `serve`, since every other command is +short-lived with nothing to serve, so there is no `uploads list`. Pausing, +resuming, and a queue that outlives one invocation are +[daemon mode](#daemon-mode). + +With a daemon running `serve` starts no second server. It attaches and streams +the daemon's uploads, `--upload-slots` resizes the daemon's queue, and the local +"nothing to share" and "needs the listener" checks are skipped because the +daemon's shares and listener are the ones peers reach. + +Downloads run under `--timeout` (300s) and `--max-concurrent-downloads` at a +time, so an unattended run always ends. An interrupted one resumes: bytes stream +into `.part` and it is renamed only once whole, so re-running the same +`download` asks the peer to start at the offset already on disk. Partial files +are never offered to peers, and deleting the `.part` starts over. + +### Configuration + +Every setting can come from a flag, an environment variable, or the config +file, in that order of precedence. Flags work before or after the subcommand. + +| Flag | Environment variable | `config.toml` key | Default | +| ------------------------------ | ----------------------------------- | ---------------------------- | ------------------------- | +| `--username` | `SOULSEEK_USERNAME` | `username` | required | +| `--password` | `SOULSEEK_PASSWORD` | never stored in the file | required | +| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | unset | +| `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` | +| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `/Soulseek` | +| `--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 | +| — | — | `use_username_dirs` | 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 | +| `--daemon-token` | `SOULSEEK_DAEMON_TOKEN` | `daemon_token` | unset (TCP only) | +| `--no-daemon` | — | — | attach if one is running | +| `--log-file` | `SOULSEEK_LOG_FILE` | not a config key | stderr | + +Boolean environment variables accept `1`/`0`/`true`/`false`/`yes`/`no`. +`--config ` (or `SOULSEEK_CONFIG`) reads a different file, `--no-config` +ignores it entirely, and `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate +the config and state directories wholesale. The file lives at +`~/.config/soulseek-rs/config.toml` on macOS and Linux. Every variable above is +also read from a `.env` in the working directory, usually the tidiest way to +hand a container its credentials. + +`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 thirteen settings the file holds +(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`, +`download_dir`, `use_username_dirs`, `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 +than hanging. + +#### Credentials for unattended runs + +Four ways to supply a password, in the order they are consulted: + +1. `--password-stdin`: read the first line of stdin, like `docker login`. +2. `--password` / `SOULSEEK_PASSWORD`: convenient, but visible in `ps`. +3. The **OS keychain** (macOS Keychain, Windows Credential Manager, Linux + Secret Service), under service `soulseek-rs` and your username. The TUI + stores your password here after a successful login. +4. `password_cmd` / `--password-cmd`: a shell command whose stdout is the + password, e.g. `pass show soulseek`. Best for headless boxes with no + keychain. + +```bash +# Nothing sensitive on the command line or in the environment +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. + +### Chat and messages + +```bash +soulseek-rs message send "hello there" +soulseek-rs message read --duration 60 # or --follow until killed +soulseek-rs room list # public rooms with user counts +soulseek-rs room listen # stream messages, joins, leaves +soulseek-rs room say "hello room" # post one message and exit +``` + +In the TUI, `m` composes a private message and `i` opens the inbox with an +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. +`/` 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 + +Browsing and downloading are peer-to-peer, so one side has to accept an incoming +connection. With the listener on (the default) the client opens its listen port +via **UPnP-IGD** and **NAT-PMP**, renews it while running, and removes it on +exit. Best-effort: a router with both disabled makes it a no-op, and a log line +says to forward the port yourself. + +- The port is `--listener-port` (`SOULSEEK_LISTENER_PORT`, default `2234`). + Forward that TCP port when auto-mapping cannot get it. +- `--no-listener` turns the listener and port mapping off; `--listener` forces + it on when the config file disables it. +- When another process already holds the port, the usual case with several + one-shot commands at once, the client takes one the OS picks, says so on + stderr, and advertises that one. `whoami` reports what it really holds. + +```bash +soulseek-rs portmap # exits 0 when mapping works, 4 when it does not +soulseek-rs portmap --json # {"ok":true,"backend":"upnp","external":"…"} +``` + +`portmap` opens the port, reports whether the router allowed it and your +external address, then removes the test mapping. The verdict is the exit code, +so `soulseek-rs portmap || notify-me` works as a health check. + +Two peers both behind routers with no forwarded port cannot browse each other. +That is a Soulseek limitation, not a bug. + +## Development + +```bash +RUST_LOG=trace cargo run # run with debug and trace output +cargo test +cargo clippy +cargo fmt +``` + +### End-to-end tests + +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. + +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: +SOULFIND_BIN=/path/to/soulfind/bin/soulfind \ + cargo test --workspace -- --nocapture +``` + +> On macOS, soulfind's `sqlite3_config` call is rejected by the system SQLite +> and the server dies at startup with `SQLite error 7 (out of memory)`, which +> the suite reports as a skip. Run the tests with +> `DYLD_LIBRARY_PATH=/opt/homebrew/opt/sqlite/lib` so soulfind picks up +> Homebrew's SQLite instead; no patching needed. + +Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure +instead of a skip. CI sets it, so the e2e suite runs against a freshly built +soulfind instead of skipping. + +### Continuous integration + +`.github/workflows/ci.yml` runs on every push and pull request: + +- **Format & Clippy**: `cargo fmt --all --check` and + `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 all four e2e suites with + `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping. + +## Contributing + +Issues and pull requests both welcome. Branch off `develop`, keep the change +small, and see [CONTRIBUTING.md](./CONTRIBUTING.md) for the commit convention +and the checks CI runs. + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..941db0f --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,120 @@ +# Releasing + +Releases are automated by [release-plz](https://release-plz.dev). You do not run +`cargo publish`, edit versions, write changelog entries, or push tags by hand. + +## How it works + +1. Merge a PR to `master` using [conventional commits](https://www.conventionalcommits.org) + (`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `chore:`, or a `!` / `BREAKING CHANGE:` + footer for a major bump). +2. `.github/workflows/release.yml` opens or updates a **`chore: release vX.Y.Z`** PR. It + contains the semver bump in `[workspace.package]`, the refreshed `Cargo.lock`, and the + generated `CHANGELOG.md` entry. Review it like any other PR. +3. Merging that PR releases, but only once CI has passed on the merge commit — a published + crates.io version can be yanked but never withdrawn, so the `Wait for CI` job blocks the + publish until the CI run for that exact commit is green: + - tags `vX.Y.Z` and creates the GitHub release with the changelog as its notes, + - publishes `soulseek-rs-lib` then `soulseek-rs` to crates.io (dependency order, waiting + for the index between them), + - builds and attaches binaries with `.sha256` checksums for six targets: + + | Archive | Notes | + | ------------------------------------------------ | ------------------------------ | + | `soulseek-rs-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz` | static, any glibc | + | `soulseek-rs-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz` | static, any glibc | + | `soulseek-rs-vX.Y.Z-x86_64-apple-darwin.tar.gz` | Intel Mac | + | `soulseek-rs-vX.Y.Z-aarch64-apple-darwin.tar.gz` | Apple Silicon | + | `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | + | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | + + - packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`, + `soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the + bash, zsh and fish completions. The names carry no version, so + `releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working, + + - 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, + - 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, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`, +verifies all sixteen files as one set, then replaces the mutable `nightly` +prerelease and tag. Every nightly binary reports `X.Y.Z+git.` +instead of the crate version. The published release is smoke-tested through the +curl and wget installer paths, and by installing the `.deb` and the `.rpm`. + +Never enable GitHub's Immutable Releases on this repository: nightly replaces +its assets and moves its tag, and a tag name burnt by that setting cannot be +reused. + +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 + create and approve pull requests*. Without it the release PR is never opened. +- **Settings → Secrets and variables → Actions**: add `CARGO_REGISTRY_TOKEN`, a + [crates.io token](https://crates.io/settings/tokens) scoped to `publish-new` and + `publish-update`. Without it the tag and GitHub release still happen, but the crates.io + publish fails. +- **Homebrew tap**: create a public `michel/homebrew-tap` repository, then add a + `HOMEBREW_TAP_TOKEN` secret — a fine-grained PAT with *Contents: read and write* on that + repository only. `GITHUB_TOKEN` cannot reach another repository. The formula itself is + generated by the workflow; nothing in the tap needs editing by hand. + +## Notes + +- A merge with no releasable commits (only `docs:`/`chore:`/`test:`) opens no release PR. + That is intended. +- The release PR does not run CI on its own, because GitHub does not trigger workflows for + PRs opened with `GITHUB_TOKEN`. Pushing any commit to that branch yourself does start it — + handy if you want the version bump validated before merging. +- If one target's build fails, the other five still upload (`fail-fast: false`). To retry, + run the **Release** workflow manually and set `tag` to the existing tag (e.g. `v6.0.0`); + it rebuilds and re-uploads every target for that tag. Re-running the failed job from the + Actions UI replays the *old* workflow file, so it is no use when the workflow is the thing + that needs fixing. +- To skip a release for a commit that would otherwise trigger one, use a `chore:` type. +- **If CI flakes on the release commit the publish is blocked, by design.** The e2e suite + talks to a real soulfind server over the network and does occasionally time out. To + recover: re-run the failed CI job, wait for that run to go green, then re-run the + **Release** workflow. The gate reads the *newest* CI run for the commit, so it will then + pass. Never work around it by publishing by hand. +- Release binaries are stripped via `[profile.release] strip = true` in the workspace + `Cargo.toml`, so `cargo install` benefits too — not just the release archives. + +## Installing a release + +```bash +cargo install soulseek-rs +``` + +Or download an archive from the [releases page](https://github.com/michel/soulseek-rs/releases), +verify it against the adjacent `.sha256`, and extract the binary. + +## Using the library + +```bash +cargo add soulseek-rs-lib +``` diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..248181d --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,83 @@ +# Concurrency benchmark + +Answers one question: how much simultaneous Soulseek activity can the client +absorb before it starts losing work? + +```sh +scripts/stress.sh # run the default profile +scripts/stress.sh --save # ...and store it as the new baseline if it wins +``` + +A run takes about 7 seconds. It spawns a local soulfind, surrounds one real +`Client` with a swarm of mock peers, and drives searches, downloads, uploads and +browses **at the same time** — the mixing is the point, since every ceiling found +so far only appeared under a mixed load. + +## The score + +One number out of 100, weighted across five dimensions: + +| Dimension | Weight | Measures | +|------------|--------|-------------------------------------------------| +| downloads | 0.30 | completed / started | +| uploads | 0.20 | served / requested | +| searches | 0.20 | queries that got at least one result | +| browses | 0.15 | browse requests answered | +| throughput | 0.15 | aggregate MiB/s against `TARGET_MBPS` (500) | + +The four functional dimensions should stay at 100%: anything less means work was +silently dropped, which is the failure mode that matters. Throughput is the term +with headroom, so the score still moves when transfer handling regresses. + +`--save` only overwrites `stress-baseline.json` when the run beats it, so the +baseline ratchets upward. Without `--save` a run just prints the delta. + +## The gate + +`STRESS_REQUIRE_FUNCTIONAL=1` makes the run exit 1 when any of the four +functional dimensions is below 100%. CI runs it that way on every pull request +with an eighth of the default load; throughput never fails the gate because it +measures the machine, not the client. + +## Tuning the load + +Every knob is an env var; the defaults are in `Config::from_env`. + +```sh +STRESS_PEERS=2048 STRESS_FILE_KB=8192 scripts/stress.sh +``` + +`STRESS_PEERS` `STRESS_SEARCHES` `STRESS_DOWNLOADS` `STRESS_LEECHERS` +`STRESS_BROWSES` `STRESS_FILE_KB` `STRESS_TIMEOUT` + +## Reading a failure + +`downloads completed 480/512` is the headline, but the two lines under it say +where to look: + +- **`download states`** — a straggler on `Queued` never got its transfer + negotiated; one on `InProgress` is merely slow. These are different bugs. +- **`mocks never online`** — mock peers the harness could not bring up. That is + a harness/server limit, not the client, and it is reported separately so it + cannot quietly shrink a denominator and flatter the score. + +`peak threads` counts the whole process — client *and* mock swarm — so treat it +as a trend, not a client measurement. + +`LOG_LEVEL=debug scripts/stress.sh` correlates a stuck peer's name against the +client's own trace, which is how each ceiling below was actually found. + +## Ceilings found and removed + +| Symptom | Cause | +|----------------------------------------------------|-------------------------------------------------------------------------------| +| Hard stop at ~15 peers; later peers never start | Every `PeerActor` held a `ThreadPool` worker for life, so the pool capped peers at one per core | +| Downloads stuck on `Queued` under load | `TransferResponse` was sent before the client recorded the peer's token, so the file connection raced its own bookkeeping | +| One download vanishing per few hundred | Token was `md5(filename)`, and the store deletes *every* entry with a token — same filename from two peers destroyed both | +| Uploads dropped after the peer accepted | Server answered `GetPeerAddress` with port 0; the client cached it and dialled it | + +## Requirements + +A soulfind binary (`SOULFIND_BIN`, defaulted in `scripts/stress.sh`), or +`SOULSEEK_TEST_SERVER=host:port` to point at a running server. The script raises +the descriptor limit — a swarm holds a lot of sockets open. diff --git a/benchmarks/stress-baseline.json b/benchmarks/stress-baseline.json new file mode 100644 index 0000000..9bec249 --- /dev/null +++ b/benchmarks/stress-baseline.json @@ -0,0 +1,23 @@ +{ + "score": 97.78, + "parts": { + "downloads": 1.0000, + "uploads": 1.0000, + "searches": 1.0000, + "browses": 1.0000, + "throughput": 0.8522 + }, + "load": { + "peers": 1024, + "searches": 128, + "downloads": 512, + "leechers": 256, + "browses": 128, + "file_bytes": 4194304 + }, + "observed": { + "downloads_completed": 512, + "uploads_completed": 256, + "bytes": 3221225472 + } +} diff --git a/docs/daemon-protocol.md b/docs/daemon-protocol.md new file mode 100644 index 0000000..7f139fd --- /dev/null +++ b/docs/daemon-protocol.md @@ -0,0 +1,184 @@ +# The daemon control protocol + +`soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so +several clients can share one Soulseek session. The machine-readable contract +is [`openrpc.json`](../soulseek-rs/docs/openrpc.json), an [OpenRPC](https://open-rpc.org) +document and the JSON-RPC counterpart to OpenAPI. Point a client generator at it, +or at a running daemon via `rpc.discover`, and you have a client in your +language. + +This page covers what a schema cannot: framing, the handshake, and how pushed +events behave. + +## Framing + +Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions, +UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on +an established connection it stays open, but the *first* line has to be a valid +`auth` request, so a parse error there ends the connection. + +Lines are capped at 1 MiB and a connection that has not authenticated within +30 seconds is closed. The daemon serves at most 64 control connections at +once. + +## Transports and authentication + +| Transport | Address | Authentication | +|---|---|---| +| Unix socket | `/daemon.sock`, mode 0600 | The socket's permissions. `token` may be omitted. | +| TCP | opt-in via `daemon --bind ADDR` | The token in `/daemon.token`, mode 0600. | + +The local socket is the Docker and ssh-agent bargain: if the operating system +let you open it, you are the user who started the daemon, and there is nothing +further to prove. A local client, whether a script or an agent, works with no +configuration at all. + +TCP always requires the token, and a token that *is* offered is always +checked, on either transport, so a wrong one never passes. Failed attempts +sleep one second. The token is 256 bits from the OS, generated on first start +and reused across restarts so existing clients keep working; +`soulseek-rs daemon token` prints it. + +There is no transport encryption. Binding non-loopback TCP means putting SSH +or a TLS-terminating proxy in front of it. + +`` is `$SOULSEEK_STATE_DIR`, else the platform data directory +(`~/.local/share/soulseek-rs/state` on Linux and macOS). + +### The handshake + +`auth` must be the first request on a connection. Anything else is answered +`-32600` and the connection is dropped. Until it succeeds the connection is +told nothing and receives no events. + +```json +--> {"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}} +<-- {"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}} +``` + +Compare `protocol` with your own and fail loudly on a mismatch rather than +guessing; it is bumped whenever a change would break a client written against +the old shape. + +## Requests + +Parameters are by name: a JSON object, never an array. Each method's fields +are published as individual OpenRPC content descriptors, so a generated client +sends exactly the object the daemon deserializes. `rpc.discover` returns that +same document from the running daemon. + +An event is the exception: its `params` object *is* the payload, published as +a single `payload` descriptor because several payloads are tagged unions with +no fixed field set. + +Requests may be pipelined and are answered out of order, so match replies by +`id`. A request without an `id` is a notification and is answered with +silence, including when it fails. + +## Errors + +```json +{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}} +``` + +An error always carries an `id`, explicitly `null` when the request was too +malformed to have one. Do not read a missing `id` as "this is a notification" +and drop it. + +Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown +method, `-32602` bad parameters) mean what they always mean. Application +failures use `-32000` and carry `data.exit`: the CLI exit status the same +operation would have produced locally, so a wrapper can branch on it exactly +as it branches on the exit code. + +| `exit` | Meaning | +|---|---| +| 2 | Usage or configuration | +| 3 | Could not reach the server, or the login was rejected | +| 4 | Succeeded but produced nothing | +| 5 | Timed out | +| 6 | A transfer started but did not complete | +| 7 | The server session ended mid-operation | + +## Events + +The daemon pushes notifications. They carry no `id`, so never answer them: + +```json +{"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}} +``` + +`event.room`, `event.message`, `event.upload`, `event.download_status`, +`event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`, +where they appear as entries marked `x-notification`. OpenRPC has no +first-class notion of a server-pushed message, so that flag is how they are +distinguished from methods you may call. + +`message.history` and `event.message` describe the same conversation in +different shapes, deliberately: a live event is the message as the server +delivered it (`id`, `timestamp`, `username`, `message`), while history is a +chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it +includes what this account sent. + +**Events are the only way to observe these things, and they are not +replayable.** The underlying library hands over its event buffer and empties +it, so the daemon drains once and copies to every attached connection. A +client that is not connected when something happens does not learn about it +later, with one exception: private messages are also kept, and +`message.history` returns the conversation including what arrived while +nobody was attached. + +Every connection gets its own queue, bounded at 1024 events. **A client that +stops reading its socket is disconnected** once that queue fills. The daemon +will not let one stalled consumer cost the others their events. Read +continuously; do not treat the socket as request/response only. + +A `download.start` you issued reports progress through `event.download_status` +matched on `username` + `filename`. + +## Behaviour worth knowing + +**Files land on the daemon's filesystem.** The directory is the daemon's +configuration and a client cannot name one, which would be an arbitrary write +on the daemon's host. `daemon.status` reports the directory in use. There is +no method to fetch the bytes over the control socket. + +**Configuration is per machine, shares are per session.** A client's config +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. + +**The daemon owns durable state.** Transfers, joined rooms, and the +conversation survive a restart: unfinished transfers are re-queued and rooms +rejoined on startup. + +**Searching is two steps.** `search.start` puts the query on the wire and +returns immediately; peers answer over their own connections for as long as +you care to wait. Decide the window yourself, then read `search.results`. This +is deliberate: a client's search window must not be time the daemon spends not +answering everybody else. + +## A session by hand + +```console +$ nc -U ~/.local/share/soulseek-rs/state/daemon.sock +{"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}} +{"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}} +{"jsonrpc":"2.0","id":2,"method":"search.start","params":{"query":"aphex twin"}} +{"jsonrpc":"2.0","id":2,"result":{"ok":true}} +{"jsonrpc":"2.0","id":3,"method":"search.results","params":{"query":"aphex twin"}} +{"jsonrpc":"2.0","id":3,"result":{"results":[…]}} +``` + +That the whole protocol is reachable from `nc` and a text editor is the reason +it is shaped this way. diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md new file mode 100644 index 0000000..134d633 --- /dev/null +++ b/docs/protocol-coverage.md @@ -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. diff --git a/fixtures/demo.svg b/fixtures/demo.svg new file mode 100644 index 0000000..92aa121 --- /dev/null +++ b/fixtures/demo.svg @@ -0,0 +1 @@ +╭Status────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,0que│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╭[2]Results───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails│││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯╭Shortcuts·Sharing:~/Music──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[z→zoom][w→hide][?→keys][q→quit]│╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│search:│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││search:public││search:publicdomain││search:publicdomainfield││search:publicdomainfieldrecordings│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails││›Activepublic domain fi…│││││││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[Tab/1-3→pane][z→zoom][w→hide][?→keys][q→quit]││✓FilenameFolderSizeUserBitrateSpeedSlots││›[ ]01 - Coastal Dawn (Sunrise Take, Long Version).f@@gufoo\Field Recordings\Coastal30.5 MBloopdigger675 kbps3.35 MB/s1││[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1││[]05-HarbourBells.flac@@gufoo\FieldRecordings\Coastal52.5MBloopdigger902kbps3.35MB/s1││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1││[]cover.jpg@@gufoo\FieldRecordings\Coastal0.4MBloopdigger-3.35MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││01-CoastalDawn(SunriseTake,Long││›Activepublic domain fi7││││Version).flac││││││@@gufoo/FieldRecordings/CoastalDawn││││││Sessions(2019)[FLAC24-96]││││││Userloopdigger││││││Size30.5MB││││││Bitrate675kbps││││││Length6m52s││││││Freeslots1available││││││Uploadspeed3.4MB/s││[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2││›Activepublic domain fi11││││Version).flac││[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1││›[ ]02 - Reservoir Wind.flac@@gufoo\Field Recordings\Coastal22.6 MBloopdigger569 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││02-ReservoirWind.flac││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDawn││││││Size22.6MB││││││Bitrate569kbps││││││Length5m18s││›[ ]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││03-MarshatNight(Long).flac││││││Size48.0MB││││││Bitrate920kbps││││││Length8m21s││[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0││[]readme.txt@@hnttf\complete\Explorersofth0.1MBnectoUA-1.05MB/s0││›Activepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││›[✓]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1││[✓]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││›[ ]04 - Tram Depot, 06h.flac@@gufoo\Field Recordings\Coastal32.2 MBloopdigger725 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││04-TramDepot,06h.flac││││││Size32.2MB││││││Bitrate725kbps││││││Length6m06s││›[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││05-HarbourBells.flac││││││Size52.5MB││││││Bitrate902kbps││││││Length9m33s││›[✓]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1││›Donepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,2que││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│✓FilenameFolderSizeUserBitrateSpeedSlots││[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1││[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1││›[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1││[]cover.jpg@@gufoo\FieldRecordings\Coastal0.4MBloopdigger-3.35MB/s1││[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2││[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0││[]readme.txt@@hnttf\complete\Explorersofth0.1MBnectoUA-1.05MB/s0│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]││││││││││││Userloopdigger││││││Size52.5MB││││││StatusQueued││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s│╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c→clearfinished][b→browseuser][h/l→scrollname]││││││StatusInprogress││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que5.6/100.5MB·18.61MB/s·[█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]5%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger2.9 MB/52.5 MB (5%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger2.7MB/48.0MB(5%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que11.2/100.5MB·18.61MB/s·[███░░░░░░░░░░░░░░░░░░░░░░░░░░░░]11%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger5.8 MB/52.5 MB (11%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger5.3MB/48.0MB(11%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que16.8/100.5MB·18.61MB/s·[█████░░░░░░░░░░░░░░░░░░░░░░░░░░]16%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger8.8 MB/52.5 MB (16%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger8.0MB/48.0MB(16%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que25.1/100.5MB·18.61MB/s·[███████░░░░░░░░░░░░░░░░░░░░░░░░]25%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger13.1 MB/52.5 MB (25%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger12.0MB/48.0MB(25%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que30.7/100.5MB·18.61MB/s·[█████████░░░░░░░░░░░░░░░░░░░░░░]30%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger16.0 MB/52.5 MB (30%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger14.7MB/48.0MB(30%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que36.3/100.5MB·18.61MB/s·[███████████░░░░░░░░░░░░░░░░░░░░]36%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger19.0 MB/52.5 MB (36%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger17.3MB/48.0MB(36%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que41.9/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]41%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger20.0MB/48.0MB(41%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.6MB/41.0MB(13%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger20.0MB/48.0MB(41%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.0MB/41.0MB(14%)3.7MB/s││[Tab/1-3→pane][z→unzoom][w→hide][?→keys][q→quit]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que44.7/100.5MB·18.61MB/s·[█████████████░░░░░░░░░░░░░░░░░░]44%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger23.3 MB/52.5 MB (44%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger21.3MB/48.0MB(44%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que50.2/100.5MB·18.61MB/s·[███████████████░░░░░░░░░░░░░░░░]50%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger26.2 MB/52.5 MB (50%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger24.0MB/48.0MB(50%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que55.8/100.5MB·18.61MB/s·[█████████████████░░░░░░░░░░░░░░]55%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger29.2 MB/52.5 MB (55%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger26.7MB/48.0MB(55%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que61.4/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]61%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger32.1 MB/52.5 MB (61%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger29.3MB/48.0MB(61%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que67.0/100.5MB·18.61MB/s·[████████████████████░░░░░░░░░░░]66%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger35.0 MB/52.5 MB (66%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger32.0MB/48.0MB(66%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que72.6/100.5MB·18.61MB/s·[██████████████████████░░░░░░░░░]72%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger37.9 MB/52.5 MB (72%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger34.7MB/48.0MB(72%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que78.2/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]77%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger40.8 MB/52.5 MB (77%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger37.3MB/48.0MB(77%)8.9MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger38.7MB/48.0MB(80%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.6MB/41.0MB(33%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que83.8/100.5MB·18.61MB/s·[█████████████████████████░░░░░░]83%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger43.8 MB/52.5 MB (83%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger40.0MB/48.0MB(83%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.0MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que89.3/100.5MB·18.61MB/s·[███████████████████████████░░░░]88%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger46.7 MB/52.5 MB (88%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger42.7MB/48.0MB(88%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que94.9/100.5MB·18.61MB/s·[█████████████████████████████░░]94%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger49.6 MB/52.5 MB (94%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger45.3MB/48.0MB(94%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que97.7/100.5MB·18.61MB/s·[██████████████████████████████░]97%││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›↯@@gufoo\Floopdigger51.0 MB/52.5 MB (97%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger46.7MB/48.0MB(97%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer17.0MB/41.0MB(41%)3.7MB/s││││││││Userloopdigger││││││Size52.5MB││││││StatusInprogress││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s││soulseek-rs🦀v14.1.2Downloads:0active,2completed,0failed,0que││›Donepublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn││││✓@@gufoo\FloopdiggerCompleted-││Sessions(2019)[FLAC24-96]││││││StatusCompleted││││↑↯HarbourBcrateminer19.9MB/41.0MB(48%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────╮╭[Info]──────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›✓@@gufoo\Field Recordings\CoastalloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawnSessions││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││↑↯HarbourBells(LiveattheQuay)crateminer20.3MB/41.0MB(49%)3.7MB/s││││││Userloopdigger││││Size52.5MB││││Bitrate902kbps││││Length9m33s││││││││Freeslots1available││││Uploadspeed3.4MB/s│╰────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯│↑↯HarbourBells(LiveattheQuay)crateminer27.6MB/41.0MB(67%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.2MB/41.0MB(71%)3.7MB/s││││[]…CoastalDawn(SunriseTake,LongVersion).flac…wnSessions(2019)[FLAC24-96]30.5MBloopdigger675kbps3.35MB/s1││[]02-ReservoirWind.flac…wnSessions(2019)[FLAC24-96]22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac…wnSessions(2019)[FLAC24-96]48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac…wnSessions(2019)[FLAC24-96]32.2MBloopdigger725kbps3.35MB/s1││›[ ]05 - Harbour Bells.flac…wn Sessions (2019) [FLAC 24-96]52.5 MBloopdigger902 kbps3.35 MB/s1││[]06-RainonZincRoof.flac…wnSessions(2019)[FLAC24-96]30.4MBloopdigger804kbps3.35MB/s1││[]01-EmptyChapelTone.flac…Recordings,Vol.2(CreativeCo17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flac…Recordings,Vol.2(CreativeCo38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flac…Recordings,Vol.2(CreativeCo34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flac…Recordings,Vol.2(CreativeCo18.7MBtapehiss723kbps2.1MB/s2││[]01-Opener.mp3…nterior-FieldRecordings20049.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3…nterior-FieldRecordings20045.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3…nterior-FieldRecordings200419.5MBnectoUA320kbps1.05MB/s0││[]readme.txt…nterior-FieldRecordings20040.1MBnectoUA-1.05MB/s0││↑↯HarbourBells(LiveattheQuay)crateminer35.3MB/41.0MB(86%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer38.2MB/41.0MB(93%)3.7MB/s││││││↑↯HarbourBcrateminer38.6MB/41.0MB(94%)3.7MB/s││││││↑✓HarbourBcrateminerCompleted-││││[]03-Marshat╭Keys───────────────────────────────────────────────────────────────────────────────────────────────────╮3.35MB/s1││[]04-TramDepo│PanesSearches│3.35MB/s1││›[ ]05 - Harbour B│Tab/Shift-Tabnext/previouspaneEntershowitsresults│3.35 MB/s1││[]06-RainonZ│123focusapane,hiddenornotdremovethesearch│3.35MB/s1││[]cover.jpg│zzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]01-EmptyCha│whidethefocusedpanecchatrooms│2.1MB/s2││[]02-ForestFl│Escleavezoom│2.1MB/s2││[]03-QuarryEc│clickfocusapaneResults│2.1MB/s2││[]04-NightFer│Spaceselect/deselect│2.1MB/s2││[]01-Opener.mp│Anywherea/Aselectall/none│1.05MB/s0││[]02-Interlude│ssearchthenetworkEnterdownloadtheselection│1.05MB/s0││[]03-Coda.mp3│bbrowseauser'sfiles/filter,Enterkeepsit,Esccle│1.05MB/s0││[]readme.txt│mcomposeaprivatemessagebbrowsetheowner│1.05MB/s0│││iinboxcchatrooms││││osettings││││?thislistDownloads/Uploads││││qquitppause/resume││╰────────────────────│xcancel│─────────────────────╯╭[1]Searches──────│Anylistrretryafailedone│─────────────────────╮│StatusQuery│↑↓/kjmoveonerowddeleteaqueuedorfinishedone│flac││›Donepublic doma│HomeEnd/gGfirst/lastrowccleareveryfinishedone│dings/CoastalDawn│││PgUpPgDn/^b^fonepage│AC24-96]│││^u^dhalfapageChatandbrowsepopups││││hl/←→scrollalongnamesidewaysPgUpPgDn/^b^fapageofmessages,orofthet│loopdigger│││0/$itsstart/itsend^u^dhalfapageofthem│52.5MB│││HomeEnd/gGoldest/newest,orfirst/las│Completed│││Tab/Shift-Tabnextroom,chatoruser│││╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ulseek││[?/Esc→close]│╰────────────────────────────────────────────────────────────────╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───│││││╭Shortcuts·Sharing:~/Music───────────────────────────────────────────────────────────────────────────│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│StatusQueryResults╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮╭│││││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search]││search:p││search:pu││search:pub││search:publ││search:publi││search:publicd││search:publicdo││search:publicdom││search:publicdoma││search:publicdomai││search:publicdomainf││search:publicdomainfi││search:publicdomainfie││search:publicdomainfiel││search:publicdomainfieldr││search:publicdomainfieldre││search:publicdomainfieldrec││search:publicdomainfieldreco││search:publicdomainfieldrecor││search:publicdomainfieldrecord││search:publicdomainfieldrecordi││search:publicdomainfieldrecordin││search:publicdomainfieldrecording│╰────────────s────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults│││││││││││╰──────────────────────────────────╯╰──────────────────────││[Space→select][Enter→download]───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Tab/1-3→pane][z][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││[]06-RainonZincRoof.flac││││││Length││[]04-NightFerry.flacMusic\lossless\PublicDomainFie1││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDa││[]readme.txt@@hnttf\complete\Explorersofth0│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c[a/A→selectall/none][h/l→scrollname][g/G→top/end]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que2.8/100.5MB·18.61MB/s·[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]2%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger1.5 MB/52.5 MB (2%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger1.3MB/48.0MB(2%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que8.4/100.5MB·18.61MB/s·[██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]8%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger4.4 MB/52.5 MB (8%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger4.0MB/48.0MB(8%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que14.0/100.5MB·18.61MB/s·[████░░░░░░░░░░░░░░░░░░░░░░░░░░░]13%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger7.3 MB/52.5 MB (13%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger6.7MB/48.0MB(13%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer0.2MB/41.0MB(0%)3.7MB/s││││││↑↯HarbourBcrateminer0.5MB/41.0MB(1%)3.7MB/s││││││↑↯HarbourBcrateminer0.9MB/41.0MB(2%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que19.5/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]19%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger10.2 MB/52.5 MB (19%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger9.3MB/48.0MB(19%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.3MB/41.0MB(3%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que22.3/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]22%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger11.7 MB/52.5 MB (22%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger10.7MB/48.0MB(22%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.7MB/41.0MB(4%)3.7MB/s││││││↑↯HarbourBcrateminer2.1MB/41.0MB(5%)3.7MB/s││││││↑↯HarbourBcrateminer2.5MB/41.0MB(6%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que27.9/100.5MB·18.61MB/s·[████████░░░░░░░░░░░░░░░░░░░░░░░]27%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger14.6 MB/52.5 MB (27%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger13.3MB/48.0MB(27%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer2.9MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.3MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.6MB/41.0MB(8%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que33.5/100.5MB·18.61MB/s·[██████████░░░░░░░░░░░░░░░░░░░░░]33%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger17.5 MB/52.5 MB (33%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger16.0MB/48.0MB(33%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer4.0MB/41.0MB(9%)3.7MB/s││││││↑↯HarbourBcrateminer4.4MB/41.0MB(10%)3.7MB/s││││││↑↯HarbourBcrateminer4.8MB/41.0MB(11%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que39.1/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]38%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger20.4 MB/52.5 MB (38%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger18.7MB/48.0MB(38%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.2MB/41.0MB(12%)3.7MB/s││││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]-CoastalD30.5MBloopdigger675kbps3.35MB/s1││ieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││17.4MBtapehiss836kbps2.1MB/s2││oUA320kbps1.05MB/s0││───────────────────────────────────────────────────────╯│5.6MB/41.0MB(13%)3.7MB/s││││[Tab/1-3→pane][z→unzoom]→hide][?→keys][q→quit]││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.4MB/41.0MB(15%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que47.5/100.5MB·18.61MB/s·[██████████████░░░░░░░░░░░░░░░░░]47%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger24.8 MB/52.5 MB (47%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger22.7MB/48.0MB(47%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.8MB/41.0MB(16%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.1MB/41.0MB(17%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.5MB/41.0MB(18%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que53.0/100.5MB·18.61MB/s·[████████████████░░░░░░░░░░░░░░░]52%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger27.7 MB/52.5 MB (52%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger25.3MB/48.0MB(52%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.9MB/41.0MB(19%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.3MB/41.0MB(20%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.7MB/41.0MB(21%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que58.6/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]58%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger30.6 MB/52.5 MB (58%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger28.0MB/48.0MB(58%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.0MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.4MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.8MB/41.0MB(23%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que64.2/100.5MB·18.61MB/s·[███████████████████░░░░░░░░░░░░]63%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger33.5 MB/52.5 MB (63%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger30.7MB/48.0MB(63%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.2MB/41.0MB(24%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.6MB/41.0MB(25%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.0MB/41.0MB(26%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que69.8/100.5MB·18.61MB/s·[█████████████████████░░░░░░░░░░]69%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger36.5 MB/52.5 MB (69%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger33.3MB/48.0MB(69%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.3MB/41.0MB(27%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.7MB/41.0MB(28%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.1MB/41.0MB(29%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que75.4/100.5MB·18.61MB/s·[███████████████████████░░░░░░░░]75%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger39.4 MB/52.5 MB (75%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger36.0MB/48.0MB(75%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.5MB/41.0MB(30%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.9MB/41.0MB(31%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.2MB/41.0MB(32%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que81.0/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]80%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]01-CoastalDawn(Sunrisgs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps││[]02-Interlud│╰──────────────────────────────││StatusQueryResults││││││[Tab/1-3→pane][z→zoom][w[w→hide][?→keys][q→quit]││││↑↯HarbourBcrateminer14.2MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que86.5/100.5MB·18.61MB/s·[██████████████████████████░░░░░]86%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger45.2 MB/52.5 MB (86%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger41.3MB/48.0MB(86%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.6MB/41.0MB(35%)3.7MB/s││││││↑↯HarbourBcrateminer15.0MB/41.0MB(36%)3.7MB/s││││││↑↯HarbourBcrateminer15.4MB/41.0MB(37%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que92.1/100.5MB·18.61MB/s·[████████████████████████████░░░]91%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger48.1 MB/52.5 MB (91%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger44.0MB/48.0MB(91%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer15.8MB/41.0MB(38%)3.7MB/s││││││↑↯HarbourBcrateminer16.2MB/41.0MB(39%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s│││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[s→search][Enter→results][d→remove][m→message][i→inbox]done][c→clearfinished][b→browseuser][h/l→scrollname]││[z→zoom][w→hide][?→keys][q→quit→keys][q→quit]││││↑↯HarbourBcrateminer17.2MB/41.0MB(41%)3.7MB/s││││││↑↯HarbourBcrateminer17.6MB/41.0MB(42%)3.7MB/s││││││↑↯HarbourBcrateminer18.0MB/41.0MB(43%)3.7MB/s││││││↑↯HarbourBcrateminer18.4MB/41.0MB(44%)3.7MB/s││││││↑↯HarbourBcrateminer18.8MB/41.0MB(45%)3.7MB/s││││││↑↯HarbourBcrateminer19.2MB/41.0MB(46%)3.7MB/s││││││↑↯HarbourBcrateminer19.6MB/41.0MB(47%)3.7MB/s│││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│StatusFilenameUserProgressSpeed││05d││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]ns(2019)[FLAC24-96]││││StatusCompleted││││Uploadspeed9m33s││[Space→select][Enter→download][b→browseowner][c[i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[Tab/1-3→pane][z→zoom][w→hide][it]││↑↯HarbourBells(LiveattheQuay)crateminer20.6MB/41.0MB(50%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.0MB/41.0MB(51%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.4MB/41.0MB(52%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.8MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.1MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.5MB/41.0MB(54%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.9MB/41.0MB(55%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.3MB/41.0MB(56%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.7MB/41.0MB(57%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.1MB/41.0MB(58%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.5MB/41.0MB(59%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.9MB/41.0MB(60%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.3MB/41.0MB(61%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.6MB/41.0MB(62%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.0MB/41.0MB(63%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.4MB/41.0MB(64%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.8MB/41.0MB(65%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer27.2MB/41.0MB(66%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.0MB/41.0MB(68%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.4MB/41.0MB(69%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.8MB/41.0MB(70%)3.7MB/s││││[]03-QuarryEcho.flac…RecorCreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer29.5MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.9MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.2MB/41.0MB(73%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.6MB/41.0MB(74%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.0MB/41.0MB(75%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.4MB/41.0MB(76%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.8MB/41.0MB(77%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.2MB/41.0MB(78%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.6MB/41.0MB(79%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.0MB/41.0MB(80%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.4MB/41.0MB(81%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.7MB/41.0MB(82%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.1MB/41.0MB(83%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.5MB/41.0MB(84%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.9MB/41.0MB(85%)3.7MB/s││││[]03-QuarryEcho.flacMusic\lossless\PublicD-Quarry34.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer35.7MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer35.8MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.2MB/41.0MB(88%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.6MB/41.0MB(89%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.0MB/41.0MB(90%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.4MB/41.0MB(91%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.8MB/41.0MB(92%)3.7MB/s││││StatusQueryResults││StatusFilenameUserSpeed││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││││││User││[s→e→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][][g/G→top/end]││││↑↯HarbourBcrateminer38.8MB/41.0MB(94%)3.7MB/s││││││↑↯HarbourBcrateminer39.2MB/41.0MB(95%)3.7MB/s││││││↑↯HarbourBcrateminer39.6MB/41.0MB(96%)3.7MB/s││││││↑↯HarbourBcrateminer40.0MB/41.0MB(97%)3.7MB/s││││││↑↯HarbourBcrateminer40.4MB/41.0MB(98%)3.7MB/s││││││↑↯HarbourBcrateminer40.7MB/41.0MB(99%)3.7MB/s││││[]03-QuarryEc│clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2││[]03-Coda.mp3│bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0│││qquit─────────────────────────────────────────────────────────────────────────────────╯│Statpublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn│││PgUpPgDn/^b^fonepage-│││││││││Saveto│││╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[]cover.jpgzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MB/deselect│2.1MB/s2│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───────────────────cancel│─────────────────────╯│›Donepublic domain fi15││›moveonerowddeleteaqueuedorfinishedone│flac││││↑✓HarbourB│AC24-96]││HomeEnd/gGoldest/newest,orfirst/las│Completed│╰──────────────────────────────────╯╰───────────────────────────────││││[s→search][Enter→results][d→remove][m→message][i→inbox]─────────────────────────────────────────────────────────────────────────────╮│[z→zoom][w→hide][?→keys][q→quit│ \ No newline at end of file diff --git a/fixtures/payloads.txt b/fixtures/payloads.txt new file mode 100644 index 0000000..f9ebbdd --- /dev/null +++ b/fixtures/payloads.txt @@ -0,0 +1,22 @@ +Message { data: [10, 9, 0, 0, 9, 0, 0, 0, 120, 156, 205, 154, 123, 108, 91, 87, 25, 192, 157, 38, 43, 83, 1, 173, 83, 39, 4, 18, 143, 179, 63, 232, 90, 53, 137, 124, 253, 170, 45, 33, 54, 191, 146, 52, 216, 73, 107, 59, 201, 210, 164, 176, 227, 235, 99, 251, 44, 247, 222, 99, 238, 35, 137, 215, 13, 170, 81, 4, 21, 76, 67, 2, 36, 42, 4, 19, 98, 157, 52, 84, 173, 99, 211, 64, 136, 9, 85, 168, 148, 10, 193, 64, 162, 72, 147, 168, 166, 117, 42, 48, 134, 16, 8, 196, 134, 196, 31, 240, 157, 115, 237, 60, 238, 57, 97, 161, 179, 195, 34, 249, 17, 251, 222, 243, 253, 206, 247, 157, 239, 113, 190, 227, 219, 66, 161, 80, 137, 25, 6, 181, 26, 168, 210, 244, 172, 26, 177, 205, 67, 95, 137, 193, 167, 161, 129, 243, 103, 110, 15, 117, 255, 238, 230, 31, 180, 224, 233, 158, 123, 152, 233, 213, 220, 197, 162, 231, 80, 125, 49, 141, 114, 184, 141, 42, 12, 149, 136, 73, 204, 42, 177, 209, 8, 154, 96, 38, 129, 239, 150, 208, 66, 36, 28, 78, 193, 7, 179, 165, 120, 76, 139, 192, 155, 108, 46, 13, 207, 99, 133, 116, 246, 196, 162, 166, 193, 219, 180, 197, 220, 38, 220, 84, 102, 32, 62, 93, 101, 158, 11, 16, 4, 205, 17, 178, 68, 172, 218, 104, 221, 192, 250, 63, 126, 114, 101, 160, 11, 49, 200, 33, 224, 113, 29, 30, 67, 240, 200, 157, 15, 133, 110, 129, 215, 189, 252, 243, 147, 189, 132, 139, 189, 41, 28, 58, 144, 214, 153, 231, 184, 84, 63, 40, 56, 111, 251, 197, 57, 137, 243, 37, 21, 167, 41, 113, 102, 153, 81, 107, 25, 128, 10, 50, 209, 4, 193, 53, 52, 230, 25, 6, 98, 117, 148, 179, 9, 54, 29, 116, 32, 18, 214, 226, 7, 209, 201, 112, 50, 18, 79, 196, 18, 169, 100, 4, 158, 31, 66, 11, 217, 220, 136, 207, 27, 142, 142, 162, 137, 182, 105, 161, 58, 179, 145, 27, 208, 160, 30, 185, 38, 145, 237, 26, 80, 144, 125, 90, 34, 203, 81, 11, 163, 44, 182, 109, 88, 33, 64, 55, 71, 221, 38, 87, 67, 129, 45, 19, 116, 64, 75, 165, 82, 0, 53, 150, 47, 231, 114, 40, 126, 120, 51, 15, 92, 93, 160, 203, 212, 186, 11, 141, 1, 210, 38, 189, 241, 127, 202, 45, 172, 19, 148, 177, 133, 134, 29, 84, 164, 171, 190, 22, 111, 124, 231, 107, 187, 130, 172, 45, 21, 171, 172, 197, 9, 108, 215, 70, 198, 40, 8, 46, 187, 24, 198, 4, 245, 101, 179, 149, 89, 97, 232, 56, 124, 26, 15, 199, 195, 177, 196, 97, 148, 76, 164, 52, 20, 65, 135, 131, 38, 15, 119, 145, 27, 65, 100, 65, 118, 105, 232, 153, 237, 217, 247, 47, 18, 217, 36, 107, 90, 168, 200, 108, 108, 16, 7, 132, 240, 145, 139, 251, 139, 124, 210, 240, 255, 212, 124, 22, 205, 112, 183, 107, 216, 12, 220, 15, 229, 168, 163, 51, 88, 123, 160, 24, 176, 252, 33, 116, 212, 38, 203, 20, 22, 154, 209, 134, 203, 70, 74, 196, 32, 216, 33, 53, 148, 95, 213, 13, 24, 28, 236, 80, 198, 134, 195, 60, 163, 51, 92, 119, 89, 103, 50, 121, 164, 69, 82, 217, 108, 46, 48, 79, 62, 62, 210, 22, 195, 220, 253, 178, 77, 155, 58, 96, 85, 195, 117, 154, 212, 38, 104, 63, 202, 26, 216, 113, 80, 90, 119, 41, 179, 184, 193, 187, 54, 155, 44, 162, 116, 221, 229, 206, 64, 28, 135, 127, 215, 153, 128, 111, 181, 47, 62, 243, 209, 161, 160, 110, 22, 118, 41, 116, 115, 78, 210, 77, 145, 234, 77, 76, 12, 52, 110, 139, 245, 191, 105, 161, 108, 250, 110, 220, 160, 46, 0, 84, 217, 170, 144, 203, 39, 170, 113, 27, 166, 139, 197, 88, 132, 187, 235, 92, 62, 179, 54, 201, 176, 54, 138, 110, 118, 100, 49, 163, 91, 47, 183, 165, 117, 248, 113, 213, 58, 180, 118, 104, 70, 117, 136, 17, 196, 30, 189, 191, 213, 40, 189, 177, 182, 14, 197, 223, 0, 253, 95, 16, 248, 250, 224, 99, 167, 146, 137, 100, 50, 17, 23, 139, 163, 188, 65, 111, 65, 226, 18, 174, 81, 134, 242, 53, 234, 250, 138, 153, 250, 19, 147, 220, 224, 162, 202, 13, 62, 217, 67, 170, 72, 144, 42, 191, 234, 194, 43, 184, 193, 44, 211, 177, 177, 110, 182, 207, 254, 230, 217, 193, 32, 221, 171, 42, 179, 177, 30, 210, 69, 131, 116, 211, 54, 109, 64, 224, 52, 192, 1, 239, 90, 103, 251, 93, 246, 81, 137, 237, 69, 21, 219, 114, 15, 217, 98, 65, 182, 41, 170, 163, 49, 108, 233, 20, 50, 12, 13, 170, 239, 106, 248, 155, 18, 226, 62, 149, 31, 247, 208, 184, 255, 205, 93, 32, 120, 149, 33, 30, 27, 228, 224, 168, 193, 26, 239, 127, 231, 166, 117, 31, 26, 168, 246, 142, 98, 43, 177, 186, 71, 30, 30, 12, 136, 61, 218, 59, 177, 58, 36, 82, 225, 212, 87, 175, 4, 132, 148, 122, 39, 196, 38, 142, 7, 17, 158, 171, 240, 249, 161, 128, 152, 251, 36, 49, 229, 227, 60, 87, 100, 93, 219, 88, 11, 72, 21, 214, 130, 74, 106, 165, 129, 242, 22, 196, 43, 23, 83, 203, 36, 150, 27, 12, 185, 137, 81, 228, 223, 27, 204, 156, 255, 186, 241, 184, 20, 50, 246, 170, 22, 254, 19, 50, 140, 206, 152, 43, 234, 54, 81, 52, 184, 16, 140, 60, 19, 85, 136, 222, 180, 32, 40, 173, 182, 136, 77, 137, 5, 133, 4, 20, 72, 104, 158, 116, 178, 62, 47, 4, 116, 6, 233, 12, 46, 192, 34, 74, 240, 240, 5, 73, 203, 15, 177, 220, 93, 203, 77, 82, 175, 83, 98, 212, 80, 197, 179, 68, 82, 86, 165, 72, 40, 170, 214, 1, 58, 51, 186, 83, 76, 233, 238, 137, 159, 73, 83, 186, 166, 138, 130, 223, 125, 59, 77, 41, 186, 197, 148, 96, 197, 27, 94, 117, 61, 12, 60, 234, 190, 38, 37, 63, 67, 101, 176, 231, 222, 102, 179, 139, 171, 103, 55, 53, 82, 177, 49, 151, 186, 54, 195, 250, 238, 63, 74, 51, 60, 165, 154, 97, 83, 154, 33, 159, 215, 49, 98, 82, 199, 229, 20, 147, 140, 90, 162, 224, 62, 182, 86, 121, 29, 207, 79, 101, 115, 80, 122, 5, 24, 33, 137, 109, 190, 181, 192, 28, 23, 173, 108, 240, 147, 31, 94, 92, 144, 160, 246, 169, 160, 30, 144, 160, 102, 177, 217, 226, 5, 92, 55, 32, 128, 80, 44, 246, 46, 160, 93, 78, 151, 177, 105, 141, 8, 101, 10, 44, 102, 120, 102, 149, 98, 69, 209, 36, 15, 52, 1, 133, 7, 154, 103, 30, 154, 98, 43, 2, 243, 219, 131, 175, 135, 130, 152, 103, 85, 107, 191, 95, 152, 17, 37, 38, 182, 77, 102, 181, 225, 213, 48, 4, 230, 135, 110, 124, 88, 210, 102, 76, 165, 77, 175, 79, 152, 81, 21, 102, 6, 195, 165, 22, 22, 132, 47, 239, 126, 90, 82, 100, 93, 165, 200, 149, 62, 17, 198, 84, 132, 149, 38, 108, 9, 10, 180, 78, 4, 227, 153, 221, 215, 165, 64, 247, 174, 237, 21, 45, 189, 97, 140, 43, 181, 72, 27, 40, 99, 120, 62, 226, 7, 255, 240, 136, 164, 70, 99, 39, 213, 152, 80, 187, 205, 10, 120, 184, 213, 248, 132, 96, 188, 112, 58, 42, 169, 241, 170, 138, 241, 83, 125, 98, 60, 172, 98, 156, 177, 170, 16, 130, 113, 21, 182, 150, 115, 77, 234, 250, 234, 188, 118, 76, 238, 183, 188, 119, 39, 45, 158, 84, 161, 150, 160, 248, 65, 69, 108, 9, 196, 196, 59, 94, 148, 44, 254, 85, 149, 54, 79, 247, 9, 49, 165, 66, 44, 98, 27, 146, 91, 13, 65, 66, 192, 104, 156, 71, 205, 146, 231, 52, 5, 240, 240, 240, 212, 246, 122, 7, 171, 253, 1, 214, 194, 74, 224, 54, 36, 68, 199, 197, 75, 190, 225, 127, 61, 248, 110, 9, 242, 178, 10, 178, 79, 134, 215, 148, 233, 167, 220, 54, 91, 48, 86, 91, 32, 62, 219, 122, 76, 66, 124, 101, 7, 93, 93, 83, 166, 158, 178, 103, 213, 13, 182, 2, 133, 60, 103, 252, 72, 230, 85, 105, 113, 158, 81, 49, 182, 251, 196, 168, 204, 59, 99, 2, 16, 21, 25, 243, 93, 232, 145, 59, 47, 72, 154, 252, 179, 138, 210, 238, 19, 165, 50, 247, 68, 194, 17, 77, 224, 61, 249, 250, 23, 36, 37, 86, 85, 120, 167, 250, 132, 167, 76, 59, 115, 112, 55, 49, 120, 55, 184, 194, 26, 132, 15, 42, 96, 127, 62, 236, 72, 186, 252, 209, 78, 58, 142, 50, 1, 149, 93, 168, 121, 27, 29, 196, 135, 78, 62, 39, 33, 254, 115, 39, 23, 165, 50, 255, 148, 91, 54, 239, 182, 150, 173, 78, 101, 249, 215, 91, 95, 150, 172, 254, 45, 21, 229, 153, 62, 81, 42, 83, 207, 36, 177, 61, 7, 27, 196, 28, 70, 83, 100, 5, 74, 97, 123, 105, 24, 214, 129, 109, 80, 223, 149, 94, 122, 250, 53, 137, 250, 41, 21, 245, 137, 254, 80, 175, 119, 6, 143, 191, 47, 208, 25, 220, 78, 105, 27, 252, 4, 118, 102, 225, 228, 65, 116, 242, 222, 66, 54, 135, 162, 90, 114, 99, 91, 95, 235, 182, 7, 63, 70, 107, 14, 202, 49, 235, 149, 83, 103, 93, 190, 203, 131, 251, 48, 202, 54, 249, 38, 75, 232, 196, 28, 178, 164, 245, 246, 119, 149, 78, 228, 134, 240, 44, 182, 121, 187, 27, 165, 109, 183, 179, 81, 226, 2, 43, 184, 234, 161, 18, 129, 93, 34, 8, 206, 176, 85, 84, 38, 174, 80, 140, 104, 112, 165, 51, 51, 153, 233, 123, 195, 97, 191, 169, 29, 220, 24, 118, 123, 96, 147, 216, 68, 251, 81, 129, 172, 64, 129, 219, 105, 99, 59, 80, 110, 116, 190, 45, 143, 78, 143, 150, 71, 161, 64, 239, 184, 186, 175, 141, 113, 106, 251, 219, 137, 197, 59, 126, 42, 109, 39, 30, 222, 222, 193, 68, 119, 172, 163, 6, 110, 243, 195, 142, 17, 116, 196, 114, 153, 47, 212, 179, 132, 194, 35, 160, 240, 108, 174, 56, 83, 168, 204, 39, 99, 155, 14, 82, 52, 233, 242, 96, 71, 246, 188, 253, 132, 164, 235, 223, 171, 116, 221, 99, 178, 136, 76, 70, 151, 49, 63, 107, 235, 238, 178, 173, 61, 159, 147, 218, 137, 215, 85, 58, 147, 203, 158, 183, 68, 198, 27, 6, 17, 13, 118, 215, 89, 98, 185, 158, 221, 70, 7, 192, 170, 15, 96, 10, 108, 45, 175, 106, 80, 125, 35, 164, 246, 131, 7, 37, 195, 126, 94, 5, 121, 95, 79, 33, 55, 95, 153, 205, 105, 162, 215, 184, 59, 216, 107, 148, 211, 239, 91, 17, 250, 102, 247, 10, 12, 131, 53, 246, 238, 249, 255, 99, 152, 81, 175, 17, 10, 96, 148, 123, 138, 81, 197, 250, 18, 132, 77, 210, 184, 116, 45, 32, 167, 210, 83, 57, 221, 38, 47, 105, 236, 123, 161, 175, 19, 170, 65, 180, 19, 114, 46, 159, 11, 200, 193, 91, 202, 153, 99, 182, 81, 235, 132, 64, 120, 95, 27, 153, 246, 92, 7, 178, 142, 45, 68, 105, 32, 106, 190, 20, 78, 112, 49, 144, 115, 70, 214, 27, 69, 221, 235, 209, 129, 156, 87, 245, 61, 233, 196, 151, 158, 148, 60, 233, 130, 202, 147, 122, 76, 3, 69, 249, 250, 101, 107, 52, 223, 43, 220, 33, 209, 92, 81, 209, 220, 223, 91, 154, 232, 70, 221, 172, 157, 4, 173, 117, 30, 15, 61, 255, 111, 9, 235, 61, 170, 35, 150, 30, 99, 197, 54, 42, 73, 198, 122, 252, 216, 143, 165, 36, 130, 85, 218, 218, 122, 197, 222, 12, 214, 218, 249, 199, 233, 139, 129, 210, 101, 86, 150, 195, 140, 58, 74, 67, 244, 38, 188, 3, 100, 120, 155, 74, 150, 8, 31, 63, 55, 17, 214, 194, 9, 77, 202, 159, 156, 40, 67, 176, 238, 111, 198, 191, 49, 241, 183, 80, 112, 170, 95, 15, 41, 166, 42, 31, 47, 221, 28, 130, 255, 91, 136, 22, 255, 169, 200, 18, 154, 182, 4, 206, 56, 63, 103, 23, 56, 191, 252, 149, 92, 154, 191, 161, 194, 209, 123, 132, 195, 79, 116, 120, 116, 241, 57, 108, 130, 93, 2, 169, 114, 130, 186, 62, 207, 37, 244, 1, 137, 231, 203, 253, 84, 79, 170, 99, 161, 2, 6, 140, 34, 182, 184, 138, 242, 216, 118, 125, 107, 61, 245, 194, 103, 36, 156, 91, 84, 11, 115, 190, 55, 56, 218, 166, 5, 131, 142, 28, 241, 79, 189, 7, 228, 122, 246, 183, 42, 165, 60, 168, 164, 104, 192, 78, 12, 141, 195, 156, 172, 141, 39, 19, 188, 83, 148, 54, 137, 77, 117, 44, 74, 89, 46, 121, 166, 128, 162, 225, 104, 84, 209, 190, 83, 140, 35, 60, 13, 12, 121, 196, 133, 23, 223, 122, 103, 47, 124, 95, 10, 47, 243, 42, 117, 201, 29, 146, 94, 128, 242, 30, 206, 22, 160, 176, 59, 48, 91, 130, 113, 33, 191, 71, 98, 84, 253, 30, 233, 63, 231, 121, 234, 121], pointer: 8 } +Message { data: [201, 0, 0, 0, 9, 0, 0, 0, 120, 156, 227, 98, 96, 96, 72, 204, 75, 169, 204, 201, 79, 206, 78, 45, 202, 213, 158, 98, 194, 12, 20, 97, 12, 1, 18, 14, 14, 165, 21, 149, 85, 233, 49, 201, 249, 185, 5, 57, 169, 37, 169, 49, 70, 73, 137, 41, 185, 153, 201, 169, 49, 33, 25, 169, 10, 225, 169, 169, 217, 169, 121, 41, 49, 190, 153, 201, 25, 137, 169, 57, 10, 238, 69, 137, 149, 10, 209, 6, 134, 177, 10, 72, 178, 10, 26, 65, 137, 41, 153, 249, 10, 174, 41, 153, 37, 154, 122, 185, 5, 198, 207, 119, 84, 49, 192, 0, 19, 148, 118, 96, 4, 90, 8, 164, 15, 128, 44, 142, 33, 215, 98, 35, 52, 139, 93, 43, 74, 128, 116, 106, 138, 66, 88, 126, 114, 98, 142, 130, 111, 102, 5, 216, 1, 155, 67, 245, 25, 113, 57, 224, 5, 136, 78, 36, 215, 1, 38, 104, 14, 240, 203, 76, 86, 112, 75, 204, 75, 206, 44, 205, 201, 201, 68, 115, 197, 228, 187, 158, 56, 93, 33, 12, 20, 96, 252, 208, 46, 196, 128, 12, 0, 212, 187, 112, 37], pointer: 8 } +Message { data: [182, 2, 0, 0, 9, 0, 0, 0, 120, 156, 165, 149, 191, 111, 211, 64, 20, 199, 83, 70, 6, 164, 74, 21, 3, 3, 122, 83, 149, 82, 138, 104, 83, 33, 16, 75, 147, 166, 129, 210, 38, 141, 146, 64, 6, 130, 208, 139, 253, 18, 191, 218, 190, 75, 207, 118, 126, 252, 1, 136, 5, 137, 161, 72, 44, 12, 76, 252, 27, 72, 76, 72, 76, 32, 209, 1, 177, 32, 33, 102, 36, 58, 0, 19, 119, 193, 13, 72, 181, 39, 223, 112, 95, 217, 190, 179, 63, 247, 125, 63, 60, 159, 203, 229, 54, 29, 84, 94, 212, 157, 140, 109, 21, 177, 77, 171, 235, 254, 242, 179, 245, 115, 250, 193, 220, 190, 158, 54, 54, 124, 116, 35, 238, 84, 163, 128, 15, 34, 10, 58, 165, 173, 98, 171, 190, 215, 104, 65, 155, 200, 37, 97, 67, 157, 45, 55, 128, 213, 2, 180, 20, 10, 139, 32, 191, 118, 117, 109, 125, 169, 83, 100, 53, 194, 33, 193, 10, 180, 28, 130, 182, 84, 158, 13, 195, 0, 154, 3, 25, 114, 111, 2, 249, 173, 113, 168, 119, 147, 13, 85, 30, 47, 93, 241, 7, 5, 222, 124, 155, 59, 25, 103, 98, 221, 152, 211, 24, 90, 43, 70, 173, 44, 56, 37, 244, 108, 169, 97, 26, 250, 136, 16, 106, 162, 26, 247, 157, 16, 138, 30, 141, 161, 70, 138, 161, 65, 62, 143, 13, 199, 59, 250, 153, 202, 113, 104, 244, 65, 22, 142, 242, 118, 173, 24, 123, 178, 233, 177, 143, 99, 200, 239, 41, 238, 179, 64, 111, 230, 196, 217, 155, 223, 83, 9, 30, 103, 118, 162, 140, 67, 182, 161, 34, 85, 151, 130, 152, 228, 22, 250, 148, 16, 145, 167, 135, 239, 83, 57, 238, 26, 165, 76, 28, 20, 70, 130, 46, 195, 14, 10, 233, 198, 32, 117, 201, 34, 76, 112, 228, 254, 171, 75, 115, 105, 36, 223, 140, 142, 50, 145, 72, 159, 5, 187, 176, 163, 208, 247, 73, 197, 44, 21, 38, 207, 14, 0, 117, 206, 234, 171, 93, 234, 133, 80, 165, 4, 182, 70, 254, 75, 170, 75, 194, 168, 147, 137, 77, 113, 47, 244, 165, 20, 134, 74, 113, 151, 64, 246, 166, 249, 219, 140, 68, 66, 200, 142, 175, 173, 165, 26, 117, 108, 244, 81, 22, 152, 59, 82, 217, 40, 244, 167, 45, 215, 163, 137, 38, 106, 163, 26, 232, 146, 38, 77, 176, 203, 232, 67, 155, 189, 64, 163, 46, 78, 13, 108, 145, 229, 8, 182, 24, 69, 0, 51, 208, 89, 153, 189, 168, 126, 72, 181, 237, 158, 209, 135, 89, 72, 119, 80, 137, 41, 225, 180, 249, 16, 6, 228, 37, 132, 238, 224, 227, 167, 84, 6, 203, 168, 155, 133, 161, 134, 33, 42, 220, 199, 66, 89, 115, 236, 162, 94, 24, 135, 174, 226, 201, 145, 78, 179, 211, 60, 245, 39, 11, 169, 209, 123, 99, 212, 207, 196, 67, 236, 193, 237, 72, 132, 211, 20, 223, 242, 7, 172, 102, 217, 84, 101, 161, 115, 253, 116, 62, 253, 88, 249, 156, 234, 80, 47, 115, 11, 168, 19, 90, 142, 55, 233, 157, 252, 37, 106, 52, 130, 230, 136, 69, 63, 193, 155, 243, 219, 233, 45, 224, 171, 81, 145, 137, 132, 45, 9, 37, 25, 121, 52, 68, 101, 199, 56, 85, 83, 118, 38, 110, 37, 180, 220, 4, 115, 186, 207, 33, 21, 233, 200, 232, 203, 68, 36, 194, 112, 32, 85, 104, 202, 39, 134, 250, 239, 214, 95, 62, 3, 117, 66, 104, 22, 65, 225, 70, 167, 138, 34, 84, 8, 45, 57, 101, 43, 69, 182, 237, 160, 222, 80, 194, 238, 4, 202, 210, 138, 139, 174, 76, 34, 228, 64, 247, 208, 58, 169, 30, 89, 33, 52, 245, 38, 209, 215, 33, 95, 212, 133, 234, 144, 126, 195, 180, 4, 151, 180, 7, 215, 205, 33, 142, 22, 230, 83, 15, 241, 218, 76, 191, 127, 93, 204, 93, 200, 253, 27, 127, 0, 98, 77, 44, 101], pointer: 8 } +Message { data: [145, 5, 0, 0, 9, 0, 0, 0, 120, 156, 189, 152, 77, 108, 27, 85, 16, 199, 29, 90, 224, 132, 132, 224, 88, 62, 6, 169, 106, 109, 181, 182, 214, 249, 168, 27, 33, 164, 110, 214, 78, 236, 36, 206, 26, 127, 36, 45, 89, 55, 60, 175, 159, 227, 215, 172, 247, 109, 118, 215, 78, 28, 4, 170, 132, 16, 8, 142, 237, 137, 11, 28, 122, 228, 194, 137, 170, 28, 90, 113, 228, 128, 128, 94, 144, 144, 144, 232, 29, 16, 69, 8, 113, 130, 25, 219, 11, 169, 157, 165, 41, 170, 187, 7, 191, 221, 245, 91, 207, 111, 254, 111, 118, 222, 140, 159, 136, 68, 34, 210, 110, 10, 151, 181, 78, 93, 157, 126, 14, 175, 38, 46, 226, 199, 185, 115, 204, 223, 113, 28, 99, 35, 95, 41, 229, 52, 213, 88, 85, 33, 14, 5, 225, 155, 205, 134, 116, 183, 160, 220, 228, 144, 84, 20, 152, 227, 158, 15, 37, 105, 111, 122, 32, 27, 48, 169, 36, 103, 13, 101, 42, 137, 83, 87, 89, 203, 17, 46, 135, 53, 206, 183, 184, 93, 199, 59, 165, 110, 203, 97, 126, 179, 155, 104, 57, 83, 103, 202, 31, 68, 130, 227, 177, 193, 120, 110, 2, 77, 227, 120, 135, 16, 190, 236, 33, 52, 109, 223, 111, 24, 138, 18, 64, 224, 89, 86, 175, 148, 50, 70, 81, 152, 204, 173, 75, 88, 21, 150, 197, 44, 89, 147, 30, 172, 207, 47, 171, 90, 213, 152, 84, 148, 25, 50, 79, 184, 203, 210, 148, 144, 22, 38, 135, 19, 112, 192, 19, 113, 200, 217, 61, 71, 242, 98, 23, 47, 22, 92, 206, 109, 156, 57, 103, 181, 57, 172, 107, 122, 49, 159, 59, 175, 36, 147, 85, 163, 255, 5, 67, 39, 122, 95, 69, 181, 116, 50, 102, 40, 147, 9, 80, 45, 190, 139, 183, 185, 11, 25, 134, 50, 220, 185, 124, 5, 245, 216, 20, 182, 45, 236, 205, 192, 241, 68, 195, 98, 230, 175, 219, 207, 6, 94, 70, 142, 68, 250, 110, 2, 126, 28, 197, 49, 253, 73, 36, 242, 56, 142, 79, 211, 125, 30, 230, 118, 110, 37, 157, 203, 24, 154, 90, 44, 100, 202, 106, 201, 72, 65, 52, 143, 38, 209, 242, 60, 179, 33, 205, 81, 87, 40, 114, 83, 186, 117, 143, 22, 33, 9, 249, 194, 20, 50, 166, 32, 133, 148, 13, 154, 71, 126, 6, 72, 184, 0, 150, 123, 45, 116, 1, 158, 162, 241, 234, 33, 73, 210, 172, 35, 80, 24, 185, 35, 120, 220, 227, 22, 55, 125, 94, 55, 116, 191, 201, 93, 143, 214, 34, 69, 81, 227, 202, 142, 176, 113, 21, 162, 229, 85, 208, 251, 146, 23, 89, 93, 72, 104, 248, 9, 216, 247, 3, 49, 88, 175, 44, 85, 33, 138, 15, 180, 36, 226, 79, 37, 214, 200, 201, 250, 126, 240, 217, 159, 175, 132, 130, 223, 37, 9, 183, 15, 9, 158, 89, 206, 104, 229, 162, 14, 5, 189, 96, 32, 209, 198, 171, 109, 102, 9, 22, 47, 113, 211, 229, 254, 198, 26, 103, 142, 180, 227, 168, 165, 18, 159, 95, 187, 144, 69, 45, 55, 208, 169, 141, 237, 254, 44, 191, 41, 188, 141, 157, 127, 169, 50, 153, 250, 8, 213, 141, 193, 66, 31, 35, 186, 215, 14, 73, 149, 179, 113, 177, 28, 105, 161, 108, 42, 204, 11, 27, 131, 147, 123, 30, 68, 233, 205, 66, 121, 166, 38, 149, 170, 161, 76, 15, 175, 230, 243, 191, 108, 135, 138, 114, 157, 48, 222, 60, 164, 249, 209, 183, 86, 147, 182, 239, 50, 88, 239, 41, 129, 81, 21, 215, 100, 167, 250, 86, 114, 86, 217, 170, 57, 222, 240, 252, 184, 114, 54, 1, 11, 162, 67, 241, 95, 113, 122, 148, 11, 109, 155, 8, 63, 252, 116, 84, 160, 235, 3, 129, 94, 32, 210, 243, 255, 143, 208, 192, 88, 39, 51, 75, 2, 35, 63, 45, 237, 147, 152, 141, 124, 122, 85, 85, 208, 154, 12, 131, 142, 140, 71, 68, 117, 196, 248, 173, 129, 241, 223, 104, 124, 231, 144, 198, 107, 150, 52, 193, 97, 174, 223, 53, 230, 232, 180, 64, 167, 189, 165, 10, 4, 27, 100, 20, 77, 224, 20, 37, 9, 112, 207, 52, 202, 147, 48, 47, 93, 208, 44, 214, 133, 245, 180, 240, 152, 227, 112, 230, 66, 150, 187, 188, 74, 164, 11, 199, 46, 133, 146, 190, 68, 50, 237, 141, 135, 116, 114, 136, 52, 219, 182, 125, 49, 128, 93, 163, 164, 207, 61, 194, 251, 232, 114, 62, 20, 239, 54, 141, 111, 140, 7, 111, 106, 8, 111, 141, 137, 127, 240, 104, 86, 42, 145, 60, 75, 124, 87, 23, 47, 134, 242, 77, 76, 60, 64, 114, 120, 64, 190, 233, 33, 62, 250, 166, 224, 178, 46, 119, 137, 234, 218, 78, 49, 148, 234, 71, 26, 237, 241, 80, 205, 12, 81, 85, 108, 129, 123, 119, 139, 144, 190, 250, 172, 27, 138, 180, 76, 66, 137, 241, 32, 157, 25, 66, 210, 123, 201, 225, 244, 9, 17, 74, 243, 34, 209, 248, 227, 161, 73, 13, 135, 21, 238, 90, 28, 114, 30, 100, 101, 171, 151, 56, 156, 111, 182, 66, 193, 78, 16, 152, 51, 30, 176, 179, 67, 96, 75, 46, 111, 239, 213, 184, 187, 73, 80, 223, 253, 180, 19, 10, 181, 64, 80, 59, 227, 129, 154, 29, 130, 202, 97, 166, 197, 98, 10, 235, 142, 22, 111, 213, 250, 161, 126, 119, 247, 245, 80, 182, 39, 137, 173, 53, 22, 182, 164, 50, 156, 105, 219, 118, 157, 245, 42, 205, 151, 63, 111, 133, 18, 157, 34, 162, 173, 241, 16, 141, 228, 126, 119, 215, 39, 158, 189, 63, 194, 51, 252, 113, 226, 169, 60, 52, 30, 97, 131, 63, 224, 73, 236, 9, 231, 237, 63, 217, 209, 200, 190, 35, 60, 78, 242, 185, 114, 78, 211, 75, 134, 106, 50, 135, 99, 185, 220, 47, 254, 131, 43, 220, 173, 88, 7, 11, 226, 85, 105, 181, 91, 216, 5, 80, 113, 162, 204, 196, 104, 187, 203, 11, 179, 201, 184, 133, 165, 52, 11, 210, 96, 0, 19, 13, 30, 143, 145, 10, 191, 127, 123, 114, 68, 133, 175, 7, 42, 24, 52, 222, 12, 99, 211, 75, 101, 44, 200, 105, 135, 199, 95, 111, 248, 160, 182, 125, 9, 81, 220, 74, 77, 185, 233, 50, 167, 217, 141, 25, 196, 51, 25, 235, 183, 0, 195, 83, 19, 216, 0, 152, 125, 77, 32, 170, 55, 26, 194, 20, 204, 194, 141, 25, 227, 5, 203, 28, 115, 11, 11, 209, 93, 40, 113, 63, 6, 209, 148, 150, 142, 25, 129, 147, 248, 92, 188, 40, 205, 45, 35, 57, 147, 192, 190, 162, 195, 221, 154, 236, 165, 12, 108, 134, 246, 239, 68, 251, 138, 178, 191, 222, 127, 119, 196, 199, 160, 40, 251, 158, 124, 44, 133, 249, 88, 212, 181, 37, 67, 75, 151, 32, 175, 47, 234, 6, 213, 243, 70, 150, 185, 29, 106, 181, 138, 188, 35, 60, 65, 53, 54, 202, 173, 183, 253, 160, 158, 222, 103, 119, 125, 231, 70, 168, 221, 228, 127, 230, 135, 114, 81, 93, 209, 50, 198, 100, 60, 221, 174, 161, 26, 88, 61, 25, 101, 217, 194, 221, 118, 23, 219, 162, 69, 108, 52, 22, 25, 54, 81, 62, 3, 199, 229, 30, 183, 125, 88, 145, 80, 153, 215, 189, 161, 165, 214, 27, 61, 133, 32, 154, 179, 61, 223, 69, 249, 108, 159, 89, 177, 94, 35, 244, 74, 225, 244, 145, 128, 45, 104, 132, 110, 29, 212, 8, 189, 119, 31, 198, 233, 120, 218, 229, 172, 245, 48, 40, 15, 126, 24, 179, 154, 216, 237, 67, 95, 248, 226, 206, 72, 247, 246, 195, 65, 208, 151, 30, 21, 116, 15, 235, 120, 124, 122, 68, 203, 155, 7, 97, 105, 247, 193, 154, 171, 228, 11, 185, 21, 99, 142, 213, 33, 203, 106, 194, 199, 119, 160, 59, 100, 152, 226, 234, 235, 219, 163, 153, 235, 227, 224, 157, 165, 184, 146, 247, 218, 41, 168, 69, 21, 50, 37, 173, 162, 101, 213, 34, 221, 88, 93, 85, 31, 249, 255, 8, 207, 164, 98, 61, 33, 130, 227, 111, 100, 143, 111, 107], pointer: 8 } +Message { data: [118, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 103, 96, 96, 112, 73, 204, 205, 76, 204, 203, 207, 213, 158, 98, 194, 8, 228, 50, 250, 1, 9, 7, 135, 172, 146, 172, 162, 212, 24, 151, 252, 242, 188, 156, 252, 196, 148, 226, 24, 151, 210, 228, 236, 220, 196, 188, 152, 224, 212, 196, 226, 252, 60, 5, 3, 99, 152, 136, 130, 174, 66, 176, 129, 177, 171, 161, 5, 144, 17, 146, 145, 170, 224, 147, 159, 151, 158, 90, 92, 162, 16, 158, 154, 154, 157, 154, 151, 162, 151, 88, 150, 201, 48, 227, 61, 23, 3, 18, 96, 116, 87, 225, 67, 230, 51, 0, 0, 30, 181, 33, 84], pointer: 8 } +Message { data: [95, 3, 0, 0, 9, 0, 0, 0, 120, 156, 173, 149, 65, 111, 27, 69, 20, 199, 183, 72, 72, 72, 8, 136, 212, 47, 240, 36, 36, 228, 136, 78, 180, 187, 118, 76, 205, 41, 222, 196, 73, 220, 216, 181, 137, 141, 44, 145, 141, 166, 227, 221, 177, 61, 238, 236, 206, 106, 103, 55, 118, 114, 45, 18, 18, 87, 56, 160, 182, 66, 92, 168, 128, 27, 71, 56, 113, 238, 13, 33, 14, 136, 3, 92, 250, 25, 184, 242, 198, 113, 149, 84, 209, 186, 23, 239, 193, 207, 171, 241, 204, 255, 247, 254, 239, 205, 243, 91, 150, 101, 125, 38, 162, 33, 171, 184, 118, 244, 225, 215, 149, 13, 124, 191, 53, 196, 143, 157, 157, 121, 60, 77, 152, 223, 83, 185, 212, 156, 63, 244, 3, 21, 37, 146, 103, 220, 191, 107, 95, 128, 199, 134, 130, 95, 64, 95, 65, 127, 194, 193, 61, 184, 0, 23, 78, 204, 215, 198, 60, 225, 169, 104, 139, 249, 169, 239, 216, 139, 197, 1, 110, 230, 113, 8, 163, 12, 250, 91, 176, 203, 100, 196, 53, 223, 138, 146, 242, 197, 63, 63, 89, 47, 159, 55, 150, 113, 231, 22, 202, 99, 188, 109, 226, 211, 149, 24, 158, 82, 99, 193, 193, 227, 44, 3, 175, 115, 224, 217, 21, 167, 15, 4, 246, 68, 192, 40, 139, 67, 122, 36, 66, 90, 143, 195, 115, 178, 4, 160, 199, 185, 158, 208, 70, 151, 118, 89, 154, 209, 109, 226, 212, 106, 21, 162, 85, 158, 250, 158, 75, 110, 238, 218, 19, 154, 54, 53, 221, 87, 41, 197, 44, 104, 75, 197, 161, 138, 233, 110, 202, 103, 139, 77, 38, 129, 234, 243, 233, 141, 4, 126, 179, 46, 19, 120, 223, 36, 80, 94, 153, 192, 110, 150, 74, 223, 174, 34, 243, 53, 155, 204, 185, 79, 126, 255, 161, 208, 152, 13, 19, 187, 43, 207, 221, 23, 49, 135, 125, 46, 77, 184, 60, 187, 6, 45, 113, 198, 53, 52, 186, 80, 50, 105, 111, 66, 217, 181, 125, 187, 124, 93, 244, 109, 251, 89, 161, 232, 123, 38, 62, 94, 41, 122, 204, 152, 158, 176, 24, 234, 147, 136, 133, 40, 235, 145, 158, 8, 185, 190, 3, 88, 166, 76, 242, 177, 134, 15, 224, 152, 165, 34, 19, 200, 97, 152, 58, 163, 145, 8, 4, 147, 128, 189, 2, 125, 150, 112, 40, 185, 182, 189, 125, 7, 98, 149, 129, 138, 65, 178, 33, 151, 155, 232, 15, 153, 45, 235, 135, 182, 75, 90, 10, 167, 52, 18, 153, 206, 105, 202, 35, 49, 223, 36, 122, 182, 104, 38, 43, 9, 110, 224, 63, 91, 214, 226, 99, 131, 255, 104, 37, 126, 143, 203, 17, 189, 151, 39, 34, 227, 41, 233, 241, 249, 57, 197, 190, 210, 25, 233, 119, 90, 196, 174, 58, 100, 208, 240, 136, 107, 59, 53, 210, 238, 28, 214, 7, 71, 190, 189, 77, 180, 217, 50, 93, 110, 153, 113, 26, 176, 152, 74, 149, 199, 99, 78, 177, 81, 50, 236, 153, 37, 56, 9, 130, 218, 136, 85, 221, 138, 225, 252, 107, 227, 121, 161, 205, 7, 38, 58, 43, 57, 141, 115, 109, 117, 198, 125, 187, 114, 189, 120, 103, 214, 78, 225, 169, 19, 227, 194, 39, 175, 57, 85, 104, 16, 151, 117, 105, 171, 144, 167, 49, 12, 84, 42, 67, 188, 194, 91, 112, 200, 83, 14, 248, 83, 172, 27, 38, 5, 179, 43, 213, 23, 255, 126, 94, 168, 250, 135, 81, 189, 191, 90, 85, 68, 28, 39, 72, 62, 158, 208, 133, 114, 61, 206, 38, 74, 170, 241, 185, 239, 18, 251, 46, 244, 18, 51, 53, 178, 87, 175, 134, 149, 124, 95, 40, 249, 174, 137, 253, 149, 146, 3, 37, 71, 80, 151, 34, 48, 55, 195, 147, 249, 213, 116, 58, 65, 144, 20, 246, 184, 204, 231, 252, 212, 183, 157, 229, 205, 193, 46, 8, 38, 70, 248, 193, 11, 90, 40, 252, 196, 228, 250, 96, 61, 194, 101, 92, 111, 137, 68, 103, 34, 120, 8, 157, 120, 1, 113, 32, 153, 214, 6, 226, 231, 225, 55, 133, 16, 255, 25, 8, 182, 30, 136, 143, 112, 189, 43, 217, 249, 165, 122, 138, 19, 150, 235, 12, 14, 241, 218, 25, 138, 47, 167, 39, 133, 20, 95, 173, 209, 138, 218, 178, 6, 45, 188, 136, 208, 198, 217, 130, 118, 52, 112, 118, 47, 234, 17, 252, 242, 93, 33, 196, 155, 38, 14, 214, 2, 225, 188, 210, 8, 208, 108, 26, 237, 102, 239, 139, 66, 237, 191, 141, 1, 227, 245, 104, 87, 139, 12, 128, 146, 153, 231, 155, 134, 69, 54, 127, 44, 100, 89, 252, 137, 30, 173, 100, 57, 113, 109, 215, 57, 133, 150, 194, 110, 51, 82, 123, 74, 165, 232, 252, 22, 212, 211, 156, 184, 168, 255, 18, 173, 133, 67, 199, 232, 253, 90, 98, 133, 122, 223, 154, 151, 79, 245, 59, 214, 159, 214, 213, 243, 63, 183, 118, 63, 207], pointer: 8 } +Message { data: [93, 2, 0, 0, 9, 0, 0, 0, 120, 156, 173, 149, 205, 107, 19, 65, 24, 198, 19, 109, 65, 193, 131, 232, 197, 63, 192, 143, 131, 153, 154, 164, 105, 155, 226, 39, 33, 212, 40, 217, 22, 186, 109, 131, 88, 28, 39, 201, 187, 201, 152, 217, 217, 56, 187, 107, 216, 30, 123, 235, 165, 21, 84, 208, 147, 8, 30, 60, 136, 21, 196, 139, 160, 7, 65, 241, 15, 168, 39, 65, 80, 60, 120, 233, 189, 71, 39, 187, 173, 135, 204, 136, 29, 200, 194, 236, 176, 187, 195, 252, 158, 231, 217, 119, 102, 14, 165, 82, 169, 122, 232, 251, 20, 4, 184, 103, 31, 22, 142, 201, 231, 116, 83, 222, 172, 208, 167, 119, 67, 88, 94, 34, 110, 151, 10, 192, 53, 128, 14, 240, 38, 154, 33, 65, 27, 4, 246, 28, 44, 123, 92, 18, 180, 9, 203, 217, 28, 26, 24, 134, 29, 32, 193, 24, 46, 19, 78, 129, 49, 192, 21, 66, 93, 84, 241, 88, 19, 223, 240, 66, 60, 235, 245, 198, 28, 70, 26, 171, 231, 118, 82, 123, 215, 193, 62, 88, 182, 39, 178, 141, 200, 86, 126, 153, 74, 141, 202, 254, 104, 255, 253, 21, 83, 65, 121, 84, 33, 194, 245, 120, 36, 201, 140, 197, 176, 143, 75, 167, 15, 12, 194, 10, 105, 13, 236, 188, 41, 108, 28, 149, 136, 91, 167, 156, 196, 156, 175, 167, 94, 43, 166, 28, 157, 169, 139, 166, 156, 2, 90, 104, 83, 31, 87, 169, 3, 49, 233, 65, 230, 71, 122, 144, 116, 68, 231, 232, 130, 41, 105, 2, 149, 104, 11, 151, 88, 152, 128, 222, 143, 222, 87, 44, 177, 161, 88, 154, 148, 69, 209, 195, 85, 143, 183, 112, 76, 186, 249, 188, 160, 88, 218, 210, 145, 202, 166, 164, 41, 180, 200, 235, 64, 4, 169, 179, 8, 215, 218, 52, 72, 172, 109, 69, 95, 20, 224, 137, 161, 100, 88, 68, 243, 180, 209, 198, 22, 225, 49, 232, 218, 198, 55, 37, 195, 71, 58, 103, 220, 20, 52, 189, 175, 197, 103, 17, 33, 40, 52, 49, 229, 152, 224, 171, 253, 149, 56, 31, 250, 237, 164, 142, 22, 231, 148, 12, 190, 235, 164, 93, 50, 148, 150, 203, 34, 43, 194, 22, 245, 3, 210, 73, 226, 62, 188, 121, 92, 65, 125, 214, 161, 76, 227, 206, 229, 144, 29, 185, 93, 249, 37, 74, 182, 150, 23, 79, 21, 208, 79, 29, 232, 150, 41, 40, 255, 143, 184, 237, 0, 238, 1, 174, 146, 70, 132, 236, 144, 59, 204, 235, 129, 136, 165, 56, 252, 183, 242, 231, 215, 116, 82, 110, 155, 74, 25, 255, 191, 148, 153, 88, 7, 182, 60, 47, 41, 195, 51, 35, 155, 74, 46, 219, 58, 49, 69, 83, 49, 5, 148, 207, 230, 115, 49, 228, 205, 234, 186, 226, 184, 174, 131, 220, 49, 133, 76, 236, 171, 214, 107, 114, 60, 176, 254, 166, 178, 224, 181, 160, 63, 77, 44, 107, 122, 197, 87, 188, 127, 24, 74, 241, 77, 34, 59, 16, 132, 183, 118, 65, 228, 211, 91, 5, 180, 163, 3, 93, 54, 5, 77, 33, 187, 43, 168, 52, 102, 243, 221, 51, 244, 221, 250, 47, 37, 235, 103, 58, 214, 156, 41, 171, 136, 174, 131, 8, 125, 194, 192, 205, 224, 89, 232, 201, 163, 91, 116, 50, 50, 89, 193, 104, 82, 76, 39, 31, 111, 43, 236, 87, 58, 246, 218, 198, 202, 223, 129, 127, 0, 135, 142, 56, 90], pointer: 8 } +Message { data: [219, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 99, 96, 96, 200, 73, 205, 203, 171, 52, 203, 213, 158, 98, 194, 12, 228, 49, 122, 0, 9, 7, 135, 220, 172, 252, 178, 252, 152, 16, 115, 133, 224, 140, 204, 212, 156, 148, 152, 252, 156, 20, 133, 226, 228, 140, 252, 252, 28, 133, 244, 196, 162, 196, 244, 84, 133, 24, 67, 99, 5, 93, 5, 223, 204, 228, 140, 196, 212, 28, 5, 247, 162, 196, 74, 32, 55, 36, 35, 85, 33, 60, 53, 53, 59, 53, 47, 69, 47, 183, 192, 184, 220, 215, 141, 1, 6, 152, 160, 244, 1, 144, 37, 64, 188, 31, 68, 71, 19, 107, 153, 59, 132, 78, 43, 202, 207, 85, 72, 76, 46, 201, 204, 207, 163, 138, 253, 9, 88, 237, 47, 205, 43, 201, 44, 201, 73, 77, 81, 72, 3, 186, 36, 181, 72, 193, 40, 198, 200, 192, 208, 18, 106, 188, 99, 94, 94, 105, 98, 142, 66, 68, 68, 88, 140, 129, 177, 161, 17, 126, 55, 164, 229, 36, 38, 111, 189, 214, 196, 8, 115, 4, 51, 212, 242, 35, 64, 204, 2, 196, 46, 107, 24, 24, 88, 129, 180, 0, 72, 252, 236, 73, 126, 134, 221, 12, 8, 0, 0, 55, 242, 102, 234], pointer: 8 } +Message { data: [167, 8, 0, 0, 9, 0, 0, 0, 120, 156, 189, 153, 107, 136, 100, 71, 21, 128, 123, 242, 67, 69, 163, 63, 252, 17, 132, 69, 172, 32, 100, 103, 220, 204, 216, 221, 51, 179, 51, 227, 18, 152, 247, 107, 231, 197, 244, 236, 116, 70, 59, 12, 53, 247, 86, 119, 87, 230, 118, 213, 77, 221, 186, 211, 219, 187, 74, 134, 24, 88, 13, 36, 97, 113, 37, 74, 98, 36, 27, 18, 34, 171, 6, 37, 200, 6, 66, 34, 136, 17, 193, 72, 36, 24, 246, 135, 129, 184, 34, 75, 196, 199, 15, 149, 196, 7, 232, 57, 117, 111, 207, 244, 99, 106, 30, 75, 111, 247, 143, 174, 190, 247, 86, 223, 250, 234, 156, 83, 167, 206, 57, 245, 145, 68, 34, 81, 226, 186, 66, 55, 117, 190, 116, 226, 82, 223, 16, 92, 119, 60, 9, 95, 195, 195, 50, 31, 86, 202, 185, 249, 48, 224, 78, 110, 48, 73, 10, 84, 209, 2, 35, 126, 80, 113, 138, 185, 213, 17, 210, 77, 70, 132, 212, 69, 166, 72, 198, 247, 104, 80, 36, 50, 79, 198, 164, 39, 67, 69, 22, 88, 153, 44, 97, 63, 230, 50, 143, 83, 194, 5, 25, 85, 92, 83, 104, 243, 74, 150, 72, 106, 8, 222, 167, 37, 182, 253, 185, 49, 151, 164, 114, 169, 84, 170, 27, 222, 181, 94, 162, 106, 195, 99, 235, 1, 244, 85, 14, 13, 152, 185, 233, 73, 81, 88, 47, 51, 182, 201, 132, 219, 83, 242, 123, 251, 63, 180, 149, 168, 126, 110, 139, 219, 225, 14, 224, 134, 246, 85, 228, 223, 104, 226, 199, 225, 125, 233, 231, 230, 169, 214, 100, 156, 241, 66, 81, 75, 81, 127, 5, 19, 58, 205, 221, 128, 100, 52, 163, 30, 153, 100, 64, 46, 10, 164, 51, 157, 76, 13, 116, 229, 146, 253, 240, 120, 165, 200, 72, 118, 23, 227, 194, 79, 126, 96, 197, 56, 134, 237, 215, 154, 48, 102, 169, 79, 5, 153, 151, 46, 217, 160, 2, 198, 130, 47, 178, 58, 18, 73, 19, 238, 50, 37, 72, 86, 42, 207, 37, 166, 35, 11, 152, 233, 123, 23, 153, 84, 140, 110, 110, 48, 170, 73, 166, 40, 203, 40, 24, 4, 75, 246, 3, 88, 58, 6, 203, 56, 84, 121, 76, 107, 22, 192, 141, 165, 80, 249, 94, 29, 236, 191, 249, 23, 154, 96, 127, 154, 136, 96, 191, 139, 237, 3, 71, 130, 205, 148, 65, 56, 40, 159, 168, 19, 74, 169, 175, 107, 191, 71, 136, 56, 13, 74, 133, 14, 53, 84, 111, 189, 121, 202, 74, 245, 44, 182, 97, 19, 21, 242, 44, 179, 45, 190, 69, 189, 220, 28, 24, 6, 89, 161, 158, 135, 98, 81, 186, 210, 116, 163, 42, 27, 25, 2, 59, 216, 231, 20, 207, 231, 193, 96, 199, 192, 224, 141, 0, 135, 64, 128, 3, 104, 201, 154, 232, 122, 229, 190, 157, 186, 199, 170, 92, 7, 201, 244, 190, 100, 56, 234, 152, 236, 206, 104, 170, 130, 186, 139, 152, 104, 20, 117, 57, 37, 65, 89, 82, 92, 223, 126, 222, 200, 118, 49, 18, 22, 50, 245, 66, 183, 101, 9, 211, 0, 179, 108, 0, 235, 252, 197, 111, 173, 96, 57, 108, 207, 223, 74, 176, 193, 189, 193, 72, 231, 28, 223, 98, 93, 200, 119, 199, 218, 67, 86, 190, 183, 80, 112, 247, 237, 203, 87, 93, 10, 187, 58, 74, 117, 129, 119, 64, 58, 198, 4, 46, 1, 36, 157, 209, 199, 81, 199, 18, 40, 70, 89, 227, 186, 124, 247, 222, 146, 149, 224, 71, 72, 240, 220, 193, 4, 187, 162, 96, 130, 41, 170, 57, 140, 52, 2, 243, 196, 251, 35, 162, 160, 42, 100, 13, 108, 170, 64, 230, 153, 56, 250, 31, 146, 67, 241, 31, 198, 185, 98, 14, 118, 13, 118, 151, 5, 25, 167, 194, 97, 42, 192, 153, 252, 227, 161, 199, 173, 51, 121, 15, 103, 82, 104, 154, 201, 138, 116, 105, 133, 204, 8, 151, 51, 163, 222, 5, 185, 69, 193, 185, 25, 175, 48, 41, 21, 136, 86, 112, 184, 200, 208, 77, 224, 149, 34, 12, 200, 132, 203, 17, 33, 242, 36, 160, 70, 92, 186, 121, 232, 217, 96, 119, 233, 27, 235, 86, 150, 239, 36, 246, 180, 187, 70, 150, 69, 229, 114, 65, 65, 20, 163, 178, 18, 52, 223, 129, 225, 23, 183, 96, 117, 70, 86, 25, 10, 141, 43, 53, 244, 116, 168, 34, 111, 215, 23, 47, 214, 170, 160, 64, 97, 210, 11, 145, 29, 249, 222, 81, 15, 88, 249, 94, 70, 190, 115, 22, 190, 18, 232, 62, 218, 219, 114, 83, 74, 202, 173, 10, 57, 35, 28, 47, 34, 94, 165, 133, 144, 145, 25, 151, 81, 164, 195, 109, 109, 77, 170, 77, 50, 135, 126, 22, 150, 12, 80, 165, 211, 64, 117, 18, 93, 174, 98, 94, 232, 50, 220, 214, 80, 114, 115, 50, 208, 181, 226, 123, 229, 15, 205, 123, 214, 109, 49, 222, 95, 17, 239, 171, 7, 227, 205, 129, 92, 138, 224, 193, 20, 10, 174, 64, 69, 247, 204, 57, 99, 104, 205, 15, 120, 244, 128, 92, 223, 126, 44, 178, 180, 57, 185, 197, 112, 33, 179, 29, 232, 84, 122, 207, 125, 162, 207, 251, 114, 162, 246, 211, 81, 57, 152, 203, 12, 192, 4, 136, 128, 59, 140, 244, 54, 94, 87, 157, 176, 6, 233, 162, 214, 28, 137, 90, 47, 128, 142, 123, 12, 76, 18, 36, 152, 170, 209, 235, 52, 247, 125, 176, 81, 164, 57, 221, 83, 176, 42, 245, 5, 164, 11, 14, 73, 135, 251, 66, 252, 254, 166, 27, 48, 116, 58, 73, 230, 185, 8, 97, 243, 188, 155, 164, 122, 1, 85, 250, 70, 80, 201, 193, 152, 109, 148, 66, 72, 162, 92, 112, 57, 198, 216, 146, 87, 30, 177, 114, 93, 71, 46, 217, 6, 46, 84, 224, 180, 244, 56, 188, 23, 153, 228, 107, 23, 173, 76, 127, 65, 38, 213, 6, 38, 84, 246, 36, 236, 191, 168, 223, 25, 35, 170, 219, 63, 231, 90, 177, 204, 22, 127, 182, 13, 88, 125, 24, 151, 200, 18, 211, 69, 4, 27, 101, 16, 159, 24, 251, 154, 120, 34, 180, 194, 189, 134, 112, 219, 109, 128, 195, 208, 114, 158, 185, 101, 244, 149, 65, 188, 47, 96, 152, 194, 96, 31, 64, 200, 63, 191, 191, 97, 133, 124, 166, 93, 138, 69, 23, 183, 200, 97, 207, 245, 184, 179, 105, 100, 247, 194, 75, 15, 91, 177, 174, 33, 150, 104, 3, 22, 238, 7, 145, 71, 65, 164, 27, 87, 191, 98, 69, 250, 85, 187, 144, 6, 141, 173, 193, 42, 80, 136, 116, 106, 237, 91, 86, 164, 15, 218, 229, 193, 48, 222, 128, 32, 110, 13, 115, 179, 108, 228, 45, 30, 251, 253, 166, 149, 235, 69, 228, 42, 223, 122, 174, 84, 18, 181, 199, 113, 207, 207, 114, 136, 231, 38, 61, 89, 70, 182, 217, 59, 206, 89, 217, 94, 71, 54, 191, 13, 108, 232, 245, 151, 96, 19, 135, 180, 202, 216, 214, 157, 191, 126, 206, 10, 245, 137, 142, 61, 19, 130, 214, 67, 165, 141, 171, 128, 92, 54, 32, 83, 10, 226, 97, 35, 173, 215, 127, 105, 5, 155, 193, 246, 226, 173, 7, 59, 90, 247, 158, 251, 253, 194, 167, 159, 73, 212, 125, 58, 182, 90, 67, 121, 154, 9, 81, 57, 142, 89, 188, 44, 249, 4, 52, 232, 97, 6, 131, 97, 70, 111, 188, 149, 215, 245, 64, 1, 254, 240, 63, 171, 86, 1, 62, 154, 104, 221, 102, 190, 63, 154, 9, 199, 204, 45, 100, 98, 127, 60, 107, 101, 250, 57, 50, 125, 163, 13, 76, 71, 236, 143, 106, 253, 38, 111, 80, 107, 115, 89, 225, 166, 56, 35, 31, 79, 200, 10, 47, 65, 222, 18, 42, 200, 152, 198, 101, 217, 228, 165, 201, 129, 88, 177, 187, 251, 192, 237, 111, 127, 221, 42, 189, 119, 19, 135, 203, 5, 90, 65, 101, 106, 51, 245, 207, 16, 239, 141, 139, 246, 84, 229, 42, 226, 61, 209, 22, 188, 35, 255, 3, 21, 92, 120, 163, 65, 193, 238, 97, 89, 165, 191, 243, 3, 19, 205, 154, 252, 200, 84, 23, 210, 177, 22, 39, 96, 59, 48, 249, 96, 252, 107, 138, 43, 15, 133, 118, 237, 194, 1, 27, 86, 43, 65, 210, 53, 213, 205, 198, 76, 238, 217, 127, 217, 115, 146, 239, 33, 136, 211, 66, 16, 140, 167, 179, 69, 137, 57, 154, 169, 11, 102, 41, 36, 235, 162, 128, 28, 15, 47, 82, 43, 199, 211, 200, 113, 111, 11, 57, 48, 128, 158, 41, 149, 164, 210, 212, 104, 227, 253, 247, 236, 145, 22, 90, 72, 7, 107, 225, 224, 24, 32, 47, 21, 165, 150, 5, 69, 253, 34, 233, 164, 142, 12, 3, 205, 29, 83, 111, 250, 246, 223, 179, 86, 146, 199, 91, 45, 134, 147, 38, 229, 42, 147, 121, 42, 76, 20, 53, 123, 249, 128, 18, 240, 253, 45, 28, 28, 99, 221, 113, 238, 138, 227, 240, 136, 10, 29, 101, 10, 240, 174, 21, 73, 38, 34, 211, 60, 113, 85, 88, 113, 94, 74, 236, 89, 123, 187, 121, 156, 65, 179, 70, 92, 172, 197, 5, 224, 56, 34, 187, 24, 254, 175, 182, 18, 152, 210, 124, 182, 133, 4, 24, 214, 206, 130, 37, 144, 51, 38, 33, 57, 117, 230, 128, 80, 123, 173, 117, 99, 155, 208, 21, 251, 76, 82, 29, 149, 42, 174, 62, 202, 173, 163, 95, 193, 209, 191, 212, 194, 209, 77, 73, 130, 155, 34, 84, 153, 49, 109, 252, 227, 131, 7, 228, 62, 95, 108, 225, 248, 232, 31, 103, 162, 10, 112, 108, 122, 199, 206, 111, 91, 135, 255, 77, 171, 135, 239, 173, 115, 8, 102, 246, 255, 187, 108, 29, 254, 163, 216, 210, 22, 14, 223, 103, 106, 129, 152, 182, 140, 201, 18, 22, 20, 166, 101, 201, 100, 234, 215, 94, 60, 160, 156, 176, 218, 66, 10, 227, 21, 239, 100, 78, 100, 126, 31, 254, 188, 61, 61, 249, 56, 182, 188, 117, 67, 239, 255, 24, 163, 132, 202, 83, 137, 186, 79, 71, 254, 230, 70, 223, 249, 217, 60, 178, 109, 123, 126, 254, 111, 119, 53, 9, 2, 107, 152, 59, 37, 195, 67, 214, 0, 50, 140, 6, 220, 197, 179, 35, 17, 228, 106, 47, 48, 151, 14, 53, 89, 204, 155, 40, 169, 241, 209, 78, 113, 90, 58, 155, 88, 50, 68, 36, 95, 14, 54, 33, 109, 199, 72, 55, 18, 135, 223, 37, 50, 113, 185, 187, 238, 2, 113, 32, 48, 88, 162, 94, 64, 70, 28, 109, 36, 85, 173, 14, 238, 93, 41, 63, 118, 229, 130, 213, 84, 126, 135, 56, 223, 63, 24, 103, 231, 204, 99, 202, 147, 113, 184, 102, 206, 80, 168, 203, 229, 206, 25, 229, 225, 122, 221, 77, 86, 165, 215, 67, 210, 134, 252, 100, 188, 201, 226, 159, 86, 20, 21, 65, 41, 242, 114, 184, 239, 50, 133, 231, 2, 37, 22, 52, 30, 77, 188, 243, 167, 7, 173, 19, 122, 19, 39, 116, 233, 176, 19, 82, 220, 39, 99, 94, 184, 17, 68, 103, 55, 138, 23, 10, 56, 248, 8, 142, 235, 115, 47, 42, 170, 227, 49, 138, 209, 64, 145, 42, 63, 62, 182, 229, 129, 222, 169, 101, 87, 143, 138, 231, 240, 92, 57, 112, 164, 207, 106, 13, 99, 49, 159, 135, 134, 25, 195, 184, 252, 193, 147, 86, 240, 127, 34, 248, 83, 77, 224, 134, 115, 1, 226, 176, 34, 30, 22, 103, 100, 232, 145, 49, 55, 200, 45, 44, 46, 175, 76, 79, 44, 47, 144, 204, 226, 153, 185, 158, 158, 177, 241, 128, 108, 48, 13, 123, 131, 32, 159, 77, 14, 36, 187, 83, 169, 100, 110, 42, 51, 54, 78, 146, 131, 189, 6, 110, 89, 74, 29, 160, 17, 215, 189, 43, 151, 238, 239, 30, 101, 90, 71, 193, 55, 252, 32, 235, 32, 5, 25, 110, 64, 0, 186, 115, 54, 83, 35, 249, 97, 239, 51, 86, 203, 54, 237, 35, 77, 19, 200, 50, 207, 99, 42, 151, 209, 84, 120, 172, 2, 28, 20, 28, 199, 56, 243, 194, 179, 172, 122, 20, 213, 69, 82, 67, 67, 253, 245, 93, 32, 244, 169, 235, 3, 255, 225, 129, 3, 86, 3, 247, 75, 50, 232, 202, 45, 81, 16, 69, 244, 242, 234, 10, 21, 141, 184, 63, 251, 212, 137, 38, 220, 243, 49, 238, 143, 247, 118, 83, 49, 46, 190, 104, 150, 150, 170, 173, 209, 48, 15, 176, 94, 139, 182, 88, 119, 116, 223, 153, 26, 26, 24, 136, 35, 132, 26, 155, 109, 64, 57, 119, 105, 187, 9, 229, 158, 218, 99, 210, 167, 63, 249, 177, 68, 237, 231, 255, 253, 239, 60, 3], pointer: 8 } +Message { data: [100, 1, 0, 0, 9, 0, 0, 0, 120, 156, 133, 145, 205, 78, 2, 49, 20, 133, 7, 37, 236, 140, 175, 112, 147, 137, 9, 4, 33, 192, 96, 212, 29, 14, 72, 192, 24, 252, 153, 1, 54, 221, 52, 195, 5, 170, 76, 103, 210, 233, 128, 179, 97, 231, 222, 5, 123, 119, 250, 18, 38, 238, 141, 75, 31, 194, 199, 176, 37, 248, 147, 16, 226, 93, 244, 180, 55, 109, 191, 115, 218, 29, 195, 48, 104, 24, 120, 60, 240, 100, 44, 56, 139, 252, 252, 162, 154, 86, 205, 212, 157, 26, 106, 181, 17, 10, 49, 35, 126, 28, 49, 143, 152, 230, 77, 28, 73, 96, 220, 52, 73, 239, 4, 10, 96, 11, 38, 195, 32, 4, 42, 65, 142, 17, 108, 187, 14, 86, 189, 1, 217, 74, 169, 92, 205, 17, 53, 43, 147, 242, 81, 17, 26, 108, 202, 56, 66, 61, 240, 113, 144, 236, 131, 171, 182, 22, 192, 81, 43, 57, 102, 124, 4, 195, 64, 44, 143, 247, 17, 111, 145, 15, 138, 126, 104, 189, 190, 60, 26, 223, 181, 181, 210, 90, 74, 153, 82, 154, 214, 122, 181, 102, 46, 223, 57, 237, 59, 110, 183, 217, 36, 250, 126, 87, 176, 16, 90, 202, 154, 139, 202, 113, 246, 146, 10, 9, 86, 142, 148, 14, 151, 244, 21, 9, 28, 169, 250, 17, 180, 80, 160, 166, 238, 177, 247, 53, 106, 102, 69, 125, 208, 218, 91, 163, 206, 231, 243, 179, 174, 227, 66, 187, 163, 102, 196, 69, 228, 116, 132, 208, 160, 201, 64, 32, 245, 85, 100, 30, 133, 76, 80, 47, 33, 165, 99, 176, 39, 212, 71, 104, 75, 184, 224, 127, 109, 104, 244, 231, 226, 126, 99, 224, 15, 253, 27, 238, 127, 104, 253, 254, 58, 107, 48, 4, 39, 225, 114, 60, 163, 83, 36, 214, 65, 69, 61, 117, 51, 22, 201, 79, 230, 2, 92, 35, 157, 192, 121, 48, 93, 102, 126, 154, 61, 111, 4, 239, 106, 125, 203, 110, 27, 25, 227, 183, 190, 0, 62, 152, 144, 64], pointer: 8 } +Message { data: [39, 5, 0, 0, 9, 0, 0, 0, 120, 156, 189, 86, 79, 104, 28, 101, 20, 223, 180, 189, 8, 130, 32, 234, 69, 208, 119, 106, 38, 104, 74, 154, 52, 109, 115, 80, 51, 59, 59, 155, 204, 102, 103, 38, 204, 108, 146, 166, 140, 135, 47, 187, 223, 238, 126, 217, 217, 249, 226, 204, 236, 110, 182, 94, 196, 131, 40, 165, 160, 40, 136, 130, 182, 210, 131, 39, 171, 208, 163, 61, 20, 61, 123, 232, 69, 169, 20, 68, 177, 130, 189, 88, 197, 139, 224, 193, 247, 38, 155, 118, 179, 187, 83, 82, 136, 13, 108, 222, 236, 31, 222, 239, 247, 126, 239, 239, 99, 153, 76, 38, 87, 48, 13, 109, 73, 213, 139, 205, 23, 62, 56, 241, 44, 126, 48, 182, 138, 255, 230, 231, 195, 118, 208, 240, 189, 252, 138, 181, 228, 41, 166, 122, 198, 112, 65, 47, 129, 163, 155, 198, 25, 221, 157, 240, 52, 219, 92, 54, 138, 106, 201, 176, 45, 215, 211, 152, 223, 22, 1, 44, 178, 48, 20, 17, 76, 130, 195, 89, 165, 11, 121, 25, 66, 169, 206, 97, 141, 243, 6, 15, 42, 199, 154, 91, 51, 153, 119, 107, 153, 221, 191, 67, 61, 251, 15, 65, 226, 235, 38, 217, 229, 135, 134, 150, 126, 101, 203, 103, 93, 68, 93, 236, 54, 131, 81, 160, 27, 226, 210, 16, 232, 252, 216, 14, 232, 33, 178, 209, 195, 130, 58, 51, 117, 182, 1, 71, 193, 210, 157, 85, 27, 242, 156, 197, 199, 64, 237, 178, 58, 152, 44, 100, 97, 154, 0, 160, 56, 172, 34, 36, 152, 98, 123, 130, 120, 253, 242, 235, 249, 84, 94, 63, 147, 24, 149, 135, 229, 229, 214, 101, 39, 230, 13, 100, 230, 110, 133, 28, 129, 133, 31, 201, 0, 170, 9, 65, 189, 205, 192, 173, 179, 14, 210, 27, 16, 232, 155, 215, 170, 169, 68, 46, 19, 145, 143, 247, 65, 36, 103, 184, 154, 13, 248, 6, 180, 226, 74, 214, 203, 153, 26, 32, 55, 83, 119, 52, 67, 45, 38, 111, 167, 78, 159, 0, 165, 192, 130, 182, 224, 33, 28, 159, 155, 155, 66, 246, 117, 30, 134, 220, 247, 57, 82, 86, 125, 190, 205, 130, 10, 126, 103, 143, 91, 156, 249, 200, 211, 101, 113, 43, 172, 96, 114, 139, 178, 205, 65, 233, 215, 82, 173, 86, 153, 8, 19, 29, 55, 187, 111, 14, 209, 191, 222, 43, 170, 10, 133, 241, 233, 129, 208, 63, 49, 53, 11, 138, 93, 142, 229, 70, 200, 97, 122, 234, 248, 201, 137, 251, 197, 151, 71, 125, 179, 188, 43, 131, 50, 135, 118, 116, 140, 116, 14, 2, 84, 126, 18, 138, 60, 78, 228, 54, 91, 145, 40, 195, 114, 242, 227, 225, 18, 125, 250, 153, 11, 169, 25, 184, 77, 161, 188, 126, 48, 33, 204, 206, 244, 135, 48, 141, 25, 232, 215, 116, 153, 133, 113, 151, 202, 19, 20, 122, 132, 227, 152, 149, 233, 68, 226, 91, 159, 92, 60, 148, 198, 47, 127, 4, 237, 149, 131, 225, 119, 10, 37, 86, 101, 43, 38, 114, 211, 19, 30, 13, 148, 46, 184, 113, 215, 231, 17, 172, 70, 224, 158, 99, 72, 105, 112, 218, 20, 89, 204, 193, 18, 181, 58, 42, 205, 252, 134, 8, 106, 244, 219, 61, 141, 151, 111, 5, 13, 88, 99, 109, 108, 204, 102, 175, 251, 238, 158, 143, 82, 37, 191, 70, 146, 91, 251, 8, 201, 212, 23, 84, 10, 40, 103, 172, 234, 142, 155, 136, 89, 148, 178, 1, 118, 117, 15, 62, 74, 74, 144, 79, 134, 183, 83, 11, 53, 196, 15, 198, 62, 219, 15, 228, 138, 107, 104, 144, 87, 181, 146, 237, 172, 131, 169, 186, 37, 221, 193, 175, 188, 17, 79, 48, 51, 123, 26, 245, 108, 135, 194, 223, 45, 88, 129, 3, 10, 84, 164, 84, 104, 69, 49, 138, 184, 138, 197, 218, 207, 116, 18, 213, 13, 198, 99, 156, 105, 220, 7, 115, 167, 241, 94, 4, 151, 111, 227, 104, 171, 137, 50, 69, 241, 245, 227, 31, 166, 10, 247, 39, 69, 211, 61, 232, 40, 78, 206, 129, 130, 147, 53, 162, 32, 78, 77, 120, 174, 108, 242, 184, 78, 105, 30, 209, 73, 119, 174, 29, 57, 156, 202, 142, 42, 181, 179, 31, 118, 152, 216, 34, 168, 86, 14, 150, 12, 124, 70, 41, 142, 194, 146, 104, 210, 68, 194, 9, 75, 200, 84, 163, 202, 2, 246, 118, 53, 228, 81, 29, 88, 12, 49, 242, 232, 236, 240, 152, 0, 69, 223, 142, 241, 129, 87, 96, 149, 135, 145, 144, 65, 82, 114, 211, 151, 190, 31, 82, 46, 211, 227, 118, 149, 236, 27, 143, 132, 27, 41, 102, 196, 204, 151, 176, 40, 91, 17, 135, 241, 185, 169, 190, 190, 248, 81, 121, 39, 181, 72, 229, 216, 35, 19, 48, 153, 153, 50, 140, 107, 172, 198, 239, 109, 204, 185, 27, 215, 83, 5, 124, 139, 172, 191, 63, 110, 134, 182, 136, 247, 14, 44, 56, 234, 186, 103, 138, 114, 157, 33, 193, 133, 48, 57, 34, 246, 140, 141, 2, 242, 203, 74, 25, 67, 129, 149, 27, 59, 26, 193, 110, 102, 19, 66, 252, 176, 49, 150, 86, 109, 79, 81, 71, 123, 7, 75, 200, 14, 69, 77, 4, 184, 27, 119, 21, 17, 183, 46, 15, 41, 242, 67, 47, 91, 23, 136, 72, 105, 144, 64, 214, 203, 218, 217, 236, 58, 172, 217, 166, 170, 225, 91, 185, 177, 209, 133, 53, 217, 164, 8, 39, 97, 173, 206, 131, 61, 144, 26, 54, 91, 4, 74, 174, 133, 82, 244, 215, 242, 191, 95, 93, 73, 157, 2, 207, 145, 213, 15, 2, 152, 144, 198, 63, 250, 46, 21, 105, 153, 236, 236, 40, 164, 69, 213, 178, 108, 11, 81, 238, 109, 98, 59, 24, 156, 21, 147, 119, 210, 183, 238, 111, 36, 162, 51, 194, 179, 163, 91, 57, 21, 203, 185, 180, 168, 67, 214, 88, 128, 220, 74, 78, 119, 189, 108, 136, 62, 105, 57, 17, 68, 86, 212, 32, 215, 170, 112, 90, 78, 187, 225, 68, 91, 188, 44, 152, 79, 176, 223, 222, 253, 50, 21, 246, 121, 178, 218, 32, 172, 134, 103, 158, 93, 132, 156, 189, 102, 193, 89, 219, 210, 241, 240, 144, 62, 228, 100, 39, 128, 179, 50, 224, 84, 37, 194, 247, 7, 195, 251, 253, 253, 120, 8, 231, 167, 94, 109, 252, 69, 118, 110, 16, 39, 239, 229, 85, 171, 164, 186, 166, 151, 103, 65, 204, 162, 102, 95, 0, 189, 14, 197, 11, 230, 28, 35, 239, 55, 174, 126, 158, 26, 197, 19, 100, 237, 65, 239, 5, 175, 128, 137, 55, 116, 48, 109, 219, 209, 61, 234, 41, 65, 77, 46, 67, 138, 32, 57, 241, 112, 159, 19, 8, 11, 162, 14, 222, 129, 74, 223, 254, 76, 138, 238, 239, 63, 222, 30, 194, 188, 222, 139, 72, 140, 141, 138, 200, 246, 236, 241, 130, 186, 238, 146, 97, 221, 228, 90, 16, 120, 60, 140, 143, 90, 29, 234, 203, 39, 135, 188, 95, 234, 121, 255, 130, 236, 75, 15, 240, 190, 249, 32, 239, 160, 236, 156, 81, 153, 237, 247, 82, 249, 95, 24, 201, 31, 111, 250, 114, 200, 40, 15, 78, 43, 8, 104, 112, 170, 29, 28, 11, 202, 192, 117, 177, 227, 125, 185, 157, 234, 253, 21, 242, 190, 57, 232, 189, 44, 155, 91, 62, 143, 185, 167, 249, 44, 162, 251, 148, 110, 63, 60, 106, 125, 94, 142, 177, 209, 255, 231, 217, 120, 241, 213, 76, 230, 254, 174, 201, 100, 254, 3, 103, 193, 167, 196], pointer: 8 } +Message { data: [122, 1, 0, 0, 9, 0, 0, 0, 120, 156, 101, 145, 205, 78, 2, 49, 16, 199, 23, 209, 196, 163, 47, 64, 50, 7, 99, 52, 186, 100, 23, 68, 221, 196, 3, 202, 2, 33, 134, 16, 131, 202, 101, 47, 5, 6, 168, 116, 91, 237, 182, 248, 145, 152, 120, 245, 238, 91, 232, 209, 87, 240, 21, 124, 7, 223, 194, 89, 132, 4, 165, 73, 231, 63, 237, 76, 167, 191, 78, 215, 29, 199, 185, 70, 173, 80, 30, 6, 241, 238, 235, 126, 150, 214, 153, 9, 153, 114, 217, 140, 197, 237, 67, 212, 180, 143, 28, 199, 81, 213, 117, 163, 170, 213, 106, 194, 19, 174, 36, 180, 149, 28, 66, 69, 73, 131, 137, 1, 23, 252, 160, 116, 224, 22, 60, 47, 136, 252, 224, 168, 4, 117, 101, 70, 40, 187, 86, 15, 35, 207, 167, 112, 67, 163, 96, 178, 79, 94, 135, 113, 3, 151, 210, 112, 1, 23, 35, 132, 14, 226, 24, 41, 80, 81, 49, 38, 249, 248, 166, 120, 156, 219, 115, 230, 99, 101, 166, 207, 41, 18, 205, 143, 84, 159, 150, 209, 26, 132, 214, 208, 4, 213, 100, 188, 143, 50, 242, 160, 109, 108, 159, 43, 56, 17, 93, 27, 39, 132, 20, 20, 160, 134, 76, 67, 107, 48, 189, 53, 100, 122, 188, 120, 130, 184, 254, 135, 193, 79, 185, 231, 120, 29, 166, 53, 87, 58, 63, 16, 172, 167, 94, 248, 28, 204, 201, 206, 200, 218, 100, 86, 73, 195, 119, 199, 89, 35, 221, 72, 247, 223, 150, 73, 175, 24, 149, 177, 9, 241, 214, 148, 53, 8, 149, 16, 78, 213, 61, 76, 152, 132, 115, 183, 105, 19, 222, 163, 75, 67, 4, 43, 12, 199, 24, 97, 48, 205, 234, 41, 33, 176, 71, 59, 80, 240, 166, 88, 190, 87, 9, 35, 74, 251, 83, 196, 43, 165, 145, 34, 153, 38, 10, 216, 130, 5 +1, 30, 147, 223, 30, 169, 59, 78, 95, 213, 178, 6, 182, 235, 104, 160, 166, 49, 25, 193, 137, 89, 108, 255, 78, 218, 249, 79, 107, 150, 58, 159, 203, 252, 190, 239, 43, 125, 79, 233, 123, 211, 89, 28, 63, 177, 118, 148, 239], pointer: 8 } +Message { data: [78, 1, 0, 0, 9, 0, 0, 0, 120, 156, 157, 143, 61, 79, 2, 65, 16, 134, 23, 63, 18, 99, 101, 103, 233, 116, 106, 200, 93, 56, 32, 39, 52, 6, 184, 139, 49, 6, 98, 36, 70, 11, 215, 98, 61, 22, 92, 221, 15, 114, 11, 200, 117, 214, 214, 150, 246, 54, 254, 26, 126, 131, 63, 196, 57, 143, 83, 241, 163, 113, 146, 217, 157, 204, 190, 51, 251, 188, 235, 132, 16, 101, 70, 38, 86, 99, 113, 99, 85, 241, 177, 186, 140, 157, 194, 3, 30, 141, 6, 211, 50, 158, 82, 207, 163, 129, 81, 67, 33, 217, 72, 24, 109, 193, 129, 86, 43, 128, 46, 235, 9, 3, 222, 182, 133, 182, 152, 112, 104, 155, 177, 30, 112, 40, 151, 60, 31, 46, 14, 218, 205, 224, 146, 134, 194, 70, 224, 225, 56, 78, 28, 49, 197, 45, 180, 88, 130, 245, 97, 162, 52, 244, 77, 12, 163, 107, 14, 231, 156, 223, 114, 221, 131, 157, 227, 88, 12, 132, 102, 82, 38, 112, 149, 64, 96, 100, 111, 40, 89, 178, 235, 246, 37, 139, 214, 102, 39, 5, 50, 143, 119, 60, 204, 87, 204, 21, 204, 240, 133, 144, 85, 188, 55, 210, 254, 243, 34, 118, 200, 116, 196, 33, 144, 204, 90, 17, 165, 224, 184, 86, 242, 40, 181, 1, 94, 189, 86, 115, 16, 183, 74, 177, 170, 227, 219, 15, 241, 41, 226, 117, 185, 18, 83, 36, 63, 51, 114, 172, 56, 228, 43, 163, 69, 85, 60, 87, 57, 159, 186, 32, 164, 37, 223, 205, 214, 65, 51, 251, 210, 201, 221, 186, 119, 108, 66, 247, 252, 165, 239, 158, 102, 191, 121, 226, 255, 243, 84, 47, 255, 229, 169, 195, 7, 12, 58, 98, 74, 43, 21, 247, 131, 72, 13, 43, 247, 91, 181, 28, 136, 228, 104, 141, 66, 70, 182, 159, 146, 60, 109, 22, 201, 215, 120, 3, 134, 124, 146, 21], pointer: 8 } +Message { data: [177, 1, 0, 0, 9, 0, 0, 0, 120, 156, 165, 210, 189, 74, 3, 65, 16, 0, 224, 139, 40, 88, 136, 138, 47, 144, 41, 4, 21, 137, 36, 49, 248, 83, 153, 4, 255, 2, 103, 34, 70, 147, 194, 43, 92, 47, 147, 100, 205, 221, 110, 220, 221, 8, 177, 22, 4, 193, 74, 11, 193, 202, 119, 208, 71, 177, 16, 95, 64, 172, 236, 44, 108, 220, 189, 36, 136, 63, 215, 152, 131, 219, 129, 221, 97, 191, 155, 185, 25, 177, 44, 235, 136, 163, 91, 23, 84, 42, 159, 72, 127, 246, 58, 53, 172, 247, 34, 69, 189, 164, 211, 244, 152, 179, 154, 179, 213, 58, 109, 55, 72, 47, 236, 214, 17, 202, 136, 13, 200, 227, 9, 10, 40, 42, 34, 148, 132, 29, 222, 98, 21, 216, 68, 129, 78, 124, 9, 76, 206, 58, 21, 82, 65, 150, 214, 130, 108, 100, 149, 57, 191, 57, 63, 21, 61, 179, 122, 207, 64, 55, 190, 26, 79, 191, 147, 122, 137, 228, 66, 221, 50, 247, 170, 144, 241, 168, 139, 16, 131, 172, 215, 194, 222, 189, 48, 157, 140, 39, 19, 51, 78, 60, 161, 15, 140, 156, 69, 226, 214, 141, 230, 54, 14, 126, 105, 233, 72, 71, 187, 53, 234, 94, 31, 218, 188, 62, 176, 105, 83, 42, 234, 54, 160, 192, 2, 121, 195, 35, 82, 26, 249, 254, 229, 38, 84, 126, 55, 114, 169, 15, 121, 81, 31, 108, 123, 164, 221, 33, 5, 18, 133, 186, 211, 155, 84, 5, 244, 229, 219, 126, 40, 125, 213, 111, 209, 203, 221, 22, 219, 68, 139, 91, 132, 1, 103, 176, 166, 7, 32, 104, 119, 117, 229, 46, 84, 30, 50, 209, 254, 191, 156, 248, 246, 115, 33, 151, 51, 160, 253, 116, 30, 10, 62, 135, 148, 106, 238, 88, 213, 195, 46, 136, 171, 40, 103, 82, 95, 155, 231, 135, 188, 210, 158, 146, 176, 141, 162, 138, 174, 114, 18, 177, 120, 18, 138, 220, 71, 85, 167, 172, 6, 235, 92, 64, 111, 234, 131, 57, 78, 145, 15, 123, 34, 242, 83, 30, 29, 236, 200, 23, 70, 62, 253, 45, 151, 136, 223, 164, 226, 171, 178, 24, 232, 210, 82, 58, 20, 152, 215, 134, 13, 94, 129, 50, 145, 144, 57, 228, 39, 8, 123, 242, 143, 244, 63, 243, 244, 254, 66, 183, 53, 197, 150, 254, 124, 97, 26, 243, 24, 125, 10, 109, 204, 190, 137, 206, 195, 184, 53, 102, 125, 61, 159, 130, 8, 5, 233], pointer: 8 } +Message { data: [162, 1, 0, 0, 9, 0, 0, 0, 120, 156, 189, 210, 79, 75, 2, 65, 20, 0, 240, 213, 91, 218, 181, 251, 75, 162, 18, 107, 201, 52, 243, 150, 138, 248, 7, 82, 2, 5, 9, 36, 152, 220, 73, 135, 221, 157, 217, 102, 119, 37, 187, 117, 136, 14, 25, 20, 244, 77, 58, 245, 33, 58, 118, 238, 84, 208, 173, 110, 29, 130, 154, 209, 149, 180, 133, 46, 193, 14, 236, 62, 222, 240, 222, 242, 227, 237, 139, 40, 138, 98, 27, 200, 52, 49, 79, 166, 178, 102, 226, 54, 61, 39, 110, 66, 39, 226, 149, 203, 81, 219, 208, 81, 187, 230, 218, 164, 211, 46, 115, 68, 53, 11, 173, 216, 80, 97, 174, 141, 161, 142, 17, 7, 167, 135, 161, 129, 81, 187, 225, 82, 58, 128, 22, 198, 58, 166, 218, 159, 165, 176, 14, 51, 197, 34, 223, 72, 138, 4, 242, 156, 147, 62, 86, 77, 43, 21, 123, 80, 149, 201, 9, 123, 241, 93, 162, 196, 115, 40, 227, 117, 144, 184, 77, 168, 66, 137, 185, 34, 169, 13, 160, 137, 53, 109, 0, 5, 217, 82, 227, 42, 148, 196, 183, 116, 99, 32, 209, 145, 207, 140, 15, 253, 229, 161, 169, 140, 23, 65, 162, 83, 80, 71, 22, 52, 137, 41, 230, 122, 228, 224, 113, 237, 174, 75, 59, 61, 105, 85, 239, 19, 62, 235, 135, 103, 221, 151, 241, 50, 72, 107, 26, 90, 200, 208, 1, 57, 163, 162, 61, 198, 29, 88, 173, 82, 129, 54, 92, 13, 199, 165, 119, 254, 110, 209, 231, 141, 134, 198, 222, 29, 233, 61, 15, 210, 187, 53, 30, 41, 101, 140, 78, 208, 98, 33, 198, 147, 61, 61, 136, 251, 164, 143, 211, 171, 123, 19, 164, 52, 35, 86, 183, 106, 162, 46, 161, 24, 154, 61, 97, 37, 80, 36, 118, 135, 245, 197, 66, 228, 29, 3, 81, 135, 216, 82, 253, 124, 236, 159, 239, 139, 167, 174, 200, 120, 21, 164, 122, 27, 126, 58, 26, 14, 227, 4, 219, 222, 18, 23, 9, 165, 152, 75, 241, 90, 127, 217, 39, 126, 245, 196, 13, 25, 135, 65, 138, 179, 80, 102, 76, 131, 58, 233, 246, 196, 140, 41, 252, 234, 151, 224, 179, 183, 37, 31, 248, 105, 26, 92, 248, 15, 120, 244, 75, 85, 139, 118, 195, 195, 5, 101, 250, 132, 112, 44, 58, 115, 241, 13, 34, 221, 176, 30], pointer: 8 } +Message { data: [151, 0, 0, 0, 9, 0, 0, 0, 120, 156, 227, 103, 96, 96, 72, 42, 173, 204, 173, 204, 45, 45, 206, 76, 54, 73, 43, 74, 77, 205, 213, 158, 98, 194, 8, 20, 102, 140, 5, 18, 14, 14, 201, 229, 73, 169, 85, 49, 190, 32, 233, 24, 143, 196, 162, 20, 5, 151, 196, 188, 228, 212, 24, 255, 156, 20, 5, 183, 196, 178, 252, 162, 204, 146, 212, 226, 152, 176, 204, 188, 202, 156, 244, 162, 252, 252, 178, 212, 34, 5, 53, 133, 144, 140, 84, 133, 160, 212, 20, 5, 143, 212, 196, 20, 5, 93, 5, 159, 204, 178, 84, 5, 183, 252, 34, 176, 120, 120, 106, 106, 118, 106, 94, 138, 94, 110, 129, 177, 66, 227, 99, 6, 24, 96, 130, 210, 14, 64, 171, 65, 182, 151, 128, 104, 127, 3, 14, 6, 100, 0, 0, 198, 219, 43, 73], pointer: 8 } +Message { data: [46, 1, 0, 0, 9, 0, 0, 0, 120, 156, 227, 98, 96, 96, 72, 207, 73, 172, 42, 205, 203, 47, 51, 50, 204, 213, 158, 98, 194, 12, 20, 97, 92, 14, 36, 28, 28, 210, 18, 115, 203, 202, 98, 130, 51, 18, 139, 82, 99, 114, 242, 139, 75, 20, 178, 74, 179, 83, 147, 242, 43, 98, 194, 28, 117, 125, 64, 124, 47, 8, 95, 55, 44, 63, 167, 52, 55, 85, 193, 192, 210, 80, 215, 208, 192, 32, 6, 187, 156, 41, 138, 184, 2, 66, 92, 65, 87, 193, 200, 0, 72, 132, 100, 164, 42, 184, 23, 229, 231, 23, 64, 217, 94, 169, 37, 10, 193, 249, 121, 233, 10, 26, 225, 25, 169, 121, 96, 161, 240, 212, 212, 236, 212, 188, 20, 245, 98, 5, 255, 178, 212, 34, 77, 189, 220, 2, 227, 13, 86, 181, 12, 48, 192, 4, 165, 29, 24, 129, 126, 0, 210, 39, 65, 126, 41, 35, 224, 151, 196, 162, 204, 252, 210, 98, 5, 199, 162, 146, 204, 226, 146, 98, 160, 221, 40, 206, 140, 6, 186, 83, 193, 208, 34, 54, 198, 208, 88, 183, 4, 232, 130, 162, 212, 178, 212, 156, 196, 146, 204, 252, 60, 160, 202, 228, 252, 146, 18, 32, 35, 57, 49, 47, 165, 82, 161, 28, 226, 54, 5, 13, 67, 75, 51, 11, 176, 211, 178, 88, 140, 48, 156, 118, 128, 1, 226, 180, 14, 16, 93, 64, 185, 211, 140, 12, 44, 98, 21, 162, 29, 140, 141, 12, 98, 99, 12, 140, 161, 33, 231, 84, 90, 156, 145, 155, 154, 7, 214, 144, 151, 154, 83, 9, 11, 55, 144, 163, 34, 103, 6, 224, 12, 175, 102, 144, 163, 18, 109, 5, 25, 144, 1, 0, 139, 155, 148, 211], pointer: 8 } +Message { data: [213, 0, 0, 0, 9, 0, 0, 0, 120, 156, 227, 96, 96, 96, 40, 200, 207, 207, 73, 73, 76, 169, 202, 213, 158, 98, 194, 4, 228, 51, 154, 1, 9, 7, 135, 148, 244, 226, 202, 220, 24, 223, 210, 226, 204, 228, 24, 67, 75, 115, 35, 5, 93, 5, 143, 196, 162, 178, 212, 226, 146, 24, 3, 67, 5, 255, 210, 18, 5, 255, 60, 133, 144, 140, 84, 133, 240, 212, 212, 236, 212, 188, 20, 189, 92, 147, 196, 155, 225, 27, 24, 96, 128, 9, 74, 59, 50, 2, 141, 4, 210, 66, 32, 186, 5, 195, 232, 176, 196, 220, 130, 204, 34, 184, 41, 64, 91, 208, 69, 52, 140, 12, 12, 44, 52, 21, 162, 221, 124, 28, 157, 99, 21, 162, 13, 205, 156, 116, 77, 76, 244, 12, 179, 61, 170, 98, 99, 12, 13, 245, 48, 212, 235, 130, 93, 229, 157, 153, 82, 172, 224, 146, 159, 167, 94, 162, 16, 92, 146, 8, 20, 78, 84, 112, 206, 72, 204, 75, 78, 213, 75, 203, 73, 76, 62, 46, 91, 200, 8, 115, 40, 51, 3, 196, 129, 159, 129, 152, 5, 136, 93, 214, 48, 48, 176, 2, 105, 1, 168, 56, 10, 0, 0, 248, 198, 71, 111], pointer: 8 } +Error reading from peer: Connection reset by peer (os error 54) +Message { data: [229, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 103, 96, 96, 72, 201, 79, 201, 76, 79, 204, 203, 213, 158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135, 162, 156, 148, 194, 188, 152, 228, 252, 220, 130, 156, 212, 146, 212, 24, 231, 196, 188, 228, 204, 252, 188, 212, 226, 152, 144, 162, 210, 226, 226, 212, 28, 93, 67, 75, 115, 75, 93, 119, 160, 144, 130, 91, 126, 145, 66, 72, 70, 170, 66, 120, 106, 106, 118, 106, 94, 138, 174, 161, 89, 82, 102, 137, 174, 137, 137, 142, 161, 119, 70, 149, 94, 90, 78, 98, 114, 203, 175, 243, 32, 163, 193, 128, 25, 100, 7, 16, 23, 0, 9, 22, 32, 237, 178, 134, 129, 129, 21, 72, 11, 128, 196, 243, 176, 217, 29, 156, 153, 151, 158, 147, 90, 12, 54, 95, 193, 216, 84, 193, 200, 192, 200, 36, 198, 45, 181, 168, 40, 53, 57, 91, 193, 37, 177, 60, 79, 71, 193, 55, 177, 44, 181, 40, 19, 200, 13, 78, 76, 42, 74, 85, 80, 83, 240, 75, 45, 7, 10, 38, 103, 100, 2, 221, 167, 139, 238, 66, 5, 13, 144, 17, 154, 96, 167, 173, 228, 215, 98, 68, 119, 218, 74, 6, 76, 167, 49, 60, 153, 202, 200, 160, 199, 128, 0, 0, 161, 99, 76, 142], pointer: 8 } +Message { data: [205, 0, 0, 0, 9, 0, 0, 0, 120, 156, 77, 142, 193, 138, 194, 48, 16, 134, 163, 103, 223, 193, 57, 42, 98, 145, 234, 238, 185, 224, 30, 150, 98, 17, 86, 193, 131, 185, 132, 116, 106, 107, 211, 76, 77, 26, 117, 125, 14, 189, 250, 124, 130, 47, 97, 20, 23, 246, 63, 204, 7, 255, 204, 192, 215, 97, 140, 197, 46, 221, 32, 36, 168, 27, 161, 170, 193, 121, 210, 242, 93, 235, 234, 71, 20, 185, 93, 182, 45, 249, 130, 156, 178, 136, 37, 124, 209, 65, 43, 18, 169, 229, 146, 170, 90, 97, 131, 28, 21, 202, 198, 144, 46, 100, 102, 208, 230, 129, 95, 240, 165, 17, 71, 75, 206, 72, 132, 149, 127, 67, 157, 122, 138, 154, 180, 133, 68, 252, 194, 71, 147, 67, 56, 10, 199, 60, 252, 12, 96, 153, 35, 248, 123, 89, 230, 40, 210, 19, 12, 95, 197, 180, 48, 210, 89, 152, 103, 48, 163, 61, 66, 239, 155, 188, 161, 133, 120, 145, 192, 15, 86, 197, 177, 31, 84, 245, 248, 78, 55, 246, 151, 246, 155, 145, 119, 127, 234, 159, 158, 92, 95, 186, 236, 127, 30, 31, 35, 66, 88], pointer: 8 } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c954c07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + in + { + packages = lib.genAttrs systems (system: { + default = nixpkgs.legacyPackages.${system}.callPackage ( + { rustPlatform, installShellFiles }: + rustPlatform.buildRustPackage { + pname = "soulseek-rs"; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ + "--package" + "soulseek-rs" + ]; + doCheck = false; + nativeBuildInputs = [ installShellFiles ]; + env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}"; + postInstall = '' + installShellCompletion --cmd soulseek-rs \ + --bash <($out/bin/soulseek-rs completions print bash) \ + --zsh <($out/bin/soulseek-rs completions print zsh) \ + --fish <($out/bin/soulseek-rs completions print fish) + $out/bin/soulseek-rs man > soulseek-rs.1 + installManPage soulseek-rs.1 + ''; + meta = { + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + homepage = "https://re-invention.nl/soulseek-rs/"; + license = lib.licenses.mit; + mainProgram = "soulseek-rs"; + }; + } + ) { }; + }); + }; +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..1493db5 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,55 @@ +name: soulseek-rs +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +version_schema: none +release: "1" +section: net +priority: optional +maintainer: Michel de Graaf +description: | + Soulseek client for the terminal + A TUI, a scriptable command line and a daemon for the Soulseek + peer-to-peer network. +vendor: Michel de Graaf +homepage: https://re-invention.nl/soulseek-rs/ +license: MIT +suggests: + - gnome-keyring +contents: + - src: target/nfpm/soulseek-rs + dst: /usr/bin/soulseek-rs + file_info: + mode: 0755 + - src: target/nfpm/soulseek-rs.1.gz + dst: /usr/share/man/man1/soulseek-rs.1.gz + - src: target/nfpm/soulseek-rs.bash + dst: /usr/share/bash-completion/completions/soulseek-rs + - src: target/nfpm/soulseek-rs.fish + dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/vendor-completions/_soulseek-rs + packager: deb + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/site-functions/_soulseek-rs + packager: rpm + - dst: /usr/share/doc/soulseek-rs + type: dir + packager: rpm + - dst: /usr/share/licenses/soulseek-rs + type: dir + packager: rpm + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: deb + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: rpm + type: doc + - src: LICENSE + dst: /usr/share/doc/soulseek-rs/copyright + packager: deb + - src: LICENSE + dst: /usr/share/licenses/soulseek-rs/LICENSE + packager: rpm + type: license diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..f327a83 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,22 @@ +# Both crates inherit `version.workspace`, so they always move together. This +# config makes release-plz treat them as one release: a single `vX.Y.Z` tag, one +# GitHub release, one root CHANGELOG.md covering both. release-plz still +# publishes them to crates.io in dependency order (lib first) and waits for the +# index, which is what RELEASE.md used to ask you to do by hand. + +[workspace] +git_tag_name = "v{{ version }}" +git_release_name = "v{{ version }}" + +[[package]] +name = "soulseek-rs" +version_group = "workspace" +changelog_path = "./CHANGELOG.md" +changelog_include = ["soulseek-rs-lib"] + +[[package]] +name = "soulseek-rs-lib" +version_group = "workspace" +changelog_update = false +git_tag_enable = false +git_release_enable = false diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..df99c69 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..8216098 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +umask 022 + +[ $# -ge 4 ] || { + echo "usage: $0 []" >&2 + exit 2 +} + +NFPM_VERSION=2.47.0 + +binary=$1 +PKG_VERSION=$2 +target=$3 +out=$4 +package=${5:-soulseek-rs} + +case "$target" in + x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;; + aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;; + *) echo "no package architecture for $target" >&2; exit 1 ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;; + Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;; + *) echo "no pinned nfpm build for this machine" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +stage=$root/target/nfpm +rm -rf "$stage" +mkdir -p "$stage" "$out" +out=$(cd "$out" && pwd) + +cp "$binary" "$stage/soulseek-rs" +"$binary" completions print bash > "$stage/soulseek-rs.bash" +"$binary" completions print zsh > "$stage/_soulseek-rs" +"$binary" completions print fish > "$stage/soulseek-rs.fish" +"$binary" man > "$stage/soulseek-rs.1" +gzip -9n "$stage/soulseek-rs.1" + +archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz +curl -fsSL -o "$work/$archive" \ + "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive" +echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null +tar -xzf "$work/$archive" -C "$work" nfpm + +export PKG_ARCH PKG_VERSION +cd "$root" +"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb" +"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm" diff --git a/scripts/stress.sh b/scripts/stress.sh new file mode 100755 index 0000000..36896ba --- /dev/null +++ b/scripts/stress.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the concurrency stress benchmark against a local soulfind. +# +# scripts/stress.sh # run with the default load profile +# scripts/stress.sh --save # store the run as the new baseline if it wins +# STRESS_PEERS=128 scripts/stress.sh +# +# Knobs: STRESS_PEERS STRESS_SEARCHES STRESS_DOWNLOADS STRESS_LEECHERS +# STRESS_BROWSES STRESS_FILE_KB STRESS_TIMEOUT +set -euo pipefail + +cd "$(dirname "$0")/.." + +# soulfind is a D binary linked against Homebrew's SQLite. +export SOULFIND_BIN="${SOULFIND_BIN:-$HOME/src/soulseek_download/soulfind/bin/soulfind}" +export DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-/opt/homebrew/opt/sqlite/lib}" + +if [ ! -x "$SOULFIND_BIN" ]; then + echo "stress: no soulfind at $SOULFIND_BIN — set SOULFIND_BIN" >&2 + exit 2 +fi + +# Stale databases from a killed run make the next login behave oddly. +rm -f "${TMPDIR:-/tmp}"/soulfind-stress-*.db + +# Every mock peer holds sockets open; the default 256 descriptors is nowhere near +# enough for a swarm. +ulimit -n 20000 2>/dev/null || true + +for arg in "$@"; do + case "$arg" in + --save) export STRESS_SAVE=1 ;; + *) echo "stress: unknown argument $arg" >&2; exit 2 ;; + esac +done + +exec cargo run --release --quiet -p soulseek-rs-lib --example stress diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml new file mode 100644 index 0000000..659c074 --- /dev/null +++ b/soulseek-rs-lib/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soulseek-rs-lib" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Library for Soulseek protocol implementation in Rust" +keywords = ["soulseek", "p2p", "protocol", "file-sharing"] +categories = ["network-programming"] + +[lib] +name = "soulseek_rs" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] + +[target.'cfg(unix)'.dependencies] +libc = "0.2.189" + +[[example]] +name = "stress" +test = true diff --git a/soulseek-rs-lib/README.md b/soulseek-rs-lib/README.md new file mode 100644 index 0000000..6b41dc1 --- /dev/null +++ b/soulseek-rs-lib/README.md @@ -0,0 +1,119 @@ +# soulseek-rs-lib + +A Rust library for implementing the Soulseek peer-to-peer protocol. + +Website: + +## About + +This library provides the core functionality for interacting with the Soulseek +network. It can be used to build custom Soulseek clients. + +## Usage + +Add it to your `Cargo.toml`: + +```bash +cargo add soulseek-rs-lib +``` + +## Example + +### Simple Usage + +```rust,no_run +use soulseek_rs::Client; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Create and connect to Soulseek server + let mut client = Client::new("username", "password"); + + client.connect()?; + client.login()?; + + // Search for files + let results = client.search("Alex Kassian lifestream", Duration::from_secs(10))?; + + // Download first available file + if let Some(result) = results.iter().find(|r| !r.files.is_empty()) { + let file = &result.files[0]; + let (_download, status) = client.download( + file.name.clone(), + file.username.clone(), + file.size, + "~/Downloads".to_string(), + )?; + + // `download` returns as soon as the request is queued: the transfer is + // finished when the channel says so, not when this call returns. + for update in status { + println!("{}: {update:?}", file.name); + } + } + + Ok(()) +} +``` + +### Advanced Configuration + +```rust,no_run +use soulseek_rs::{Client, ClientSettings, PeerAddress}; + +fn main() -> Result<(), Box> { + // Create client with custom settings + let settings = ClientSettings { + server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416), + enable_listen: true, + listen_port: 3000, + ..ClientSettings::new("username", "password") + }; + + let mut client = Client::with_settings(settings); + client.connect()?; + client.login()?; + + Ok(()) +} +``` + +### Asking about a peer + +The server-backed lookups are request/poll pairs: you ask, then read the +answer once it lands. `request_user_info` clears any previous answer first, so +the loop below observes this request rather than the last one. + +```rust,no_run +use soulseek_rs::Client; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +fn main() -> Result<(), Box> { + let mut client = Client::new("username", "password"); + client.connect()?; + client.login()?; + + client.request_user_info("someuser")?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Some(info) = client.user_info("someuser") { + // Presence and stats are separate replies, so each is an Option: + // a reply that never arrived is None, not "offline" or "shares nothing". + println!("{:?} {:?}", info.presence, info.stats); + break; + } + sleep(Duration::from_millis(100)); + } + + // Room membership is kept current from join and leave events. + println!("{:?}", client.room_members("lobby")); + + Ok(()) +} +``` + +`ClientOperation` is `#[non_exhaustive]`: every protocol message the client +learns to handle adds a variant, so match it with a wildcard arm. `SharedFile` +is too, so build it with the `..` syntax rather than listing every field. diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs new file mode 100644 index 0000000..31d086e --- /dev/null +++ b/soulseek-rs-lib/examples/stress.rs @@ -0,0 +1,1333 @@ +//! Concurrency stress harness for the soulseek-rs client. +//! +//! Spins up a local soulfind, surrounds one real `Client` with a swarm of +//! mock peers, and drives searches, downloads, uploads and browses at the same +//! time. Prints a metrics table and a single 0-100 score, and compares that +//! score against a stored baseline so regressions are visible. +//! +//! Run it with `scripts/stress.sh` (sets SOULFIND_BIN and release mode). +//! +//! Knobs (all env vars, defaults in `Config::from_env`): +//! STRESS_PEERS mock seeders online, each answering searches +//! STRESS_SEARCHES searches fired at once +//! STRESS_DOWNLOADS downloads started at once +//! STRESS_LEECHERS mock peers pulling uploads from us at once +//! STRESS_BROWSES browses fired at once +//! STRESS_UPLOAD_SLOTS concurrent upload slots (default: every leecher) +//! STRESS_FILE_KB size of every transferred file +//! STRESS_TIMEOUT seconds before the run is called off +//! STRESS_SAVE=1 store this run as the new baseline +//! STRESS_REQUIRE_FUNCTIONAL=1 exit 1 if any transfer, search or browse was lost + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use soulseek_rs::message::Message; +use soulseek_rs::message::peer::{ + FileEntry, SharedDirectory, SharedFileEntry, build_file_search_response, + build_shared_file_list, +}; +use soulseek_rs::message::server::MessageFactory; +use soulseek_rs::{Client, ClientSettings, ClientVersion, PeerAddress}; + +/// A mock's `(name, size)` files in listing form; mocks advertise no attributes. +fn entries(files: &[(String, u64)]) -> Vec { + files + .iter() + .map(|(name, size)| SharedFileEntry { + name: name.clone(), + size: *size, + attributes: Vec::new(), + }) + .collect() +} + +/// Files matching this tag are what every mock seeder answers searches for. +const TAG: &str = "zqxstress"; + +/// Aggregate transfer rate (MiB/s) that scores full marks. Set well above any +/// real network link: the point is to keep the client, not the wire, as the +/// limiting factor, so the score still moves when transfer handling regresses. +const TARGET_MBPS: f64 = 500.0; + +const BASELINE: &str = "benchmarks/stress-baseline.json"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +struct Config { + peers: usize, + searches: usize, + downloads: usize, + leechers: usize, + browses: usize, + upload_slots: usize, + file_bytes: usize, + timeout: Duration, + save: bool, +} + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +impl Config { + fn from_env() -> Self { + Self { + peers: env_usize("STRESS_PEERS", 1024), + searches: env_usize("STRESS_SEARCHES", 128), + downloads: env_usize("STRESS_DOWNLOADS", 512), + leechers: env_usize("STRESS_LEECHERS", 256), + browses: env_usize("STRESS_BROWSES", 128), + // Serve every leecher at once by default: this profile measures + // concurrency, and the shipped slot cap would otherwise make it a + // measurement of the queue. Lower it deliberately to stress that. + upload_slots: env_usize( + "STRESS_UPLOAD_SLOTS", + env_usize("STRESS_LEECHERS", 256).max(1), + ), + file_bytes: env_usize("STRESS_FILE_KB", 4096) * 1024, + timeout: Duration::from_secs(env_usize("STRESS_TIMEOUT", 60) as u64), + save: std::env::var("STRESS_SAVE").is_ok_and(|v| v == "1"), + } + } +} + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct Metrics { + peers_online: AtomicU64, + searches_issued: AtomicU64, + searches_answered: AtomicU64, + results_total: AtomicU64, + downloads_issued: AtomicU64, + downloads_completed: AtomicU64, + bytes_downloaded: AtomicU64, + uploads_requested: AtomicU64, + uploads_completed: AtomicU64, + bytes_uploaded: AtomicU64, + browses_issued: AtomicU64, + browses_answered: AtomicU64, + peer_connect_failures: AtomicU64, + /// Mock peers that never got online (server refused the login, no free + /// port, ...). Counted separately so a harness-side limit shows up as a + /// number instead of silently shrinking a denominator. + mocks_offline: AtomicU64, +} + +impl Metrics { + fn bump(counter: &AtomicU64) { + counter.fetch_add(1, Ordering::Relaxed); + } + fn get(counter: &AtomicU64) -> u64 { + counter.load(Ordering::Relaxed) + } +} + +fn ratio(done: u64, total: u64) -> f64 { + if total == 0 { + 1.0 + } else { + done as f64 / total as f64 + } +} + +// --------------------------------------------------------------------------- +// soulfind +// --------------------------------------------------------------------------- + +struct TestServer { + host: String, + port: u16, + child: Option, + db: Option, +} + +impl TestServer { + fn spawn() -> Option { + if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") { + let (host, port) = addr.rsplit_once(':')?; + let port = port.parse().ok()?; + wait_until_listening(host, port, Duration::from_secs(2))?; + return Some(Self { + host: host.to_string(), + port, + child: None, + db: None, + }); + } + + let bin = PathBuf::from(std::env::var("SOULFIND_BIN").ok()?); + let port = free_port()?; + let db = + std::env::temp_dir().join(format!("soulfind-stress-{port}.db")); + let _ = std::fs::remove_file(&db); + + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) + .is_none() + { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + }) + } + + fn addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(db) = self.db.take() { + let _ = std::fs::remove_file(&db); + } + } +} + +fn free_port() -> Option { + TcpListener::bind("127.0.0.1:0") + .ok()? + .local_addr() + .ok() + .map(|a| a.port()) +} + +fn wait_until_listening( + host: &str, + port: u16, + timeout: Duration, +) -> Option<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if TcpStream::connect((host, port)).is_ok() { + return Some(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + None +} + +// --------------------------------------------------------------------------- +// Raw peer protocol helpers +// --------------------------------------------------------------------------- + +fn peer_init_bytes(username: &str, conn_type: &str, token: u32) -> Vec { + let mut m = Message::new(); + m.write_int8(1) + .write_string(username) + .write_string(conn_type) + .write_int32(token); + m.get_buffer() +} + +fn read_framed(stream: &mut TcpStream) -> std::io::Result { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload)?; + let mut data = len_buf.to_vec(); + data.extend_from_slice(&payload); + Ok(Message::new_with_data(data)) +} + +fn connect_retry(addr: &str, timeout: Duration) -> std::io::Result { + let deadline = Instant::now() + timeout; + loop { + match TcpStream::connect(addr) { + Ok(stream) => return Ok(stream), + Err(e) if Instant::now() >= deadline => return Err(e), + Err(_) => std::thread::sleep(Duration::from_millis(20)), + } + } +} + +/// Log in to the server and drain up to the login response, leaving the user +/// online for as long as the returned stream lives. +fn login_raw(server_addr: &str, username: &str) -> std::io::Result { + let mut srv = connect_retry(server_addr, Duration::from_secs(10))?; + srv.set_read_timeout(Some(Duration::from_secs(20)))?; + srv.write_all( + &MessageFactory::build_login_message( + username, + "pw", + ClientVersion::default(), + ) + .get_buffer(), + )?; + srv.flush()?; + loop { + let msg = read_framed(&mut srv)?; + if msg.get_message_code() == 1 { + return Ok(srv); + } + } +} + +// --------------------------------------------------------------------------- +// Mock seeder: answers searches, serves browses, uploads files to the client +// --------------------------------------------------------------------------- + +struct Seeder { + name: String, + server_addr: String, + client_addr: String, + files: Vec<(String, u64)>, + content: Arc>, + metrics: Arc, + stop: Arc, + login_delay: Duration, +} + +impl Seeder { + fn run(self) { + // soulfind logs every login through SQLite, and a thousand of them + // arriving in the same millisecond starves it badly enough that the + // client under test cannot log in either. Real swarms do not arrive in + // lockstep; spreading the herd keeps the server out of the measurement. + std::thread::sleep(self.login_delay); + let Ok(mut srv) = login_raw(&self.server_addr, &self.name) else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + let Some(port) = free_port() else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + // All interfaces: soulfind reports the host's LAN address for a + // loopback login, and the client dials that. + let Ok(listener) = TcpListener::bind(("0.0.0.0", port)) else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + if srv + .write_all( + &MessageFactory::build_set_wait_port_message(port).get_buffer(), + ) + .is_err() + { + return; + } + let _ = srv.flush(); + Metrics::bump(&self.metrics.peers_online); + + // Inbound P connections (the client dialing us to queue a download or + // browse our shares) each get their own handler thread, exactly as a + // real peer would. + let inbound = Arc::new(self.clone_state()); + let stop = self.stop.clone(); + std::thread::spawn(move || { + let _ = listener.set_nonblocking(true); + while !stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let state = inbound.clone(); + std::thread::spawn(move || { + let _ = stream.set_nonblocking(false); + state.serve_connection(stream, true); + }); + } + Err(_) => std::thread::sleep(Duration::from_millis(10)), + } + } + }); + + // Server connection: watch for relayed searches and answer the ones + // that match our tag by dialling the searcher directly. + let _ = srv.set_read_timeout(Some(Duration::from_millis(500))); + let state = Arc::new(self.clone_state()); + while !self.stop.load(Ordering::Relaxed) { + let Ok(mut msg) = read_framed(&mut srv) else { + continue; + }; + if msg.get_message_code() != 26 { + continue; + } + msg.set_pointer(8); + let _searcher = msg.read_string(); + let token = msg.read_int32(); + let query = msg.read_string(); + if !query.contains(TAG) { + continue; + } + state.answer_search(token); + } + } + + fn clone_state(&self) -> SeederState { + SeederState { + name: self.name.clone(), + client_addr: self.client_addr.clone(), + files: self.files.clone(), + content: self.content.clone(), + metrics: self.metrics.clone(), + stop: self.stop.clone(), + conn: Mutex::new(None), + } + } +} + +struct SeederState { + name: String, + client_addr: String, + files: Vec<(String, u64)>, + content: Arc>, + metrics: Arc, + stop: Arc, + /// The one control connection we hold open to the client, as a real peer + /// does. Dialling afresh per search response would be both unrealistic and + /// a self-inflicted load: it makes the harness, not the client, the thing + /// under strain. + conn: Mutex>, +} + +impl SeederState { + /// Push a `FileSearchResponse` over our control connection, opening it on + /// first use and reusing it thereafter. + fn answer_search(self: &Arc, token: u32) { + let entries: Vec = self + .files + .iter() + .map(|(name, size)| FileEntry { + name, + size: *size, + attribs: &[], + }) + .collect(); + let response = + build_file_search_response(&self.name, token, &entries, 1, 0, 0); + + let Ok(mut guard) = self.conn.lock() else { + return; + }; + if guard.is_none() { + let Ok(mut p) = + connect_retry(&self.client_addr, Duration::from_secs(10)) + else { + Metrics::bump(&self.metrics.peer_connect_failures); + return; + }; + if p.write_all(&peer_init_bytes(&self.name, "P", 0)).is_err() { + Metrics::bump(&self.metrics.peer_connect_failures); + return; + } + // Read side runs on its own thread so browses and queued downloads + // arriving on this connection are still served. + let Ok(read_half) = p.try_clone() else { + Metrics::bump(&self.metrics.peer_connect_failures); + return; + }; + let state = self.clone(); + std::thread::spawn(move || { + state.serve_connection(read_half, false); + }); + *guard = Some(p); + } + + if let Some(p) = guard.as_mut() + && p.write_all(&response.get_buffer()).is_err() + { + Metrics::bump(&self.metrics.peer_connect_failures); + *guard = None; + } + } + + /// Write a reply on the connection `p` belongs to. When `p` is the read + /// half of the shared control connection, the write half lives behind + /// `conn` and every writer must take that lock — two threads writing the + /// same socket would interleave and corrupt the framing. + fn reply(&self, local: &mut TcpStream, shared: bool, bytes: &[u8]) -> bool { + if !shared { + return local.write_all(bytes).and_then(|()| local.flush()).is_ok(); + } + let Ok(mut guard) = self.conn.lock() else { + return false; + }; + let Some(p) = guard.as_mut() else { + return false; + }; + p.write_all(bytes).and_then(|()| p.flush()).is_ok() + } + + /// Handle one P connection for its lifetime: browses, queued downloads and + /// the F-connection file transfers they lead to. `expect_init` marks a + /// connection the client dialled (so it owns its own write half); anything + /// else is our shared control connection. + fn serve_connection(&self, mut p: TcpStream, expect_init: bool) { + let shared = !expect_init; + let _ = p.set_read_timeout(Some(Duration::from_millis(500))); + if expect_init { + // The client announces itself first; anything else is a stray dial. + let Ok(init) = read_framed(&mut p) else { + return; + }; + if init.get_init_code() != 1 { + return; + } + } + + while !self.stop.load(Ordering::Relaxed) { + let mut msg = match read_framed(&mut p) { + Ok(m) => m, + Err(ref e) if is_timeout(e) => continue, + Err(_) => return, + }; + match msg.get_message_code() { + 4 if !self.send_listing(&mut p, shared) => return, + // QueueUpload: offer the transfer, then stream it once allowed. + 43 => { + msg.set_pointer(8); + let requested = msg.read_string(); + let Some((name, size)) = + self.files.iter().find(|(n, _)| *n == requested) + else { + continue; + }; + let token = next_token(); + let mut tr = Message::new(); + tr.write_int32(40) + .write_int32(1) + .write_int32(token) + .write_string(name) + .write_int64(*size); + if !self.reply(&mut p, shared, &tr.get_buffer()) { + return; + } + if !self.wait_for_allow(&mut p, shared, token) { + return; + } + let content = self.content.clone(); + let addr = self.client_addr.clone(); + let name = self.name.clone(); + std::thread::spawn(move || { + let _ = + serve_file_over_f(&addr, &name, token, &content); + }); + } + _ => {} + } + } + } + + /// Everything we hold, the answer to a GetShareFileList. + fn send_listing(&self, p: &mut TcpStream, shared: bool) -> bool { + let dir = SharedDirectory { + name: format!("stress\\{}", self.name), + files: entries(&self.files), + }; + let list = build_shared_file_list(std::slice::from_ref(&dir)); + self.reply(p, shared, &list.get_buffer()) + } + + /// Wait for the client's `TransferResponse` allowing `token`. A browse + /// that arrives meanwhile is answered: a busy client accepts an offer + /// late, and a browse it sent in between is not lost work on its side. + fn wait_for_allow( + &self, + p: &mut TcpStream, + shared: bool, + token: u32, + ) -> bool { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline && !self.stop.load(Ordering::Relaxed) { + match read_framed(p) { + Ok(mut msg) if msg.get_message_code() == 41 => { + msg.set_pointer(8); + if msg.read_int32() == token { + return true; + } + } + Ok(msg) + if msg.get_message_code() == 4 + && !self.send_listing(p, shared) => + { + return false; + } + Ok(_) => {} + Err(ref e) if is_timeout(e) => {} + Err(_) => return false, + } + } + false + } +} + +fn is_timeout(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) +} + +static NEXT_TOKEN: AtomicU64 = AtomicU64::new(0x1000); + +fn next_token() -> u32 { + NEXT_TOKEN.fetch_add(1, Ordering::Relaxed) as u32 +} + +/// Open an F connection to the downloader's listener and stream the file. +fn serve_file_over_f( + downloader_addr: &str, + username: &str, + token: u32, + content: &[u8], +) -> std::io::Result<()> { + let mut f = connect_retry(downloader_addr, Duration::from_secs(10))?; + f.set_read_timeout(Some(Duration::from_secs(30)))?; + let mut init = peer_init_bytes(username, "F", token); + init.extend_from_slice(&token.to_le_bytes()); + f.write_all(&init)?; + f.flush()?; + + let mut start = [0u8; 8]; + f.read_exact(&mut start)?; + let offset = u64::from_le_bytes(start) as usize; + if offset < content.len() { + f.write_all(&content[offset..])?; + } + f.flush()?; + std::thread::sleep(Duration::from_millis(300)); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Mock leecher: pulls an upload out of the client under test +// --------------------------------------------------------------------------- + +struct Leecher { + name: String, + server_addr: String, + client_addr: String, + filename: String, + metrics: Arc, + /// The run's global deadline. Every blocking wait honours it so a stuck + /// transfer costs the iteration nothing beyond the budget already spent. + deadline: Instant, + login_delay: Duration, + /// Raised once the client is up. Leechers come online with the seeders so + /// the server has really registered their listening port by the time they + /// ask for a file — a real peer is online long before it queues anything. + start: Arc, +} + +impl Leecher { + fn run(self) { + std::thread::sleep(self.login_delay); + Metrics::bump(&self.metrics.uploads_requested); + let Ok(mut srv) = login_raw(&self.server_addr, &self.name) else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + let Some(port) = free_port() else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + // All interfaces: soulfind reports the host's LAN address for a + // loopback login, and the client dials that. + let Ok(listener) = TcpListener::bind(("0.0.0.0", port)) else { + Metrics::bump(&self.metrics.mocks_offline); + return; + }; + // The client dials this port for the F leg, so the server must know it. + if srv + .write_all( + &MessageFactory::build_set_wait_port_message(port).get_buffer(), + ) + .is_err() + { + return; + } + let _ = srv.flush(); + + while !self.start.load(Ordering::Relaxed) { + if Instant::now() >= self.deadline { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + + if let Err(e) = self.pull(&listener) { + eprintln!("[leecher {}] {e}", self.name); + } + } + + fn pull(&self, listener: &TcpListener) -> std::io::Result<()> { + let mut p = connect_retry(&self.client_addr, Duration::from_secs(10))?; + p.set_read_timeout(Some(Duration::from_secs(30)))?; + p.write_all(&peer_init_bytes(&self.name, "P", 0))?; + p.write_all( + &MessageFactory::build_queue_upload_message(&self.filename) + .get_buffer(), + )?; + p.flush()?; + + // The client offers the transfer; accept it so it starts streaming. + let token = loop { + if Instant::now() >= self.deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "client never offered the queued upload", + )); + } + match read_framed(&mut p) { + Ok(mut msg) if msg.get_message_code() == 40 => { + msg.set_pointer(8); + let _direction = msg.read_int32(); + break msg.read_int32(); + } + Ok(_) => {} + Err(ref e) if is_timeout(e) => {} + Err(e) => return Err(e), + } + }; + let mut ok = Message::new(); + ok.write_int32(41).write_int32(token).write_int8(1); + p.write_all(&ok.get_buffer())?; + p.flush()?; + + // Accept the client's inbound F connection and drain the file. The + // client also dials our P port, so keep accepting until the PeerInit + // announces "F" — otherwise we block reading a transfer token that a + // control connection is never going to send. + let mut f = accept_file_connection(listener, self.deadline)?; + f.write_all(&0u64.to_le_bytes())?; + f.flush()?; + + let mut sink = Vec::new(); + let received = f.read_to_end(&mut sink)? as u64; + self.metrics + .bytes_uploaded + .fetch_add(received, Ordering::Relaxed); + if received > 0 { + Metrics::bump(&self.metrics.uploads_completed); + } + Ok(()) + } +} + +/// Accept connections until one announces itself as an F (file) connection, +/// and return it positioned just past its PeerInit and transfer token. +fn accept_file_connection( + listener: &TcpListener, + deadline: Instant, +) -> std::io::Result { + listener.set_nonblocking(true)?; + let mut held = Vec::new(); + loop { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "no inbound F connection", + )); + } + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + let Ok(mut init) = read_framed(&mut stream) else { + continue; + }; + if init.get_init_code() != 1 { + continue; + } + init.set_pointer(5); + let _username = init.read_string(); + if init.read_string() != "F" { + // A control connection: hold it open so the client does not see a + // reset, and keep waiting for the transfer. + held.push(stream); + continue; + } + let mut token_buf = [0u8; 4]; + stream.read_exact(&mut token_buf)?; + drop(held); + return Ok(stream); + } +} + +// --------------------------------------------------------------------------- +// Scoring and baseline +// --------------------------------------------------------------------------- + +struct Score { + total: f64, + parts: Vec<(&'static str, f64, f64)>, // name, achieved 0-1, weight +} + +/// The functional dimensions that fell short of 100%. Throughput is excluded: +/// it measures how fast the machine is, the others measure whether the client +/// silently lost work, which is the only failure a gate should stop on. +fn gate_failures(score: &Score) -> Vec { + score + .parts + .iter() + .filter(|(name, value, _)| *name != "throughput" && *value < 1.0) + .map(|(name, value, _)| format!("{name} {:.1}%", value * 100.0)) + .collect() +} + +fn compute_score(m: &Metrics, elapsed: Duration) -> Score { + let bytes = + Metrics::get(&m.bytes_downloaded) + Metrics::get(&m.bytes_uploaded); + let mbps = (bytes as f64 / 1_048_576.0) / elapsed.as_secs_f64().max(0.001); + + let parts = vec![ + ( + "downloads", + ratio( + Metrics::get(&m.downloads_completed), + Metrics::get(&m.downloads_issued), + ), + 0.30, + ), + ( + "uploads", + ratio( + Metrics::get(&m.uploads_completed), + Metrics::get(&m.uploads_requested), + ), + 0.20, + ), + ( + "searches", + ratio( + Metrics::get(&m.searches_answered), + Metrics::get(&m.searches_issued), + ), + 0.20, + ), + ( + "browses", + ratio( + Metrics::get(&m.browses_answered), + Metrics::get(&m.browses_issued), + ), + 0.15, + ), + ("throughput", (mbps / TARGET_MBPS).min(1.0), 0.15), + ]; + + let total = 100.0 * parts.iter().map(|(_, v, w)| v * w).sum::(); + Score { total, parts } +} + +fn read_baseline(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let idx = text.find("\"score\"")?; + text[idx + 7..] + .trim_start() + .trim_start_matches(':') + .trim_start() + .split(|c: char| !c.is_ascii_digit() && c != '.') + .find(|s| !s.is_empty())? + .parse() + .ok() +} + +fn write_baseline(path: &Path, score: &Score, cfg: &Config, m: &Metrics) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let parts = score + .parts + .iter() + .map(|(n, v, _)| format!(" \"{n}\": {v:.4}")) + .collect::>() + .join(",\n"); + let body = format!( + "{{\n \"score\": {:.2},\n \"parts\": {{\n{}\n }},\n \"load\": {{\n \ + \"peers\": {},\n \"searches\": {},\n \"downloads\": {},\n \ + \"leechers\": {},\n \"browses\": {},\n \"file_bytes\": {}\n }},\n \ + \"observed\": {{\n \"downloads_completed\": {},\n \ + \"uploads_completed\": {},\n \"bytes\": {}\n }}\n}}\n", + score.total, + parts, + cfg.peers, + cfg.searches, + cfg.downloads, + cfg.leechers, + cfg.browses, + cfg.file_bytes, + Metrics::get(&m.downloads_completed), + Metrics::get(&m.uploads_completed), + Metrics::get(&m.bytes_downloaded) + Metrics::get(&m.bytes_uploaded), + ); + let _ = std::fs::write(path, body); +} + +/// 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() + .ok() + .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count()) + .saturating_sub(1) +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +fn main() { + let cfg = Config::from_env(); + let Some(server) = TestServer::spawn() else { + eprintln!( + "stress: no soulfind server. Set SOULFIND_BIN (or SOULSEEK_TEST_SERVER)." + ); + std::process::exit(2); + }; + let server_addr = server.addr(); + let metrics = Arc::new(Metrics::default()); + let stop = Arc::new(AtomicBool::new(false)); + + // Shares the client under test offers to mock leechers. + let run_id = std::process::id(); + let share_dir = + std::env::temp_dir().join(format!("soulseek-stress-share-{run_id}")); + let download_dir = + std::env::temp_dir().join(format!("soulseek-stress-dl-{run_id}")); + let _ = std::fs::remove_dir_all(&share_dir); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&share_dir).expect("share dir"); + std::fs::create_dir_all(&download_dir).expect("download dir"); + + let payload: Vec = + (0..cfg.file_bytes).map(|i| (i % 251) as u8).collect(); + // Peers ask for the backslash-separated virtual path (root dir name first), + // not the basename — anything else is silently unknown to the share index. + let share_root = share_dir + .file_name() + .expect("share dir name") + .to_string_lossy() + .into_owned(); + let shared_names: Vec = (0..cfg.leechers.max(1)) + .map(|i| format!("{share_root}\\{TAG}_own_{i}.bin")) + .collect(); + for name in &shared_names { + let basename = name.rsplit('\\').next().expect("basename"); + std::fs::write(share_dir.join(basename), &payload) + .expect("write share"); + } + + let client_port = free_port().expect("client port"); + let client_name = format!("stress_client_{run_id}"); + let client_addr = format!("127.0.0.1:{client_port}"); + + println!( + "stress: {} peers, {} searches, {} downloads, {} leechers, {} browses, \ + {} KiB files", + cfg.peers, + cfg.searches, + cfg.downloads, + cfg.leechers, + cfg.browses, + cfg.file_bytes / 1024 + ); + + // --- mock seeders ----------------------------------------------------- + let content = Arc::new(payload); + let mut seeder_names = Vec::new(); + for i in 0..cfg.peers { + let name = format!("stress_seed_{run_id}_{i}"); + seeder_names.push(name.clone()); + let files = + vec![(format!("share\\{TAG}_{i}.bin"), cfg.file_bytes as u64)]; + let seeder = Seeder { + name, + server_addr: server_addr.clone(), + client_addr: client_addr.clone(), + files, + content: content.clone(), + metrics: metrics.clone(), + stop: stop.clone(), + login_delay: Duration::from_micros(1500 * i as u64), + }; + std::thread::spawn(move || seeder.run()); + } + + // --- mock leechers, brought online alongside the seeders --------------- + // They wait on `start` before queueing anything, so the server has really + // registered their listening port by the time we ask it for their address. + let start = Arc::new(AtomicBool::new(false)); + let run_deadline_hint = + Instant::now() + cfg.timeout + Duration::from_secs(40); + let mut leechers = Vec::new(); + for i in 0..cfg.leechers { + let leecher = Leecher { + name: format!("stress_leech_{run_id}_{i}"), + server_addr: server_addr.clone(), + client_addr: client_addr.clone(), + filename: shared_names[i % shared_names.len()].clone(), + metrics: metrics.clone(), + deadline: run_deadline_hint, + login_delay: Duration::from_micros(1500 * i as u64), + start: start.clone(), + }; + leechers.push(std::thread::spawn(move || leecher.run())); + } + + // Let the swarm register its wait ports before the client starts. + let deadline = Instant::now() + Duration::from_secs(30); + while Metrics::get(&metrics.peers_online) < cfg.peers as u64 + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(50)); + } + println!( + "stress: {}/{} seeders online", + Metrics::get(&metrics.peers_online), + cfg.peers + ); + + // --- client under test ------------------------------------------------ + let mut client = Client::with_settings(ClientSettings { + username: client_name, + password: "pw".to_string(), + server_address: PeerAddress::new(server.host.clone(), server.port), + 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"); + assert!(client.login().expect("client login"), "client login failed"); + client.set_upload_slots(cfg.upload_slots); + std::thread::sleep(Duration::from_secs(1)); + + let client = Arc::new(client); + let started = Instant::now(); + let run_deadline = started + cfg.timeout; + let peak_threads = Arc::new(AtomicU64::new(0)); + + // Sample thread count while the run is in flight. + { + let peak = peak_threads.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let n = thread_count() as u64; + peak.fetch_max(n, Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(200)); + } + }); + } + + let mut workers = Vec::new(); + + // --- concurrent searches --------------------------------------------- + let queries: Vec = + (0..cfg.searches).map(|k| format!("{TAG}_q{k}")).collect(); + for query in queries.clone() { + let client = client.clone(); + let metrics = metrics.clone(); + workers.push(std::thread::spawn(move || { + Metrics::bump(&metrics.searches_issued); + let _ = client.search(&query, Duration::from_secs(3)); + })); + } + + // --- concurrent uploads: release the leechers already standing by ----- + start.store(true, Ordering::Relaxed); + workers.extend(leechers); + + // --- concurrent browses ---------------------------------------------- + for name in seeder_names.iter().take(cfg.browses) { + let client = client.clone(); + let metrics = metrics.clone(); + let name = name.clone(); + workers.push(std::thread::spawn(move || { + Metrics::bump(&metrics.browses_issued); + if client.browse_user(&name).is_err() { + return; + } + let deadline = run_deadline; + while Instant::now() < deadline { + if client.take_browse_result(&name).is_some() { + Metrics::bump(&metrics.browses_answered); + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + })); + } + + // --- concurrent downloads -------------------------------------------- + // Collect distinct (peer, file) hits from the searches, then fire every + // download at once — that simultaneity is the point of the exercise. + let hit_deadline = started + cfg.timeout / 3; + let mut hits: HashMap = HashMap::new(); + let mut answered = 0; + let mut results_seen = 0u64; + // Keep collecting until there is enough to download *and* every search has + // been answered, so search recall is measured even on download-free runs. + while Instant::now() < hit_deadline + && (hits.len() < cfg.downloads || answered < queries.len()) + { + answered = 0; + results_seen = 0; + for query in &queries { + let results = client.get_search_results(query); + if !results.is_empty() { + answered += 1; + } + for result in results { + results_seen += result.files.len() as u64; + if let Some(file) = result.files.first() { + hits.entry(result.username.clone()) + .or_insert_with(|| (file.name.clone(), file.size)); + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + metrics + .searches_answered + .store(answered as u64, Ordering::Relaxed); + metrics.results_total.store(results_seen, Ordering::Relaxed); + println!( + "stress: {} distinct peers answered searches, firing {} downloads", + hits.len(), + hits.len().min(cfg.downloads) + ); + + let completed = Arc::new(Mutex::new(Vec::new())); + for (peer, (filename, size)) in hits.into_iter().take(cfg.downloads) { + let client = client.clone(); + let metrics = metrics.clone(); + let dir = download_dir.display().to_string(); + let completed = completed.clone(); + workers.push(std::thread::spawn(move || { + Metrics::bump(&metrics.downloads_issued); + let Ok((_download, status_rx)) = + client.download(filename.clone(), peer.clone(), size, dir) + else { + return; + }; + let deadline = run_deadline; + while Instant::now() < deadline { + match status_rx.recv_timeout(Duration::from_millis(500)) { + Ok(soulseek_rs::DownloadStatus::Completed) => { + Metrics::bump(&metrics.downloads_completed); + metrics + .bytes_downloaded + .fetch_add(size, Ordering::Relaxed); + if let Ok(mut c) = completed.lock() { + c.push(peer); + } + return; + } + Ok( + soulseek_rs::DownloadStatus::Failed(_) + | soulseek_rs::DownloadStatus::TimedOut, + ) => return, + _ => {} + } + } + })); + } + + // --- wait for the run to settle -------------------------------------- + for worker in workers { + let remaining = run_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let _ = worker.join(); + } + let elapsed = started.elapsed(); + stop.store(true, Ordering::Relaxed); + + // --- report ----------------------------------------------------------- + let score = compute_score(&metrics, elapsed); + let bytes = Metrics::get(&metrics.bytes_downloaded) + + Metrics::get(&metrics.bytes_uploaded); + + println!("\n--- results ({:.1}s) ---", elapsed.as_secs_f64()); + println!( + " seeders online {}/{}", + Metrics::get(&metrics.peers_online), + cfg.peers + ); + println!( + " searches answered {}/{}", + Metrics::get(&metrics.searches_answered), + Metrics::get(&metrics.searches_issued) + ); + println!( + " search results {}", + Metrics::get(&metrics.results_total) + ); + println!( + " downloads completed {}/{}", + Metrics::get(&metrics.downloads_completed), + Metrics::get(&metrics.downloads_issued) + ); + println!( + " uploads completed {}/{}", + Metrics::get(&metrics.uploads_completed), + Metrics::get(&metrics.uploads_requested) + ); + println!( + " browses answered {}/{}", + Metrics::get(&metrics.browses_answered), + Metrics::get(&metrics.browses_issued) + ); + println!( + " peer dial failures {}", + Metrics::get(&metrics.peer_connect_failures) + ); + println!( + " mocks never online {} (harness-side, not the client)", + Metrics::get(&metrics.mocks_offline) + ); + println!( + " transferred {:.1} MiB ({:.1} MiB/s)", + bytes as f64 / 1_048_576.0, + bytes as f64 / 1_048_576.0 / elapsed.as_secs_f64().max(0.001) + ); + println!( + " peak threads {} (client + mock swarm, same process)", + peak_threads.load(Ordering::Relaxed) + ); + + // Where the stragglers got stuck is the whole diagnostic: a download parked + // in Queued never got its transfer negotiated, while one in InProgress is + // simply slow. + let mut by_status: HashMap<&'static str, usize> = HashMap::new(); + for download in client.get_all_downloads() { + let label = match download.status { + soulseek_rs::DownloadStatus::Queued => "Queued", + soulseek_rs::DownloadStatus::InProgress { .. } => "InProgress", + soulseek_rs::DownloadStatus::Completed => "Completed", + soulseek_rs::DownloadStatus::Cancelled => "Cancelled", + soulseek_rs::DownloadStatus::Failed(_) => "Failed", + soulseek_rs::DownloadStatus::TimedOut => "TimedOut", + soulseek_rs::DownloadStatus::Paused { .. } => "Paused", + }; + *by_status.entry(label).or_default() += 1; + } + if !by_status.is_empty() { + let mut parts: Vec = + by_status.iter().map(|(k, v)| format!("{k}={v}")).collect(); + parts.sort(); + println!(" download states {}", parts.join(" ")); + } + let stuck: Vec = client + .get_all_downloads() + .iter() + .filter(|d| matches!(d.status, soulseek_rs::DownloadStatus::Queued)) + .map(|d| d.username.clone()) + .collect(); + if !stuck.is_empty() { + println!(" stuck peers {}", stuck.join(" ")); + } + + println!("\n--- score ---"); + for (name, value, weight) in &score.parts { + println!(" {name:<12} {:>6.1}% x{weight:.2}", value * 100.0); + } + println!(" TOTAL {:.2}/100", score.total); + + let baseline_path = PathBuf::from(BASELINE); + if let Some(prev) = read_baseline(&baseline_path) { + let delta = score.total - prev; + let sign = if delta >= 0.0 { "+" } else { "" }; + println!(" baseline {prev:.2} ({sign}{delta:.2})"); + if cfg.save && score.total > prev { + write_baseline(&baseline_path, &score, &cfg, &metrics); + println!(" baseline updated to {:.2}", score.total); + } else if cfg.save { + println!(" baseline kept (run did not beat it)"); + } + } else { + write_baseline(&baseline_path, &score, &cfg, &metrics); + println!(" baseline created at {:.2}", score.total); + } + + let _ = std::fs::remove_dir_all(&share_dir); + let _ = std::fs::remove_dir_all(&download_dir); + + let failures = gate_failures(&score); + if std::env::var("STRESS_REQUIRE_FUNCTIONAL").is_ok_and(|v| v == "1") + && !failures.is_empty() + { + eprintln!("stress: work was lost: {}", failures.join(", ")); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn score(parts: &[(&'static str, f64)]) -> Score { + Score { + total: 0.0, + parts: parts.iter().map(|&(n, v)| (n, v, 0.2)).collect(), + } + } + + #[test] + fn lost_work_fails_the_gate_but_slow_transfers_do_not() { + let clean = + score(&[("downloads", 1.0), ("uploads", 1.0), ("throughput", 0.1)]); + assert!(gate_failures(&clean).is_empty()); + + let lossy = score(&[ + ("downloads", 0.98), + ("uploads", 1.0), + ("throughput", 1.0), + ]); + assert_eq!(gate_failures(&lossy), ["downloads 98.0%"]); + } +} diff --git a/soulseek-rs-lib/src/actor/mod.rs b/soulseek-rs-lib/src/actor/mod.rs new file mode 100644 index 0000000..eed1cfd --- /dev/null +++ b/soulseek-rs-lib/src/actor/mod.rs @@ -0,0 +1,267 @@ +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::{error, trace}; + +pub mod peer_actor; +pub mod peer_registry; +pub mod server_actor; + +#[derive(Debug, Clone)] +pub enum ConnectionState { + Disconnected, + Connecting { since: Instant }, + Connected, +} +/// Core actor trait - each actor processes messages +pub trait Actor: Send + 'static { + type Message: Send + Clone + 'static; + + /// Handle a single message + fn handle(&mut self, msg: Self::Message); + + /// Called when actor starts (optional hook) + fn on_start(&mut self) {} + + /// Called when actor stops (optional hook) + fn on_stop(&mut self) {} + + /// Optional periodic tick for background work + fn tick(&mut self) {} +} + +#[derive(Clone)] +pub struct ActorHandle { + pub(crate) sender: Sender>, +} + +impl ActorHandle { + pub fn send(&self, msg: M) -> Result<(), String> { + self.sender + .send(ActorMessage::UserMessage(msg)) + .map_err(|e| format!("Failed to send message: {e}")) + } + + /// Request actor to stop gracefully + pub fn stop(&self) -> Result<(), String> { + self.sender + .send(ActorMessage::Stop) + .map_err(|e| format!("Failed to send stop signal: {e}")) + } +} + +/// Internal actor message wrapper +pub enum ActorMessage { + UserMessage(M), + Stop, +} + +/// Stack size for an actor thread. An actor loop parses messages into +/// heap-allocated buffers and never recurses, so it needs a fraction of the +/// 2 MiB default — and at a thousand concurrent peers that difference is the +/// difference between 2 GiB of reserved address space and 256 MiB. +const ACTOR_STACK_SIZE: usize = 256 * 1024; + +/// Actor system that manages actor lifecycle. +/// +/// Each actor owns a thread for its whole lifetime. That is deliberate: an +/// actor loop blocks in `recv_timeout` until it is stopped, so running actors +/// on a fixed-size pool would make the pool a semaphore capping the client at +/// one peer per core — and every peer past the cap would silently never start. +#[derive(Default)] +pub struct ActorSystem; + +impl ActorSystem { + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Spawn a new actor and return its handle + pub fn spawn(&self, actor: A) -> ActorHandle { + self.spawn_with_handle(actor, |_, _| {}) + } + + /// Spawn a new actor with initialization callback and return its handle + /// The callback receives the actor handle before `on_start` is called + pub fn spawn_with_handle( + &self, + actor: A, + init: F, + ) -> ActorHandle + where + F: FnOnce(&mut A, ActorHandle) + Send + 'static, + { + match self.try_spawn_with_handle(actor, init) { + Ok(handle) => handle, + Err(e) => { + error!("[actor_system] failed to spawn actor thread: {}", e); + // A handle nobody serves: sends fail, which callers of the + // infallible spawn already tolerate. + let (sender, _) = channel(); + ActorHandle { sender } + } + } + } + + /// Like [`Self::spawn_with_handle`], but hands a thread-spawn failure back + /// to the caller instead of returning a handle no actor will ever serve. + /// Storing such a dead handle is how a registry entry becomes a zombie + /// that claims its username for the rest of the process. + #[allow(clippy::unused_self)] // method for symmetry with the spawn family + pub(crate) fn try_spawn_with_handle( + &self, + mut actor: A, + init: F, + ) -> std::io::Result> + where + F: FnOnce(&mut A, ActorHandle) + Send + 'static, + { + let (sender, receiver) = channel::>(); + let handle = ActorHandle { sender }; + let handle_for_init = handle.clone(); + + thread::Builder::new() + .name("soulseek-actor".to_string()) + .stack_size(ACTOR_STACK_SIZE) + .spawn(move || { + init(&mut actor, handle_for_init); + actor.on_start(); + Self::run_actor_loop(&mut actor, receiver); + actor.on_stop(); + })?; + + Ok(handle) + } + + fn run_actor_loop( + actor: &mut A, + receiver: Receiver>, + ) { + let tick_interval = Duration::from_millis(100); + let mut last_tick = Instant::now(); + let mut message_count = 0; + let mut tick_count = 0; + + loop { + match receiver.recv_timeout(tick_interval) { + Ok(ActorMessage::UserMessage(msg)) => { + message_count += 1; + actor.handle(msg); + } + Ok(ActorMessage::Stop) => { + trace!( + "[actor_system] Received Stop message, breaking loop" + ); + break; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if last_tick.elapsed() >= tick_interval { + tick_count += 1; + actor.tick(); + last_tick = Instant::now(); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + trace!( + "[actor_system] Channel disconnected, breaking loop" + ); + break; + } + } + } + trace!( + "[actor_system] run_actor_loop ENDED - processed {} messages, {} ticks", + message_count, tick_count + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CounterActor { + count: Arc, + } + + impl Actor for CounterActor { + type Message = usize; + + fn handle(&mut self, msg: Self::Message) { + self.count.fetch_add(msg, Ordering::SeqCst); + } + + fn on_start(&mut self) { + println!("Counter actor started"); + } + + fn on_stop(&mut self) { + println!("Counter actor stopped"); + } + } + + #[test] + fn test_actor_system() { + let system = ActorSystem::new(); + + let count = Arc::new(AtomicUsize::new(0)); + let actor = CounterActor { + count: count.clone(), + }; + + let handle = system.spawn(actor); + + // Send some messages + handle.send(1).unwrap(); + handle.send(2).unwrap(); + handle.send(3).unwrap(); + + // Give actor time to process + std::thread::sleep(Duration::from_millis(100)); + + assert_eq!(count.load(Ordering::SeqCst), 6); + + handle.stop().unwrap(); + + // Give actor time to process the stop message + std::thread::sleep(Duration::from_millis(100)); + } + + // Actors must not be capped by a worker count: every peer runs one, and a + // cap silently strands every peer past it. Far more actors than cores must + // all make progress concurrently. + #[test] + fn far_more_actors_than_cores_all_run() { + let system = ActorSystem::new(); + let count = Arc::new(AtomicUsize::new(0)); + + let handles: Vec<_> = (0..256) + .map(|_| { + system.spawn(CounterActor { + count: count.clone(), + }) + }) + .collect(); + for handle in &handles { + handle.send(1).unwrap(); + } + + let deadline = Instant::now() + Duration::from_secs(10); + while count.load(Ordering::SeqCst) < 256 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + count.load(Ordering::SeqCst), + 256, + "every actor should have processed its message" + ); + + for handle in &handles { + handle.stop().unwrap(); + } + } +} diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs new file mode 100644 index 0000000..a494b04 --- /dev/null +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -0,0 +1,1033 @@ +use crate::actor::{Actor, ActorHandle, ConnectionState}; +use crate::client::ClientOperation; +use crate::dispatcher::MessageDispatcher; +use crate::message::peer::UserInfoResponseHandler; +use crate::message::peer::{ + FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, + GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, + QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, + TransferRequest, TransferResponse, UploadDeniedHandler, + UploadFailedHandler, UserInfoRequest, +}; +use crate::message::server::MessageFactory; +use crate::message::{Handlers, Message, MessageReader, MessageType}; +use crate::peer::Peer; +use crate::types::{Download, SearchResult, Transfer}; +use crate::utils::lock::RwLockExt; +use crate::{debug, error, trace, warn}; + +use std::io::{self, Error, Write}; +use std::net::TcpStream; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub enum PeerMessage { + SendMessage(Message), + FileSearchResult(SearchResult), + TransferRequest(Transfer), + UploadFailed(String, String), + TransferResponse { + token: u32, + allowed: bool, + reason: Option, + }, + PlaceInQueueResponse { + filename: String, + place: u32, + }, + SetUsername(String), + QueueUpload(String), + RequestTransfer(Download), + /// A peer queued one of our shared files for download (they sent us code 43). + IncomingQueueUpload(String), + /// A peer asked where their queued file sits (they sent us code 51). + IncomingPlaceInQueueRequest(String), + /// A peer asked to browse our shared files (they sent us code 4). + ShareListRequested, + /// A peer we are browsing sent us their shared-file listing (code 5). + ShareListReceived(Vec), + /// A peer answered our `UserInfoRequest` with what it says about itself. + UserInfoReceived(crate::message::peer::PeerInfo), + /// A peer answered our `FolderContentsRequest` with one folder's listing. + FolderContentsReceived { + token: u32, + folder: String, + directories: Vec, + }, + /// A peer asked what we say about ourselves (they sent us code 15). + UserInfoRequested, + /// A peer asked for one folder of our shares (they sent us code 36). + FolderContentsRequested { + token: u32, + folder: String, + }, + /// Offer the queued file to that peer: send an upload TransferRequest. + ServeUpload { + token: u32, + filename: String, + size: u64, + }, + ProcessRead, + /// A newer connection to this peer took over. Deliver what is already + /// on the wire, then close. + Retire, +} + +pub struct PeerActor { + peer: Arc>, + stream: Option, + connection_state: ConnectionState, + reader: MessageReader, + client_channel: Sender, + self_handle: Option>, + dispatcher: Option>, + dispatcher_receiver: Option>, + queued_messages: Vec, + own_username: String, + /// True when we initiated this connection (no stream supplied at + /// construction), so we must send a `PeerInit` once connected. + outbound: bool, + /// Set once the connection is up. A pre-established failure on an outbound + /// connection means the peer is unreachable (likely firewalled). + established: bool, + /// Set once this actor has reported a terminal outcome (disconnect or + /// connect-failure). Guards against a second notification — e.g. when a + /// replaced actor is later stopped — which would otherwise alias and evict + /// a newer actor registered under the same username. + disconnect_reported: bool, + /// Registry-assigned unique id, echoed in terminal notifications so the + /// client evicts only this actor and never a newer namesake. + id: u64, + /// Transfer tokens for uploads we are serving to this peer. A TransferResponse + /// for one of these is our upload being accepted, not a download offer. + serving_tokens: std::collections::HashSet, + /// Bytes queued for the peer that the socket would not take yet. The stream + /// is non-blocking (so socket reads never starve the mailbox), so a message + /// larger than the send buffer — a full share listing is easily megabytes — + /// only partly fits; the rest drains on later ticks. + pending_write: Vec, + /// When bytes last moved on this connection, in either direction. A peer + /// that goes quiet is eventually reaped: nothing else ever closes an idle + /// control connection, and each one holds a descriptor and a thread for + /// the life of the process. + last_activity: Instant, + /// Set once a newer connection to the same peer replaced this one. The + /// stream stays open until then so a reply already in flight, a browse + /// listing say, still arrives; past it the actor closes and stops. + retire_deadline: Option, +} + +/// Cap on queued outbound bytes. A peer that stops reading must not be able to +/// grow this without bound; dropping that connection is the right answer. +const MAX_PENDING_WRITE: usize = 32 * 1024 * 1024; + +/// How long a connected peer may stay silent before its control connection is +/// closed. Transfers run on their own sockets, and a peer that wants us again +/// simply reconnects — every serious client reaps idle peers this way. Five +/// minutes comfortably outlives a search's chatter and queue updates. +const IDLE_DISCONNECT: Duration = Duration::from_mins(5); +/// How long a replaced connection keeps listening for replies in flight. +const RETIRE_GRACE: Duration = Duration::from_secs(30); +/// Socket buffers read per tick: 64 KiB, against a 100 ms tick. +const READS_PER_TICK: usize = 64; + +impl PeerActor { + #[must_use] + pub fn new( + peer: Peer, + stream: Option, + reader: Option, + client_channel: Sender, + own_username: String, + id: u64, + ) -> Self { + let outbound = stream.is_none(); + let connection_state = if stream.is_some() { + ConnectionState::Connected + } else { + ConnectionState::Disconnected + }; + + Self { + peer: Arc::new(RwLock::new(peer)), + stream, + connection_state, + reader: reader.unwrap_or_default(), + client_channel, + self_handle: None, + dispatcher: None, + dispatcher_receiver: None, + queued_messages: Vec::new(), + own_username, + outbound, + established: false, + disconnect_reported: false, + id, + serving_tokens: std::collections::HashSet::new(), + pending_write: Vec::new(), + last_activity: Instant::now(), + retire_deadline: None, + } + } + + pub fn set_self_handle(&mut self, handle: ActorHandle) { + self.self_handle = Some(handle); + } + + fn peer_username(&self) -> String { + match self.peer.read_safe() { + Ok(p) => p.username.clone(), + Err(e) => { + error!("[peer_actor] peer lock poisoned: {}", e); + "".to_string() + } + } + } + + fn peer_snapshot(&self) -> Option { + match self.peer.read_safe() { + Ok(p) => Some(p.clone()), + Err(e) => { + error!("[peer_actor] peer lock poisoned: {}", e); + None + } + } + } + + fn initialize_dispatcher(&mut self) { + let (dispatcher_sender, dispatcher_receiver) = + std::sync::mpsc::channel::(); + + self.dispatcher_receiver = Some(dispatcher_receiver); + + let mut handlers = Handlers::new(); + handlers.register_handler(FileSearchResponse); + handlers.register_handler(TransferRequest); + 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); + handlers.register_handler(PlaceInQueueResponse); + handlers.register_handler(QueueUploadHandler); + handlers.register_handler(SharedFileListResponseHandler); + handlers.register_handler(PeerInit); + + self.dispatcher = Some(MessageDispatcher::new( + "peer".to_string(), + dispatcher_sender, + handlers, + )); + } + + fn process_dispatcher_messages(&mut self) { + let messages: Vec = self + .dispatcher_receiver + .as_ref() + .map_or_else(Vec::new, |receiver| receiver.try_iter().collect()); + + for msg in &messages { + self.handle_message(msg.clone()); + } + } + + fn handle_message(&mut self, msg: PeerMessage) { + if matches!(self.connection_state, ConnectionState::Connecting { .. }) { + match &msg { + PeerMessage::SetUsername(_) | PeerMessage::ProcessRead => {} + // Nothing has gone out on a dial still in progress, so there + // is nothing to wait for. + PeerMessage::Retire => { + self.retire_deadline = Some(Instant::now()); + self.disconnect(); + return; + } + _ => { + self.queued_messages.push(msg); + return; + } + } + } + + match msg { + PeerMessage::SendMessage(message) => { + self.send_message(message); + } + PeerMessage::Retire => { + self.retire_deadline = Some(Instant::now() + RETIRE_GRACE); + } + PeerMessage::FileSearchResult(file_search) => { + self.handle_file_search_result(file_search); + } + PeerMessage::TransferRequest(transfer) => { + self.handle_transfer_request(transfer); + } + PeerMessage::TransferResponse { + token, + allowed, + reason, + } => { + self.handle_transfer_response(token, allowed, reason); + } + PeerMessage::PlaceInQueueResponse { filename, place } => { + self.handle_place_in_queue_response(filename, place); + } + PeerMessage::SetUsername(username) => { + self.handle_set_username(username); + } + PeerMessage::QueueUpload(filename) => { + let message = + MessageFactory::build_queue_upload_message(&filename); + self.send_message(message); + } + PeerMessage::IncomingQueueUpload(filename) => { + self.handle_incoming_queue_upload(filename); + } + PeerMessage::IncomingPlaceInQueueRequest(filename) => { + let requester_key = self.peer_username(); + if let Err(e) = self.client_channel.send( + ClientOperation::PlaceInQueueRequested { + requester_key, + filename, + }, + ) { + error!( + "[peer_actor] forward IncomingPlaceInQueueRequest: {}", + e + ); + } + } + PeerMessage::ServeUpload { + token, + filename, + size, + } => { + self.handle_serve_upload(token, filename, size); + } + PeerMessage::ShareListRequested => { + self.forward(ClientOperation::ShareListRequested { + requester_key: self.peer_username(), + }); + } + PeerMessage::ShareListReceived(directories) => { + self.forward(ClientOperation::BrowseResult { + username: self.peer_username(), + 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(), + }); + } + PeerMessage::FolderContentsRequested { token, folder } => { + self.forward(ClientOperation::FolderContentsRequested { + requester_key: self.peer_username(), + token, + folder, + }); + } + PeerMessage::RequestTransfer(download) => { + let message = MessageFactory::build_transfer_request_message( + &download.filename, + download.token, + ); + self.send_message(message); + } + PeerMessage::ProcessRead => { + self.process_read(); + } + PeerMessage::UploadFailed(_, filename) => { + self.handle_upload_failed(filename); + } + } + } + + fn handle_file_search_result(&self, file_search: SearchResult) { + if let Err(e) = self + .client_channel + .send(ClientOperation::SearchResult(file_search)) + { + error!( + "[peer:{}] failed to forward search result: {}", + self.peer_username(), + e + ); + } + } + + /// A peer offers us a file. Record the peer's transfer token with the + /// client, and let *that* step send the TransferResponse. + /// + /// The response is what invites the peer's file connection, and that + /// connection is matched by the peer's token — so replying here would race + /// the client loop that records the token. Under load the client loop is + /// the one that loses, the file connection finds no download, and the + /// transfer is dropped with the download stuck on Queued forever. + fn handle_transfer_request(&self, transfer: Transfer) { + let username = self.peer_username(); + debug!("[peer:{}] TransferRequest for {}", username, transfer.token); + + if let Err(e) = + self.client_channel + .send(ClientOperation::UpdateDownloadTokens( + transfer, + username.clone(), + )) + { + error!( + "[peer:{}] failed to send UpdateDownloadTokens: {}", + username, e + ); + } + } + + fn handle_transfer_response( + &mut self, + token: u32, + allowed: bool, + reason: Option, + ) { + let username = self.peer_username(); + debug!( + "[peer:{}] transfer response token: {} allowed: {}", + username, token, allowed + ); + + // If this token is one of our uploads, the peer just accepted + // our offer — start streaming. This leaves the download path + // (every other token) byte-for-byte unchanged. + if self.serving_tokens.remove(&token) { + if allowed { + let _ = self + .client_channel + .send(ClientOperation::StartUpload { token }); + } + return; + } + + if allowed { + debug!( + "[peer:{}] Transfer allowed, ready to connect with token {:}", + username, token + ); + let Some(peer_snapshot) = self.peer_snapshot() else { + return; + }; + if let Err(e) = + self.client_channel.send(ClientOperation::DownloadFromPeer( + token, + peer_snapshot, + allowed, + )) + { + error!( + "[peer:{}] failed to send DownloadFromPeer: {}", + username, e + ); + } + } else if let Some(reason_text) = reason { + debug!( + "[peer:{}] Transfer rejected: {} - token {}, waiting for TransferRequest...", + username, reason_text, token + ); + } + } + + fn handle_place_in_queue_response(&self, filename: String, place: u32) { + let username = self.peer_username(); + debug!( + "[peer:{}] Place in queue response - file: {}, place: {}", + username, filename, place + ); + if let Err(e) = + self.client_channel + .send(ClientOperation::PlaceInQueueUpdate { + username: username.clone(), + filename, + place, + }) + { + error!( + "[peer:{}] failed to forward PlaceInQueueUpdate: {}", + username, e + ); + } + } + + fn handle_set_username(&self, username: String) { + trace!("[peer:{}] SetUsername: {}", self.peer_username(), username); + match self.peer.write_safe() { + Ok(mut p) => p.username = username, + Err(e) => { + error!("[peer_actor] SetUsername write: {}", e); + } + } + } + + fn handle_incoming_queue_upload(&self, filename: String) { + // A peer wants to download one of our shared files. Ask the + // client (which owns the shares) to prepare the upload. + let requester_key = self.peer_username(); + if let Err(e) = self.client_channel.send(ClientOperation::QueueUpload { + requester_key, + filename, + }) { + error!("[peer_actor] forward IncomingQueueUpload: {}", e); + } + } + + fn handle_serve_upload(&mut self, token: u32, filename: String, size: u64) { + self.serving_tokens.insert(token); + let message = MessageFactory::build_upload_transfer_request( + &filename, token, size, + ); + self.send_message(message); + } + + fn forward(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[peer_actor] forward to client: {}", e); + } + } + + fn handle_upload_failed(&self, filename: String) { + let username = self.peer_username(); + if let Err(e) = self + .client_channel + .send(ClientOperation::UploadFailed(username, filename)) + { + error!("[peer_actor] failed to forward UploadFailed: {}", e); + } + } + + fn process_read(&mut self) { + if self.reader.buffer_len() > 0 { + self.extract_and_process_messages(); + } + + { + let Some(stream) = self.stream.as_mut() else { + return; + }; + + match Self::drain(&mut self.reader, stream) { + Ok(()) => { + self.last_activity = Instant::now(); + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(ref e) if e.kind() == io::ErrorKind::TimedOut => { + if let Ok(peer_lock) = self.peer.read_safe() { + debug!( + "Read operation timed out for peer actor {:}:{:}", + peer_lock.host, peer_lock.port + ); + } + } + // A peer hanging up a control connection it had already + // established is ordinary: other clients close idle peer + // sockets while a transfer runs on its own connection. Report + // the clean disconnect it is — an error here fails every + // download queued with that peer, the one streaming included. + // A connection that never established is the other story: the + // peer accepted and vanished, which is what the server-brokered + // fallback exists for, so that one keeps the error path. + Err(ref e) + if e.kind() == io::ErrorKind::UnexpectedEof + && self.established => + { + debug!( + "[peer:{}] peer closed the connection", + self.peer_username() + ); + self.disconnect(); + return; + } + Err(e) => { + let username = self.peer_username(); + error!( + "[peer:{}] Error reading from peer: {} (kind: {:?}). Disconnecting.", + username, + e, + e.kind() + ); + self.disconnect_with_error(e); + return; + } + } + } + self.extract_and_process_messages(); + } + + /// Read what the socket holds, up to `READS_PER_TICK` buffers, so a big + /// listing lands in a few ticks rather than a few minutes; the cap keeps + /// a peer that never stops sending from starving the mailbox. Only the + /// first read reports an error: a peer that sends its reply and hangs up + /// has still sent the reply, which must be parsed before the close is + /// acted on, and the socket says so again on the next call. + fn drain( + reader: &mut MessageReader, + stream: &mut TcpStream, + ) -> io::Result<()> { + reader.read_from_socket(stream)?; + for _ in 1..READS_PER_TICK { + if reader.read_from_socket(stream).is_err() { + break; + } + } + Ok(()) + } + + fn extract_and_process_messages(&mut self) { + let username = self.peer_username(); + let mut extracted_count = 0; + loop { + match self.reader.extract_message() { + Ok(Some(mut message)) => { + extracted_count += 1; + trace!( + "[peer:{}] ← Message #{}: {:?}", + username, + extracted_count, + message + .get_message_name( + MessageType::Peer, + message.get_message_code() + ) + .map_err(|e| e.to_string()) + ); + if let Some(ref dispatcher) = self.dispatcher { + dispatcher.dispatch(&mut message); + } else { + warn!("[peer:{}] No dispatcher available!", username); + } + } + Err(e) => { + warn!( + "[peer:{}] Error extracting message: {}. Disconnecting peer.", + username, e + ); + self.disconnect_with_error(e); + return; + } + Ok(None) => { + break; + } + } + } + + self.process_dispatcher_messages(); + } + + fn send_message(&mut self, message: Message) { + let username = self.peer_username(); + if self.stream.is_none() { + error!("Cannot send message: stream is None"); + return; + } + + trace!( + "[peer:{}] ➡ {:?}", + username, + message + .get_message_name( + MessageType::Peer, + u32::from_le_bytes( + message.get_slice(0, 4).try_into().unwrap_or_default() + ) + ) + .map_err(|e| e.to_string()) + ); + + // Queue then drain, so messages always leave in order even when an + // earlier one is still partly buffered. + self.pending_write.extend_from_slice(&message.get_buffer()); + self.flush_pending_write(); + } + + /// Push as much of `pending_write` into the socket as it will take. A + /// non-blocking socket refuses the rest with `WouldBlock`; that is normal + /// backpressure, not a failure, so the remainder stays queued for the next + /// tick. Only a real I/O error disconnects. + fn flush_pending_write(&mut self) { + if self.pending_write.is_empty() { + return; + } + + let username = self.peer_username(); + let Some(stream) = self.stream.as_mut() else { + return; + }; + + let mut written = 0; + let outcome = loop { + match stream.write(&self.pending_write[written..]) { + Ok(0) => { + break Err(Error::new( + io::ErrorKind::WriteZero, + "peer stopped accepting data", + )); + } + Ok(n) => { + written += n; + if written == self.pending_write.len() { + break Ok(()); + } + } + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + break Ok(()); + } + Err(e) => break Err(e), + } + }; + self.pending_write.drain(..written); + if written > 0 { + self.last_activity = Instant::now(); + } + + if let Err(e) = outcome { + error!( + "[peer:{}] Error writing message: {}. Disconnecting.", + username, e + ); + self.disconnect_with_error(e); + return; + } + + // Backpressure is normal, but what a peer has still not taken after we + // pushed everything we could is a peer that is not reading at all. + if self.pending_write.len() > MAX_PENDING_WRITE { + error!( + "[peer:{}] Outbound queue stuck above {} bytes. Disconnecting.", + username, MAX_PENDING_WRITE + ); + self.disconnect_with_error(Error::new( + io::ErrorKind::WriteZero, + "peer is not draining our outbound queue", + )); + } + } + + fn disconnect_with_error(&mut self, error: Error) { + // A retired connection is expected to go: the peer closing it is not + // evidence the peer is gone, and must not fail its transfers on the + // connection that replaced this one. + if self.retire_deadline.is_some() { + return self.disconnect(); + } + let username = self.peer_username(); + // Include the cause: a failed outbound dial reports nothing else, so + // without it an unreachable peer is indistinguishable from a clean + // disconnect in the logs. + debug!("[peer:{}] disconnect: {}", username, error); + + self.stream.take(); + self.connection_state = ConnectionState::Disconnected; + + if self.disconnect_reported { + return; + } + self.disconnect_reported = true; + + // A direct outbound connection that never established means the peer is + // 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, + brokered_token, + ) + } else { + ClientOperation::PeerDisconnected( + self.id, + username, + Some(error.into()), + ) + }; + + if let Err(e) = self.client_channel.send(op) { + error!("Failed to send disconnect notification: {}", e); + } + } + fn disconnect(&mut self) { + let username = self.peer_username(); + debug!("[peer:{}] disconnect", username); + + self.stream.take(); + self.connection_state = ConnectionState::Disconnected; + + if self.disconnect_reported { + return; + } + self.disconnect_reported = true; + + if let Err(e) = self + .client_channel + .send(ClientOperation::PeerDisconnected(self.id, username, None)) + { + error!("Failed to send disconnect notification: {}", e); + } + } + + fn initiate_connection(&mut self) { + let (username, host, port) = match self.peer.read_safe() { + Ok(peer) => (peer.username.clone(), peer.host.clone(), peer.port), + Err(e) => { + error!("[peer_actor] initiate_connection peer lock: {}", e); + return; + } + }; + + let socket_addr = + format!("{host}:{port}").parse::(); + + let addr = match socket_addr { + Ok(addr) => addr, + Err(e) => { + error!( + "[peer:{}] Invalid socket address {}:{} - {}", + username, host, port, e + ); + self.disconnect_with_error(io::Error::new( + io::ErrorKind::InvalidInput, + e, + )); + return; + } + }; + + // Use connect_timeout to prevent blocking the thread for too long + let timeout = Duration::from_secs(5); + let stream = match TcpStream::connect_timeout(&addr, timeout) { + Ok(stream) => stream, + Err(e) => { + self.disconnect_with_error(e); + return; + } + }; + + if let Err(e) = stream.set_nonblocking(true) { + error!("[peer:{}] Failed to set non-blocking: {}", username, e); + self.disconnect_with_error(e); + return; + } + stream.set_nodelay(true).ok(); + self.stream = Some(stream); + self.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + } + + fn check_connection_status(&mut self) { + let ConnectionState::Connecting { since } = self.connection_state + else { + return; + }; + + let username = self.peer_username(); + + if since.elapsed() > Duration::from_secs(20) { + error!("[peer:{}] Connection timeout after 20 seconds", username); + self.disconnect_with_error(io::Error::new( + io::ErrorKind::TimedOut, + "Connection timeout", + )); + return; + } + + let Some(ref stream) = self.stream else { + return; + }; + + match stream.peer_addr() { + Ok(_) => { + self.connection_state = ConnectionState::Connected; + self.on_connection_established(); + } + Err(ref e) if e.kind() == io::ErrorKind::NotConnected => {} + Err(e) => { + error!("[peer:{}] Connection failed: {}", username, e); + self.disconnect_with_error(e); + } + } + } + + fn on_connection_established(&mut self) { + let (username, connection_type, token) = match self.peer.read_safe() { + Ok(peer) => ( + peer.username.clone(), + peer.connection_type.clone(), + peer.token, + ), + Err(e) => { + error!( + "[peer_actor] on_connection_established peer lock: {}", + e + ); + return; + } + }; + + self.established = true; + + let Some(ref mut stream) = self.stream else { + return; + }; + + // Connections we initiated must announce themselves; inbound peers + // already sent us theirs, so we stay silent for them. A direct dial + // (resolved via GetPeerAddress, token None) sends a PeerInit; a dial we + // make because the server asked us to (a brokered ConnectToPeer carries + // the server's token) must instead PierceFirewall with that token so + // the remote peer can correlate its own request. `get_buffer` prepends + // the length prefix the peer's MessageReader expects. + if self.outbound { + let handshake = match token { + Some(server_token) => { + MessageFactory::build_pierce_firewall_message(server_token) + } + None => MessageFactory::build_peer_init_message( + &self.own_username, + connection_type, + 0, + ), + }; + if let Err(e) = stream.write_all(&handshake.get_buffer()) { + error!( + "[peer:{}] Failed to send outbound handshake: {}", + username, e + ); + self.disconnect_with_error(e); + return; + } + } + + self.initialize_dispatcher(); + + let queued = std::mem::take(&mut self.queued_messages); + for msg in queued { + self.handle_message(msg); + } + + // Tell the client the control connection is live so any downloads + // queued for this peer can now be requested over a handshaken stream. + if self.outbound { + let _ = self + .client_channel + .send(ClientOperation::PeerConnected(username)); + } + + if let Some(ref handle) = self.self_handle { + handle.send(PeerMessage::ProcessRead).ok(); + } + + self.process_read(); + } +} + +impl Actor for PeerActor { + type Message = PeerMessage; + + fn handle(&mut self, msg: Self::Message) { + self.handle_message(msg); + } + + fn on_start(&mut self) { + if self.stream.is_none() { + self.initiate_connection(); + } else { + self.connection_state = ConnectionState::Connected; + self.on_connection_established(); + } + } + + fn on_stop(&mut self) { + let username = self.peer_username(); + trace!("[peer:{}] actor stopping", username); + self.disconnect(); + } + + fn tick(&mut self) { + match self.connection_state { + ConnectionState::Connecting { .. } => { + self.check_connection_status(); + } + ConnectionState::Connected => { + if self.stream.is_some() { + // a backlogged message drains one socket buffer + // per 100ms tick. Enough for a share listing; if a future + // message stream needs more throughput, wake the actor as + // soon as the socket is writable instead of on the tick. + self.flush_pending_write(); + self.process_read(); + self.flush_pending_write(); + + if self.stream.is_some() + && self.last_activity.elapsed() > IDLE_DISCONNECT + { + debug!( + "[peer:{}] idle for {}s, closing", + self.peer_username(), + IDLE_DISCONNECT.as_secs() + ); + self.disconnect(); + } + if self.stream.is_some() + && self + .retire_deadline + .is_some_and(|d| d <= Instant::now()) + { + self.disconnect(); + } + } + } + // Nothing in the registry points at a retired actor any more, so + // it is the one to stop itself. + ConnectionState::Disconnected => { + if self.retire_deadline.is_some() + && let Some(handle) = &self.self_handle + { + let _ = handle.stop(); + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/soulseek-rs-lib/src/actor/peer_actor/tests.rs b/soulseek-rs-lib/src/actor/peer_actor/tests.rs new file mode 100644 index 0000000..c7d8325 --- /dev/null +++ b/soulseek-rs-lib/src/actor/peer_actor/tests.rs @@ -0,0 +1,235 @@ +use super::*; +use crate::message::peer::build_shared_file_list; +use crate::peer::{ConnectionType, Peer}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::Receiver; + +/// A connected inbound actor over a real loopback socket, plus the far +/// end (kept alive so reads see silence, not EOF). +fn connected_actor() -> (PeerActor, Receiver, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let stream = TcpStream::connect(addr).unwrap(); + stream.set_nonblocking(true).unwrap(); + let (far_end, _) = listener.accept().unwrap(); + + let (mut actor, rx) = make_actor(Some(stream)); + actor.on_start(); + (actor, rx, far_end) +} + +fn make_actor( + stream: Option, +) -> (PeerActor, Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + let peer = Peer::new( + "bob".to_string(), + ConnectionType::P, + "127.0.0.1".to_string(), + 0, + None, + 0, + 0, + 0, + ); + let actor = PeerActor::new(peer, stream, None, tx, "me".to_string(), 7); + (actor, rx) +} + +#[test] +fn a_timed_out_connect_parks_the_actor_in_disconnected() { + let (mut actor, rx) = make_actor(None); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(), + }; + + actor.tick(); + + assert!( + matches!(actor.connection_state, ConnectionState::Disconnected), + "a timed-out connect must leave Connecting" + ); + match rx.try_recv() { + Ok(ClientOperation::PeerConnectFailed(7, username, _)) => { + assert_eq!(username, "bob"); + } + other => panic!("expected PeerConnectFailed, got {other:?}"), + } +} + +#[test] +fn an_upload_failed_message_reaches_the_client_as_this_peer() { + let (mut actor, rx, _far_end) = connected_actor(); + + actor.handle_message(PeerMessage::UploadFailed( + String::new(), + "song.mp3".to_string(), + )); + + match rx.try_recv() { + Ok(ClientOperation::UploadFailed(username, filename)) => { + assert_eq!(username, "bob"); + assert_eq!(filename, "song.mp3"); + } + other => panic!("expected UploadFailed, got {other:?}"), + } +} + +#[test] +fn tick_reaps_a_peer_idle_past_the_deadline() { + let (mut actor, rx, _far_end) = connected_actor(); + + actor.tick(); + assert!(actor.stream.is_some(), "a fresh connection stays open"); + + actor.last_activity = Instant::now() + .checked_sub(IDLE_DISCONNECT + Duration::from_secs(1)) + .unwrap(); + actor.tick(); + + assert!(actor.stream.is_none(), "an idle stream must be closed"); + match rx.try_recv() { + Ok(ClientOperation::PeerDisconnected(7, username, None)) => { + assert_eq!(username, "bob"); + } + other => panic!("expected a clean PeerDisconnected, got {other:?}"), + } +} + +/// A listing well past one socket buffer, so delivery within a couple of +/// ticks proves the actor drains the socket rather than sipping 1 KiB a tick. +fn big_listing() -> Vec { + let files = (0..400u64) + .map(|i| crate::message::peer::SharedFileEntry { + name: format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)), + size: i, + attributes: Vec::new(), + }) + .collect(); + let dir = crate::message::peer::SharedDirectory { + name: "album".to_string(), + files, + }; + let bytes = build_shared_file_list(std::slice::from_ref(&dir)).get_buffer(); + assert!(bytes.len() > 4096, "the listing must span several reads"); + bytes +} + +fn self_handle( + actor: &mut PeerActor, +) -> Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + actor.set_self_handle(ActorHandle { sender: tx }); + rx +} + +// A connection replaced by a newer one to the same peer may still carry +// a reply already on the wire: it is retired, not cut, and once nothing +// more can come it is the one to stop itself. +#[test] +fn a_retired_actor_delivers_what_is_on_the_wire_then_stops_itself() { + use std::io::Write; + let (mut actor, rx, mut far_end) = connected_actor(); + let stop_rx = self_handle(&mut actor); + + actor.handle_message(PeerMessage::Retire); + far_end.write_all(&big_listing()).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + actor.tick(); + actor.tick(); + + match rx.try_recv() { + Ok(ClientOperation::BrowseResult { username, .. }) => { + assert_eq!(username, "bob"); + } + other => { + panic!("expected the listing to reach the client, got {other:?}") + } + } + assert!(actor.stream.is_some(), "retiring is not yet closing"); + assert!(stop_rx.try_recv().is_err(), "not stopped while listening"); + + actor.retire_deadline = + Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap()); + actor.tick(); + assert!(actor.stream.is_none(), "past the grace the stream closes"); + match rx.try_recv() { + Ok(ClientOperation::PeerDisconnected(7, _, None)) => {} + other => panic!("expected a clean PeerDisconnected, got {other:?}"), + } + + actor.tick(); + assert!( + matches!(stop_rx.try_recv(), Ok(crate::actor::ActorMessage::Stop)), + "a closed retired actor stops itself" + ); +} + +// The peer that replaced the connection considers the old one done too: it +// writes its reply and hangs up in the same breath. The reply still counts. +#[test] +fn a_reply_sent_right_before_the_peer_hangs_up_is_still_delivered() { + use std::io::Write; + let (mut actor, rx, mut far_end) = connected_actor(); + + actor.handle_message(PeerMessage::Retire); + far_end.write_all(&big_listing()).unwrap(); + drop(far_end); + std::thread::sleep(Duration::from_millis(50)); + actor.tick(); + actor.tick(); + + match rx.try_recv() { + Ok(ClientOperation::BrowseResult { username, .. }) => { + assert_eq!(username, "bob"); + } + other => panic!("expected the listing before the close, got {other:?}"), + } + match rx.try_recv() { + Ok(ClientOperation::PeerDisconnected(7, _, None)) => {} + other => panic!("expected a clean PeerDisconnected, got {other:?}"), + } +} + +#[test] +fn a_retired_actor_reporting_an_error_reports_a_clean_close() { + let (mut actor, rx, _far_end) = connected_actor(); + + actor.handle_message(PeerMessage::Retire); + actor.disconnect_with_error(Error::from(io::ErrorKind::ConnectionReset)); + + match rx.try_recv() { + Ok(ClientOperation::PeerDisconnected(7, _, None)) => {} + other => panic!( + "a retired connection going away is not an error, got {other:?}" + ), + } +} + +// Retiring an actor whose dial has not completed: nothing went out, so +// there is nothing to wait for, and it must not linger in the queue that +// only a successful connection drains. +#[test] +fn retiring_a_connecting_actor_closes_it_at_once() { + let (mut actor, rx) = make_actor(None); + let stop_rx = self_handle(&mut actor); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + + actor.handle_message(PeerMessage::Retire); + + assert!(matches!( + actor.connection_state, + ConnectionState::Disconnected + )); + match rx.try_recv() { + Ok(ClientOperation::PeerDisconnected(7, _, None)) => {} + other => panic!("expected a clean PeerDisconnected, got {other:?}"), + } + actor.tick(); + assert!(matches!( + stop_rx.try_recv(), + Ok(crate::actor::ActorMessage::Stop) + )); +} diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs new file mode 100644 index 0000000..2c7c6f9 --- /dev/null +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -0,0 +1,461 @@ +use crate::actor::peer_actor::{PeerActor, PeerMessage}; +use crate::actor::{ActorHandle, ActorSystem}; +use crate::client::ClientOperation; +use crate::message::MessageReader; +use crate::peer::Peer; +use crate::utils::lock::MutexExt; +use crate::{debug, error}; + +use std::collections::{HashMap, HashSet}; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; + +/// Source of unique per-actor ids so terminal-outcome eviction can be made +/// identity-aware (a replaced actor must not evict its replacement). +static NEXT_PEER_ID: AtomicU64 = AtomicU64::new(1); + +/// Registered peers keyed by username, each stored with the unique id of the +/// actor currently occupying the slot. +type PeerMap = HashMap)>; + +pub struct PeerRegistry { + peers: Arc>, + actor_system: Arc, + client_channel: Sender, + own_username: String, + capacity: Arc, +} + +impl PeerRegistry { + #[must_use] + pub fn new( + actor_system: Arc, + client_channel: Sender, + own_username: String, + ) -> Self { + Self::with_capacity( + actor_system, + client_channel, + own_username, + Arc::new(AtomicUsize::new(usize::MAX)), + ) + } + + #[must_use] + pub(crate) fn with_capacity( + actor_system: Arc, + client_channel: Sender, + own_username: String, + capacity: Arc, + ) -> Self { + Self { + peers: Arc::new(Mutex::new(HashMap::new())), + actor_system, + client_channel, + own_username, + capacity, + } + } + + pub fn register_peer( + &self, + peer: Peer, + stream: Option, + reader: Option, + ) -> Result, String> { + self.register_peer_protected(peer, stream, reader, &HashSet::new()) + } + + pub(crate) fn register_peer_protected( + &self, + peer: Peer, + stream: Option, + reader: Option, + protected: &HashSet, + ) -> Result, String> { + let username = peer.username.clone(); + let id = NEXT_PEER_ID.fetch_add(1, Ordering::Relaxed); + + let actor = PeerActor::new( + peer, + stream, + reader, + self.client_channel.clone(), + self.own_username.clone(), + id, + ); + + // Take the map lock before the actor exists: a peer that dies + // instantly (refused dial, immediate hangup) reports its terminal + // outcome to the client loop, whose eviction takes this same lock. + // With the insert racing the spawn, eviction could run first, find + // nothing, and the entry inserted afterwards became a permanent + // zombie claiming the username. + let mut peers = self + .peers + .lock_safe() + .map_err(|e| format!("peer registry lock poisoned: {e}"))?; + + let capacity = self.capacity.load(Ordering::Relaxed).max(1); + if peers.len() >= capacity && !peers.contains_key(&username) { + let victim = peers + .iter() + .filter(|(name, _)| !protected.contains(name.as_str())) + .min_by_key(|(_, (id, _))| *id) + .map(|(name, _)| name.clone()); + let Some(victim) = victim else { + return Err(format!( + "peer registry at capacity ({capacity}) and every peer \ + is busy; refusing {username}" + )); + }; + if let Some((_, handle)) = peers.remove(&victim) { + let _ = handle.stop(); + debug!( + "[peer_registry] evicted idle peer {} to admit {}", + victim, username + ); + } + } + + let handle = self + .actor_system + .try_spawn_with_handle(actor, |actor, handle| { + actor.set_self_handle(handle); + }) + .map_err(|e| format!("failed to spawn peer actor thread: {e}"))?; + // Retire any actor already registered under this username rather than + // stop it: a reply to a request we sent on it may still be on the + // wire. It stops itself once that grace is over, and the eviction its + // shutdown triggers is keyed on its id, so it cannot evict this one. + if let Some((_, old_handle)) = + peers.insert(username.clone(), (id, handle.clone())) + { + let _ = old_handle.send(PeerMessage::Retire); + debug!( + "[peer_registry] Replaced existing peer actor for {}", + username + ); + } + + Ok(handle) + } + + #[must_use] + pub fn get_peer(&self, username: &str) -> Option> { + match self.peers.lock_safe() { + Ok(peers) => peers.get(username).map(|(_, handle)| handle.clone()), + Err(e) => { + error!("[peer_registry] get_peer: {}", e); + None + } + } + } + + #[must_use] + pub fn remove_peer( + &self, + username: &str, + ) -> Option> { + let mut peers = match self.peers.lock_safe() { + Ok(p) => p, + Err(e) => { + error!("[peer_registry] remove_peer: {}", e); + return None; + } + }; + let removed = peers.remove(username); + + if removed.is_some() { + debug!("[peer_registry] Removed peer actor for {}", username); + } + + removed.map(|(_, handle)| handle) + } + + /// Remove and return the actor for `username` only if it is still the actor + /// with `id`. A stale (replaced) actor's terminal notification therefore + /// cannot evict the newer actor that now occupies the slot. + #[must_use] + pub fn remove_peer_if( + &self, + username: &str, + id: u64, + ) -> Option> { + let mut peers = match self.peers.lock_safe() { + Ok(p) => p, + Err(e) => { + error!("[peer_registry] remove_peer_if: {}", e); + return None; + } + }; + if peers.get(username).is_some_and(|(stored, _)| *stored == id) { + let removed = peers.remove(username).map(|(_, handle)| handle); + debug!( + "[peer_registry] Removed peer actor {} for {}", + id, username + ); + return removed; + } + None + } + + /// Stop every peer actor and empty the registry, closing the connections + /// they hold. Used when the client shuts down. + pub fn stop_all(&self) { + let handles = match self.peers.lock_safe() { + Ok(mut peers) => peers + .drain() + .map(|(_, (_, handle))| handle) + .collect::>(), + Err(e) => { + error!("[peer_registry] stop_all: {}", e); + return; + } + }; + for handle in handles { + let _ = handle.stop(); + } + } + + #[must_use] + pub fn contains(&self, username: &str) -> bool { + match self.peers.lock_safe() { + Ok(peers) => peers.contains_key(username), + Err(e) => { + error!("[peer_registry] contains: {}", e); + false + } + } + } + + pub fn send_to_peer( + &self, + username: &str, + message: PeerMessage, + ) -> Result<(), String> { + let handle = self + .get_peer(username) + .ok_or_else(|| format!("Peer {username} not found in registry"))?; + + handle.send(message) + } + + pub fn queue_upload( + &self, + username: &str, + filename: String, + ) -> Result<(), String> { + self.send_to_peer(username, PeerMessage::QueueUpload(filename)) + } +} + +impl Clone for PeerRegistry { + fn clone(&self) -> Self { + Self { + peers: self.peers.clone(), + actor_system: self.actor_system.clone(), + client_channel: self.client_channel.clone(), + own_username: self.own_username.clone(), + capacity: self.capacity.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::PeerRegistry; + use crate::actor::ActorSystem; + use crate::peer::{ConnectionType, Peer}; + use std::collections::HashSet; + use std::net::{TcpListener, TcpStream}; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + fn loopback_peer(name: &str) -> (Peer, TcpStream, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let stream = TcpStream::connect(addr).unwrap(); + stream.set_nonblocking(true).unwrap(); + let far_end = listener.accept().unwrap().0; + let peer = Peer::new( + name.to_string(), + ConnectionType::P, + "127.0.0.1".to_string(), + u32::from(addr.port()), + None, + 0, + 0, + 0, + ); + (peer, stream, far_end) + } + + fn capped_registry( + capacity: usize, + ) -> ( + PeerRegistry, + std::sync::mpsc::Receiver, + ) { + let system = Arc::new(ActorSystem::new()); + let (tx, rx) = std::sync::mpsc::channel(); + let registry = PeerRegistry::with_capacity( + system, + tx, + "me".to_string(), + Arc::new(AtomicUsize::new(capacity)), + ); + (registry, rx) + } + + #[test] + fn capacity_evicts_the_oldest_idle_peer() { + let (registry, _rx) = capped_registry(2); + + for name in ["a", "b", "c"] { + let (peer, stream, _far_end) = loopback_peer(name); + registry.register_peer(peer, Some(stream), None).unwrap(); + } + + assert!(!registry.contains("a"), "the oldest peer must be evicted"); + assert!(registry.contains("b")); + assert!(registry.contains("c")); + } + + #[test] + fn capacity_refuses_when_every_peer_is_protected() { + let (registry, _rx) = capped_registry(1); + + let (busy, busy_stream, _busy_far) = loopback_peer("busy"); + registry + .register_peer(busy, Some(busy_stream), None) + .unwrap(); + + let protected: HashSet = + std::iter::once("busy".to_string()).collect(); + let (newcomer, stream, _far) = loopback_peer("newcomer"); + let result = registry.register_peer_protected( + newcomer, + Some(stream), + None, + &protected, + ); + + assert!(result.is_err(), "a full registry of busy peers must refuse"); + assert!(registry.contains("busy")); + assert!(!registry.contains("newcomer")); + } + + #[test] + fn a_returning_username_is_replaced_not_refused_at_capacity() { + let (registry, _rx) = capped_registry(1); + + let (first, first_stream, _first_far) = loopback_peer("bob"); + registry + .register_peer(first, Some(first_stream), None) + .unwrap(); + + let protected: HashSet = + std::iter::once("bob".to_string()).collect(); + let (again, stream, _far) = loopback_peer("bob"); + let result = registry.register_peer_protected( + again, + Some(stream), + None, + &protected, + ); + + assert!( + result.is_ok(), + "a reconnecting username must replace itself" + ); + assert!(registry.contains("bob")); + } + + #[test] + fn remove_peer_if_respects_actor_identity() { + let system = Arc::new(ActorSystem::new()); + let (tx, _rx) = std::sync::mpsc::channel(); + let registry = PeerRegistry::new(system, tx, "me".to_string()); + + // A real loopback connection makes the actor inbound (no dial-out); + // non-blocking so it can process Stop promptly on teardown. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let stream = TcpStream::connect(addr).unwrap(); + stream.set_nonblocking(true).unwrap(); + let _server_side = listener.accept().unwrap().0; + + let peer = Peer::new( + "bob".to_string(), + ConnectionType::P, + "127.0.0.1".to_string(), + u32::from(addr.port()), + None, + 0, + 0, + 0, + ); + registry.register_peer(peer, Some(stream), None).unwrap(); + assert!(registry.contains("bob")); + + // A stale / wrong id must not evict the live actor. + assert!(registry.remove_peer_if("bob", u64::MAX).is_none()); + assert!(registry.contains("bob")); + + // Unconditional removal still works (and stops the actor). + let handle = registry.remove_peer("bob"); + assert!(handle.is_some()); + let _ = handle.unwrap().stop(); + assert!(!registry.contains("bob")); + } + + // A dial that is refused reports its terminal outcome almost instantly — + // possibly while register_peer is still between spawn and insert. The + // registry holds its lock across both, so the eviction that follows must + // always find the entry; a miss left a permanent zombie claiming the + // username. + #[test] + fn refused_dial_does_not_leave_a_zombie_entry() { + use crate::client::ClientOperation; + + let system = Arc::new(ActorSystem::new()); + let (tx, rx) = std::sync::mpsc::channel(); + let registry = PeerRegistry::new(system, tx, "me".to_string()); + + // A port with nothing listening behind it: bind, learn it, drop it. + let port = { + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + probe.local_addr().unwrap().port() + }; + + let peer = Peer::new( + "ghost".to_string(), + ConnectionType::P, + "127.0.0.1".to_string(), + u32::from(port), + None, + 0, + 0, + 0, + ); + registry.register_peer(peer, None, None).unwrap(); + + // 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, _)) => { + assert_eq!(username, "ghost"); + if let Some(handle) = registry.remove_peer_if(&username, id) { + let _ = handle.stop(); + } + } + other => panic!("expected PeerConnectFailed, got {other:?}"), + } + assert!( + !registry.contains("ghost"), + "a refused dial must not leave a registry entry" + ); + } +} diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs new file mode 100644 index 0000000..658387f --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -0,0 +1,722 @@ +use crate::actor::{Actor, ActorHandle, ConnectionState}; +use crate::client::{CancelHandle, 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; +use crate::message::server::MessageFactory; +use crate::message::server::MessageUser; +use crate::message::server::ParentMinSpeedHandler; +use crate::message::server::ParentSpeedRatioHandler; +use crate::message::server::PossibleParentsHandler; +use crate::message::server::PrivilegedUsersHandler; +use crate::message::server::ReloggedHandler; +use crate::message::server::ResetDistributedHandler; +use crate::message::server::SayChatroomHandler; +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, Recommendation, RoomEvent, RoomInfo, RoomUserStats, + SessionLoss, SessionWatch, SimilarUser, UserInterests, +}; +use crate::utils::lock::RwLockExt; + +use std::io::{self, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use crate::{SoulseekRs, debug, error, trace, warn}; + +mod handlers; +mod types; +pub use types::{Context, PeerAddress, UserMessage}; + +/// Ceiling on the wait for the server's login verdict. A loaded server can +/// take seconds to answer, so this stays inside the caller's own 45s bound +/// rather than undercutting it. +const LOGIN_VERDICT_TIMEOUT: Duration = Duration::from_secs(30); + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// The server actor's mailbox. Marked non-exhaustive: each protocol message +/// the client learns adds a variant, and that must not break callers. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ServerMessage { + ProcessRead, + LoginStatus(bool), + /// The server is closing this connection: the same username logged in + /// elsewhere. + Relogged, + SendMessage(Message), + Login { + username: String, + password: String, + version: ClientVersion, + response: std::sync::mpsc::Sender>, + }, + FileSearch { + token: u32, + query: String, + }, + /// A wishlist search (code 103): a stored query the server lets us repeat + /// once per announced interval. + WishlistSearch { + token: u32, + query: String, + }, + /// The server announced how often it will accept a wishlist search. + WishlistInterval(u32), + /// Everyone the server counts as privileged (code 69). + PrivilegedUsers(Vec), + /// Seconds of our own privileges left (code 92). + OwnPrivileges(u32), + /// Ask the server for the answer to the above. + CheckPrivileges, + /// A search the server distributed to us from another user; if it matches + /// our shares we reply with a FileSearchResponse. + FileSearchRequest { + username: String, + token: u32, + query: String, + }, + #[allow(dead_code)] + ConnectToPeer(Peer), + PierceFirewall(u32), + GetPeerAddress(String), + GetPeerAddressResponse { + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + }, + PrivateMessageReceived(UserMessage), + RoomListReceived(Vec), + /// A `GetUserStatus` (code 7) reply. + UserStatusReceived { + username: String, + status: u32, + privileged: bool, + }, + /// A `WatchUser` (code 5) reply: the initial snapshot for a user we just + /// started watching. The stats are absent when the server does not know + /// the username. + WatchedUserReceived { + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + }, + /// A `GetUserStats` (code 36) reply. + UserStatsReceived { + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + }, + RoomJoined { + room: String, + users: Vec, + }, + /// Per-member statistics carried by the same `JoinRoom` (code 14) reply. + RoomMemberStats { + room: String, + stats: Vec, + }, + RoomLeft { + room: String, + }, + RoomMessageReceived { + room: String, + username: String, + message: String, + }, + RoomUserJoined { + room: String, + username: String, + }, + RoomUserLeft { + room: String, + username: String, + }, + /// Peers the server suggests as distributed-network parents: username, + /// host, port. + PossibleParents(Vec<(String, String, u16)>), + /// The server asks us to drop our parent and start over. + ResetDistributed, + /// The ticker board of a room we just joined (code 113). + RoomTickers { + room: String, + tickers: Vec, + }, + /// One member set their ticker (code 114). + RoomTickerAdded { + room: String, + username: String, + ticker: String, + }, + /// One member cleared their ticker (code 115). + RoomTickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed (code 152). + GlobalRoomMessageReceived { + room: String, + username: String, + message: String, + }, + /// Recommendations from our own interests (code 54). + RecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Server-wide recommendations (code 56). + GlobalRecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendationsReceived { + item: String, + recommendations: Vec, + }, + /// Users similar to us (code 110). + SimilarUsersReceived { + users: Vec, + }, + /// Users who like one item (code 112). + ItemSimilarUsersReceived { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterestsReceived(UserInterests), + /// A peer could not connect to us after we asked the server to broker + /// (code 1001); the token is the one we quoted. + CantConnectToPeer { + token: u32, + }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), + /// The upload speed a client needs before the server lets it carry + /// children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), + /// Who may enter a private room (code 133). + PrivateRoomMembers { + room: String, + users: Vec, + }, + /// Who runs a private room (code 148). + PrivateRoomOperators { + room: String, + users: Vec, + }, + /// One user joined or left a private room's member or operator roster + /// (codes 134/135 and 143/144). + PrivateRoomRosterChanged { + room: String, + username: String, + /// True for the member roster, false for the operator roster. + members: bool, + added: bool, + }, + /// Our own membership (139/140) or operatorship (145/146) of a private + /// room was granted or revoked. + OwnRoomStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003). + CantCreateRoom { + room: String, + }, +} + +pub struct ServerActor { + address: PeerAddress, + context: Arc>, + listen_port: u16, + enable_listen: bool, + stream: Option, + connection_state: ConnectionState, + reader: MessageReader, + client_channel: Sender, + self_handle: Option>, + dispatcher: Option>, + dispatcher_receiver: Option>, + dispatcher_sender: Option>, + queued_messages: Vec, + shared_folder_count: u32, + shared_file_count: u32, + session: SessionWatch, + cancel: CancelHandle, +} + +/// The messages a client sends right after a successful login: its shared-file +/// counts, online status, and (when listening) +/// the port peers should connect to. Kept as a free function so it can be +/// tested without a live connection. +fn post_login_messages( + enable_listen: bool, + listen_port: u16, + shared_folders: u32, + shared_files: u32, +) -> Vec { + let mut messages = vec![ + MessageFactory::build_shared_folders_message( + shared_folders, + shared_files, + ), + MessageFactory::build_set_status_message(2), + ]; + if enable_listen { + messages.push(MessageFactory::build_set_wait_port_message(listen_port)); + } + messages +} + +impl ServerActor { + #[must_use] + pub fn new( + address: PeerAddress, + client_channel: Sender, + listen_port: u16, + enable_listen: bool, + shared_folder_count: u32, + shared_file_count: u32, + ) -> Self { + Self { + address, + context: Arc::new(RwLock::new(Context::new())), + listen_port, + enable_listen, + stream: None, + connection_state: ConnectionState::Disconnected, + dispatcher: None, + dispatcher_receiver: None, + dispatcher_sender: None, + reader: MessageReader::new(), + client_channel, + self_handle: None, + queued_messages: Vec::new(), + shared_folder_count, + shared_file_count, + session: SessionWatch::default(), + cancel: CancelHandle::new(), + } + } + + /// Share the client's view of whether this session is still alive. + pub fn set_session_watch(&mut self, session: SessionWatch) { + self.session = session; + } + + pub fn set_cancel(&mut self, cancel: CancelHandle) { + self.cancel = cancel; + } + + fn initiate_connection(&mut self) -> bool { + let stream = (self.address.get_host(), self.address.get_port()) + .to_socket_addrs() + .ok() + .and_then(|mut addrs| { + addrs.find_map(|addr| { + TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).ok() + }) + }); + let Some(stream) = stream else { + error!("[server] Failed to connect to {}", self.address); + self.disconnect_with_error(); + return false; + }; + + if let Err(e) = stream.set_nonblocking(true) { + error!("[server] Failed to set non-blocking: {}", e); + self.disconnect_with_error(); + return false; + } + stream.set_nodelay(true).ok(); + if let Err(e) = crate::utils::keepalive::set_keepalive(&stream) { + warn!("[server] keepalive not set: {}", e); + } + + self.stream = Some(stream); + self.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + true + } + + pub fn set_self_handle(&mut self, handle: ActorHandle) { + self.self_handle = Some(handle); + } + + fn initialize_dispatcher(&mut self) { + let (dispatcher_sender, dispatcher_receiver) = + std::sync::mpsc::channel::(); + + self.dispatcher_receiver = Some(dispatcher_receiver); + self.dispatcher_sender = Some(dispatcher_sender.clone()); + + if let Err(e) = self + .client_channel + .send(ClientOperation::SetServerSender(dispatcher_sender.clone())) + { + error!("[server] failed to send SetServerSender: {}", e); + } + + let mut handlers = Handlers::new(); + + handlers.register_handler(LoginHandler); + handlers.register_handler(ReloggedHandler); + handlers.register_handler(AdminMessageHandler); + handlers.register_handler(PossibleParentsHandler); + handlers.register_handler(ResetDistributedHandler); + handlers.register_handler(EmbeddedMessageHandler); + handlers.register_handler(RoomListHandler); + handlers.register_handler(GetUserStatusHandler); + handlers.register_handler(WatchUserHandler); + handlers.register_handler(GetUserStatsHandler); + handlers.register_handler(JoinRoomHandler); + handlers.register_handler(LeaveRoomHandler); + handlers.register_handler(SayChatroomHandler); + handlers.register_handler(UserJoinedRoomHandler); + handlers.register_handler(UserLeftRoomHandler); + handlers.register_handler(ExcludedSearchPhrasesHandler); + handlers.register_handler(PrivilegedUsersHandler); + handlers.register_handler(MessageUser); + handlers.register_handler(WishListIntervalHandler); + handlers.register_handler(ParentMinSpeedHandler); + handlers.register_handler(ParentSpeedRatioHandler); + handlers.register_handler(CheckPrivilegesHandler); + 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(), + dispatcher_sender, + handlers, + )); + } + + fn process_dispatcher_messages(&mut self) { + let messages: Vec = self + .dispatcher_receiver + .as_ref() + .map_or_else(Vec::new, |receiver| receiver.try_iter().collect()); + + for msg in &messages { + self.handle_message(msg.clone()); + } + } + + pub fn file_search(&mut self, token: u32, query: &str) { + self.queue_message(MessageFactory::build_file_search_message( + token, query, + )); + } + + fn process_read(&mut self) { + if self.reader.buffer_len() > 0 { + self.extract_and_process_messages(); + } + + { + let Some(stream) = self.stream.as_mut() else { + return; + }; + + match self.reader.read_from_socket(stream) { + Ok(()) => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(ref e) if e.kind() == io::ErrorKind::TimedOut => { + debug!("[server] Read operation timed out",); + } + Err(e) => { + error!( + "[server] Error reading from server: {} (kind: {:?}). Disconnecting.", + e, + e.kind() + ); + self.disconnect_with_error(); + return; + } + } + } + self.extract_and_process_messages(); + } + + fn extract_and_process_messages(&mut self) { + let mut extracted_count = 0; + loop { + match self.reader.extract_message() { + Ok(Some(mut message)) => { + extracted_count += 1; + trace!( + "[server] ← Message #{}: {:?}", + extracted_count, + message + .get_message_name( + MessageType::Server, + message.get_message_code() + ) + .map_err(|e| e.to_string()) + ); + if let Some(ref dispatcher) = self.dispatcher { + dispatcher.dispatch(&mut message); + } else { + warn!("[server] No dispatcher available!",); + } + } + Err(e) => { + warn!( + "[server] Error extracting message: {}. Disconnecting.", + e + ); + self.disconnect_with_error(); + return; + } + Ok(None) => { + break; + } + } + } + + self.process_dispatcher_messages(); + } + + /// Forward a chat-room event to the client operations loop. + fn forward_room_event(&self, event: RoomEvent) { + if let Err(e) = + self.client_channel.send(ClientOperation::RoomEvent(event)) + { + error!("[server] Error forwarding room event to client: {}", e); + } + } + + fn queue_message(&mut self, message: Message) { + if let Some(sender) = &self.dispatcher_sender { + match sender.send(ServerMessage::SendMessage(message)) { + Ok(()) => {} + Err(e) => error!("Failed to send: {}", e), + } + } else { + self.queued_messages + .push(ServerMessage::SendMessage(message)); + } + } + + fn send_message(&mut self, message: Message) { + let cancel = self.cancel.clone(); + let Some(_live) = cancel.live() else { + return; + }; + let Some(stream) = self.stream.as_mut() else { + error!("[server] Cannot send message: stream is None"); + return; + }; + + trace!( + "[server] ➡ {:?}", + message + .get_message_name( + MessageType::Server, + u32::from_le_bytes( + message.get_slice(0, 4).try_into().unwrap_or_default() + ) + ) + .map_err(|e| e.to_string()) + ); + + if let Err(e) = stream.write_all(&message.get_buffer()) { + error!("[server] Error writing message: {}. Disconnecting.", e); + self.disconnect_with_error(); + return; + } + + if let Err(e) = stream.flush() { + error!("[server] Error flushing stream: {}. Disconnecting.", e); + self.disconnect_with_error(); + } + } + + /// The server is closing this connection because the same account logged + /// in elsewhere. Nothing reconnects it, so the session is over. + fn handle_relogged(&mut self) { + error!( + "[server] another login took this username; this session has been \ + closed by the server" + ); + self.session.record(SessionLoss::Displaced); + self.disconnect(); + } + + fn disconnect_with_error(&mut self) { + debug!("[server] disconnect"); + + if matches!(self.connection_state, ConnectionState::Connected) { + self.session.record(SessionLoss::Disconnected); + } + self.disconnect(); + } + + fn disconnect(&mut self) { + debug!("[server] disconnected"); + + self.stream.take(); + self.connection_state = ConnectionState::Disconnected; + self.dispatcher = None; + self.dispatcher_sender = None; + self.dispatcher_receiver = None; + self.queued_messages.clear(); + self.reader = MessageReader::new(); + if let Ok(mut ctx) = self.context.write_safe() { + ctx.logged_in = None; + } + } + + fn check_connection_status(&mut self) { + let ConnectionState::Connecting { since } = self.connection_state + else { + return; + }; + + if since.elapsed() > Duration::from_secs(20) { + error!("[server] Connection timeout after 20 seconds"); + self.disconnect_with_error(); + return; + } + + let Some(ref stream) = self.stream else { + return; + }; + + match stream.peer_addr() { + Ok(_) => { + self.connection_state = ConnectionState::Connected; + self.on_connection_established(); + } + Err(ref e) if e.kind() == io::ErrorKind::NotConnected => {} + Err(e) => { + error!("[server] Connection failed: {}", e); + self.disconnect_with_error(); + } + } + } + + fn on_connection_established(&mut self) { + self.initialize_dispatcher(); + + let queued = std::mem::take(&mut self.queued_messages); + for msg in queued { + self.handle_message(msg); + } + + if let Some(ref handle) = self.self_handle { + handle.send(ServerMessage::ProcessRead).ok(); + } + + self.process_read(); + } +} + +impl Actor for ServerActor { + type Message = ServerMessage; + + fn handle(&mut self, msg: Self::Message) { + self.handle_message(msg); + } + + fn on_start(&mut self) { + if self.stream.is_none() { + let _ = self.initiate_connection(); + } else { + self.connection_state = ConnectionState::Connected; + self.on_connection_established(); + } + } + + fn on_stop(&mut self) { + trace!("[server] actor stopping"); + self.disconnect(); + } + + fn tick(&mut self) { + match self.connection_state { + ConnectionState::Connecting { .. } => { + self.check_connection_status(); + } + ConnectionState::Connected => { + if self.stream.is_some() { + self.process_read(); + } + } + ConnectionState::Disconnected => {} + } + } +} + +#[cfg(test)] +mod tests; diff --git a/soulseek-rs-lib/src/actor/server_actor/handlers.rs b/soulseek-rs-lib/src/actor/server_actor/handlers.rs new file mode 100644 index 0000000..072cb10 --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/handlers.rs @@ -0,0 +1,528 @@ +//! What the actor does with a message once it has one: the dispatch and the +//! per-message handlers behind it. +//! +//! A child module, so these keep reaching the actor's private state without +//! any of it being widened for the move. + +use super::{ + ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration, + LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt, + ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error, + post_login_messages, +}; + +impl ServerActor { + pub(super) fn handle_message(&mut self, msg: ServerMessage) { + if !matches!(self.connection_state, ConnectionState::Connected) + && !matches!( + &msg, + ServerMessage::ProcessRead | ServerMessage::Login { .. } + ) + { + self.queued_messages.push(msg); + return; + } + + match msg { + ServerMessage::ConnectToPeer(peer) => { + self.handle_connect_to_peer(peer); + } + ServerMessage::LoginStatus(message) => { + self.handle_login_status(message); + } + ServerMessage::Relogged => self.handle_relogged(), + ServerMessage::PierceFirewall(token) => { + self.send_message( + MessageFactory::build_pierce_firewall_message(token), + ); + } + ServerMessage::SendMessage(message) => { + self.send_message(message); + } + ServerMessage::GetPeerAddress(username) => { + self.send_message(MessageFactory::build_get_peer_address( + &username, + )); + } + ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + } => { + self.handle_get_peer_address_response( + username, + host, + port, + obfuscation_type, + obfuscated_port, + ); + } + ServerMessage::PrivateMessageReceived(user_message) => { + self.handle_private_message_received(user_message); + } + ServerMessage::ProcessRead => { + self.process_read(); + } + ServerMessage::Login { + username, + password, + version, + response, + } => { + self.handle_login(username, password, version, response); + } + ServerMessage::FileSearch { token, query } => { + self.file_search(token, &query); + } + ServerMessage::FileSearchRequest { + username, + token, + query, + } => { + self.handle_file_search_request(username, token, query); + } + other => self.handle_social_message(other), + } + } + + /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events + /// and user status or stats. Split out, like the standing traffic below, + /// only to keep `handle_message`'s match readable. + fn handle_social_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::RoomListReceived(rooms) => { + self.forward_room_event(RoomEvent::List(rooms)); + } + ServerMessage::UserStatusReceived { + username, + status, + privileged, + } => { + self.forward_to_client(ClientOperation::UserStatusReceived { + username, + status, + privileged, + }); + } + ServerMessage::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::RoomJoined { room, users } => { + self.forward_room_event(RoomEvent::Joined { room, users }); + } + ServerMessage::RoomMemberStats { room, stats } => { + self.forward_to_client(ClientOperation::RoomMemberStats { + room, + stats, + }); + } + ServerMessage::RoomLeft { room } => { + self.forward_room_event(RoomEvent::Left { room }); + } + ServerMessage::RoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::Message { + room, + username, + message, + }); + } + ServerMessage::RoomUserJoined { room, username } => { + self.forward_room_event(RoomEvent::UserJoined { + room, + username, + }); + } + ServerMessage::RoomUserLeft { room, username } => { + self.forward_room_event(RoomEvent::UserLeft { room, username }); + } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } + other => self.handle_standing_message(other), + } + } + + /// The wishlist and privilege traffic: standing searches (codes 103/104) and + /// who is privileged (codes 69/92). + /// + /// Split out only because it keeps `handle_message`'s match to a readable + /// length; there is no behaviour here beyond dispatch. + fn handle_standing_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::PossibleParents(candidates) => { + self.forward_to_client(ClientOperation::PossibleParents( + candidates, + )); + } + ServerMessage::ResetDistributed => { + self.forward_to_client(ClientOperation::ResetDistributed); + } + ServerMessage::WishlistSearch { token, query } => { + self.queue_message(MessageFactory::build_wishlist_search( + token, &query, + )); + } + ServerMessage::WishlistInterval(seconds) => { + self.forward_to_client(ClientOperation::WishlistInterval( + seconds, + )); + } + ServerMessage::PrivilegedUsers(users) => { + self.forward_to_client(ClientOperation::PrivilegedUsers(users)); + } + ServerMessage::OwnPrivileges(seconds) => { + self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); + } + ServerMessage::CheckPrivileges => { + self.queue_message(MessageFactory::build_check_privileges()); + } + message @ (ServerMessage::RecommendationsReceived { .. } + | ServerMessage::GlobalRecommendationsReceived { + .. + } + | ServerMessage::ItemRecommendationsReceived { + .. + } + | ServerMessage::SimilarUsersReceived { .. } + | ServerMessage::ItemSimilarUsersReceived { .. } + | ServerMessage::UserInterestsReceived(..)) => { + self.handle_taste_message(message); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } + other => { + error!("[server] unroutable message: {:?}", other); + } + } + } + + /// What the server says about taste: the recommendation and similar-user + /// answers, and a user's interests. Split off `handle_standing_message` + /// only to keep that dispatch readable — these arms share nothing with + /// the rest of it. + fn handle_taste_message(&self, message: ServerMessage) { + match message { + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + // `handle_standing_message` routes only the variants above here. + _ => {} + } + } + + fn handle_connect_to_peer(&self, peer: Peer) { + if let Some(op) = match peer.connection_type { + ConnectionType::P | ConnectionType::F => { + Some(ClientOperation::ConnectToPeer(peer)) + } + ConnectionType::D => None, + } && let Err(e) = self.client_channel.send(op) + { + error!("[server] failed to send ConnectToPeer: {}", e); + } + } + + pub(super) fn handle_login_status(&mut self, message: bool) { + // Send the post-login handshake exactly once, only on success, + // on the live path (the old ServerActor::login did this but was + // never called). Advertises real shared counts and, when + // listening, the port peers must connect to. + if message { + for msg in post_login_messages( + self.enable_listen, + self.listen_port, + self.shared_folder_count, + self.shared_file_count, + ) { + self.send_message(msg); + } + self.session.clear(); + // The distributed stance is the leaf's to announce, and a new + // session starts without a parent. + self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); + } + match self.context.write_safe() { + Ok(mut ctx) => ctx.logged_in = Some(message), + Err(e) => { + error!("[server] LoginStatus write: {}", e); + } + } + } + + fn handle_get_peer_address_response( + &self, + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + ) { + debug!( + "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", + username, host, port, obfuscation_type, obfuscated_port + ); + + if let Err(e) = + self.client_channel + .send(ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }) + { + error!( + "[server] Error forwarding GetPeerAddress response to client: {}", + e + ); + } + } + + /// Hand an operation to the client loop, logging a dead channel rather + /// than unwinding the actor. + fn forward_to_client(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[server] Error forwarding to client: {}", e); + } + } + + fn handle_private_message_received(&self, user_message: UserMessage) { + debug!("[server] Private message from {}", user_message.username()); + if let Err(e) = self + .client_channel + .send(ClientOperation::PrivateMessageReceived(user_message)) + { + error!( + "[server] Error forwarding private message to client: {}", + e + ); + } + } + + pub(super) fn handle_login( + &mut self, + username: String, + password: String, + version: ClientVersion, + response: std::sync::mpsc::Sender>, + ) { + if self.stream.is_none() && !self.initiate_connection() { + let _ = response.send(Err(SoulseekRs::NotConnected)); + return; + } + if let Ok(mut ctx) = self.context.write_safe() { + ctx.logged_in = None; + } + self.queue_message(MessageFactory::build_login_message( + &username, &password, version, + )); + + let start = std::time::Instant::now(); + + let context = self.context.clone(); + std::thread::spawn(move || { + loop { + if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { + let _ = response.send(Err(SoulseekRs::Timeout)); + break; + } + + let logged_in = match context.read_safe() { + Ok(ctx) => ctx.logged_in, + Err(e) => { + let _ = response.send(Err(e)); + break; + } + }; + if let Some(logged_in) = logged_in { + let result = if logged_in { + Ok(true) + } else { + Err(SoulseekRs::AuthenticationFailed) + }; + let _ = response.send(result); + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + } + + fn handle_file_search_request( + &self, + username: String, + token: u32, + query: String, + ) { + if let Err(e) = + self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, + username, + token, + query, + }) + { + error!("[server] forward IncomingSearch: {}", e); + } + } +} diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs new file mode 100644 index 0000000..064ba7e --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -0,0 +1,277 @@ +use super::*; + +fn code_of(message: &Message) -> u32 { + u32::from_le_bytes(message.get_data()[0..4].try_into().unwrap()) +} + +#[test] +fn a_timed_out_connect_parks_the_actor_in_disconnected() { + let mut actor = parked_actor(1); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(), + }; + + actor.tick(); + + assert!( + matches!(actor.connection_state, ConnectionState::Disconnected), + "a timed-out connect must leave Connecting" + ); +} + +#[cfg(unix)] +#[test] +fn the_server_socket_keeps_itself_alive() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut actor = parked_actor(listener.local_addr().unwrap().port()); + + assert!(actor.initiate_connection()); + + assert!( + crate::utils::keepalive::keepalive_enabled( + actor.stream.as_ref().unwrap() + ), + "an idle NAT mapping must be kept alive and a dead path noticed" + ); +} + +fn parked_actor(port: u16) -> ServerActor { + parked_actor_with_client(port).0 +} + +fn parked_actor_with_client( + port: u16, +) -> (ServerActor, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + let actor = ServerActor::new( + PeerAddress::new("127.0.0.1".to_string(), port), + tx, + 0, + false, + 0, + 0, + ); + (actor, rx) +} + +#[test] +fn dropping_the_connection_forgets_the_dispatcher_and_the_verdict() { + let mut actor = parked_actor(1); + actor.initialize_dispatcher(); + actor.connection_state = ConnectionState::Connected; + actor.handle_login_status(true); + + actor.disconnect_with_error(); + + assert_eq!(actor.session.loss(), Some(SessionLoss::Disconnected)); + assert!( + actor.dispatcher_sender.is_none(), + "a login queued now must wait in the buffer, not in a channel \ + the next connection replaces" + ); + assert_eq!( + actor.context.read().unwrap().logged_in, + None, + "the old verdict must not answer the next login" + ); +} + +#[test] +fn disconnect_clears_a_partial_frame_so_the_next_session_reframes_clean() { + use std::io::Write; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).unwrap(); + let (mut server_side, _) = listener.accept().unwrap(); + let partial_frame = [10u8, 0, 0, 0, 1, 2, 3]; + server_side.write_all(&partial_frame).unwrap(); + server_side.flush().unwrap(); + client.set_nonblocking(true).unwrap(); + + let mut actor = parked_actor(addr.port()); + actor.stream = Some(client); + actor.connection_state = ConnectionState::Connected; + for _ in 0..50 { + actor.process_read(); + if actor.reader.buffer_len() > 0 { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + actor.reader.buffer_len() > 0, + "an incomplete frame should be waiting for its rest" + ); + + actor.disconnect(); + + assert_eq!( + actor.reader.buffer_len(), + 0, + "a stale partial frame carried across a reconnect reads the next \ + session's bytes as a length prefix and wedges recovery" + ); +} + +#[test] +fn a_login_on_a_parked_actor_dials_again_and_queues_the_login() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut actor = parked_actor(listener.local_addr().unwrap().port()); + actor.session.record(SessionLoss::Disconnected); + let (response, _verdict) = std::sync::mpsc::channel(); + + actor.handle_login( + "u".into(), + "p".into(), + ClientVersion::default(), + response, + ); + + assert!( + matches!(actor.connection_state, ConnectionState::Connecting { .. }), + "a parked actor must dial again on login" + ); + assert_eq!( + actor.queued_messages.len(), + 1, + "the login waits for the connection to come up" + ); +} + +#[test] +fn a_parked_actor_handles_login_rather_than_queueing_it() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut actor = parked_actor(listener.local_addr().unwrap().port()); + actor.session.record(SessionLoss::Disconnected); + let (response, _verdict) = std::sync::mpsc::channel(); + + actor.handle_message(ServerMessage::Login { + username: "u".into(), + password: "p".into(), + version: ClientVersion::default(), + response, + }); + + assert!( + matches!(actor.connection_state, ConnectionState::Connecting { .. }), + "a parked actor must act on a login, not park it in the queue" + ); + assert!( + actor + .queued_messages + .iter() + .all(|m| !matches!(m, ServerMessage::Login { .. })), + "the login must not be sitting unhandled in the queue" + ); +} + +#[test] +fn a_login_with_nobody_listening_is_refused_at_once() { + let mut actor = parked_actor(1); + let (response, verdict) = std::sync::mpsc::channel(); + + actor.handle_login( + "u".into(), + "p".into(), + ClientVersion::default(), + response, + ); + + assert!( + matches!( + verdict.recv_timeout(Duration::from_secs(5)), + Ok(Err(SoulseekRs::NotConnected)) + ), + "a refused dial must not make the caller wait out the verdict \ + timeout" + ); +} + +#[test] +fn a_cancelled_actor_never_writes_the_login_it_queued() { + use std::io::Read; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).unwrap(); + let (mut server_side, _) = listener.accept().unwrap(); + client.set_nonblocking(true).unwrap(); + + let cancel = CancelHandle::new(); + let mut actor = parked_actor(addr.port()); + actor.set_cancel(cancel.clone()); + actor.stream = Some(client); + actor.connection_state = ConnectionState::Connecting { + since: Instant::now(), + }; + let (response, _verdict) = std::sync::mpsc::channel(); + actor.handle_login( + "u".into(), + "p".into(), + ClientVersion::default(), + response, + ); + assert_eq!(actor.queued_messages.len(), 1); + + cancel.cancel(); + actor.tick(); + assert!(matches!(actor.connection_state, ConnectionState::Connected)); + actor.on_stop(); + + server_side + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut written = Vec::new(); + assert_eq!(server_side.read_to_end(&mut written).unwrap(), 0); +} + +#[test] +fn a_successful_login_marks_the_session_live_again() { + let mut actor = parked_actor(1); + actor.session.record(SessionLoss::Disconnected); + + actor.handle_login_status(true); + + assert_eq!(actor.session.loss(), None); +} + +// A new session starts without a parent: the leaf drops whatever tree it +// hung from and announces its parentless stance from there. +#[test] +fn a_successful_login_resets_the_distributed_leaf() { + let (mut actor, client) = parked_actor_with_client(1); + + actor.handle_login_status(true); + + assert!(matches!( + 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"); +} + +#[test] +fn post_login_messages_carry_counts_and_conditional_wait_port() { + let messages = post_login_messages(true, 4321, 3, 7); + let codes: Vec = messages.iter().map(code_of).collect(); + // SharedFolders; SetStatus; SetWaitPort. + assert_eq!(codes, vec![35, 28, 2]); + + // The SharedFolders message (code 35) carries the real counts. + let shared = messages[0].get_data(); + assert_eq!(u32::from_le_bytes(shared[4..8].try_into().unwrap()), 3); + assert_eq!(u32::from_le_bytes(shared[8..12].try_into().unwrap()), 7); + + // Not listening omits SetWaitPort (code 2). + let no_listen = post_login_messages(false, 4321, 3, 7); + let codes: Vec = no_listen.iter().map(code_of).collect(); + assert_eq!(codes, vec![35, 28]); +} diff --git a/soulseek-rs-lib/src/actor/server_actor/types.rs b/soulseek-rs-lib/src/actor/server_actor/types.rs new file mode 100644 index 0000000..51667c8 --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/types.rs @@ -0,0 +1,99 @@ +//! The values the server actor hands out: where a peer listens, a private +//! message, and the login context. + +#[derive(Debug, Clone)] +pub struct PeerAddress { + host: String, + port: u16, +} + +impl PeerAddress { + #[must_use] + pub const fn new(host: String, port: u16) -> Self { + Self { host, port } + } + + #[must_use] + pub fn get_host(&self) -> &str { + &self.host + } + + #[must_use] + pub const fn get_port(&self) -> u16 { + self.port + } +} + +impl std::fmt::Display for PeerAddress { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}:{}", self.host, self.port) + } +} + +#[derive(Debug, Default)] +pub struct Context { + pub logged_in: Option, +} + +impl Context { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} +#[derive(Debug, Clone)] +pub struct UserMessage { + id: u32, + timestamp: u32, + username: String, + message: String, + new_message: bool, +} +impl UserMessage { + #[must_use] + pub const fn new( + id: u32, + timestamp: u32, + username: String, + message: String, + new_message: bool, + ) -> Self { + Self { + id, + timestamp, + username, + message, + new_message, + } + } + /// The server-assigned id of this message (used to acknowledge it). + #[must_use] + pub const fn id(&self) -> u32 { + self.id + } + + /// Unix timestamp the server recorded for this message. + #[must_use] + pub const fn timestamp(&self) -> u32 { + self.timestamp + } + + /// The username of the sender. + #[must_use] + pub fn username(&self) -> &str { + &self.username + } + + /// The message body. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// Whether the server flagged this as freshly delivered (as opposed to a + /// message replayed because it was queued while the recipient was offline). + #[must_use] + pub const fn is_new(&self) -> bool { + self.new_message + } +} diff --git a/soulseek-rs-lib/src/client/cancel.rs b/soulseek-rs-lib/src/client/cancel.rs new file mode 100644 index 0000000..ba8ebbe --- /dev/null +++ b/soulseek-rs-lib/src/client/cancel.rs @@ -0,0 +1,24 @@ +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +#[derive(Clone)] +pub struct CancelHandle(Arc>); + +impl CancelHandle { + pub(crate) fn new() -> Self { + Self(Arc::new(Mutex::new(false))) + } + + pub fn cancel(&self) { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) = true; + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn live(&self) -> Option> { + let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner); + (!*guard).then_some(guard) + } +} diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs new file mode 100644 index 0000000..e60916d --- /dev/null +++ b/soulseek-rs-lib/src/client/children.rs @@ -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, + min_speed: u32, + ratio: u32, +) -> Option { + 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, + /// 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, + 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 { + let mut names: Vec = 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 { + 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); + } +} diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs new file mode 100644 index 0000000..6ec45e0 --- /dev/null +++ b/soulseek-rs-lib/src/client/connection.rs @@ -0,0 +1,427 @@ +use super::{ + 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; +use std::sync::mpsc::RecvTimeoutError; +use std::time::{Duration, Instant}; + +const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2); +const LOGIN_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// 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. +const LOGIN_RESPONSE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(45); + +/// Soft open-file limit below which a session should expect "too many open +/// files": every peer holds a socket, and one busy search talks to hundreds +/// of them. +const COMFORTABLE_OPEN_FILES: u64 = 1024; + +/// Say up front when the process cannot hold a busy session's sockets, so an +/// operator reads a warning with a number in it instead of an EMFILE storm an +/// hour later. The embedding process owns its rlimits; the library only looks. +#[cfg(unix)] +fn warn_if_open_file_limit_is_low() { + // SAFETY: getrlimit only fills the struct it is handed. + let limit = unsafe { + let mut limit = std::mem::zeroed::(); + if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 { + return; + } + limit.rlim_cur + }; + if limit < COMFORTABLE_OPEN_FILES { + warn!( + "only {limit} open files allowed; a busy session wants \ + {COMFORTABLE_OPEN_FILES} or more — expect \"too many open \ + files\" under load" + ); + } +} + +#[cfg(not(unix))] +fn warn_if_open_file_limit_is_low() {} + +impl Client { + pub fn connect(&mut self) -> Result<()> { + warn_if_open_file_limit_is_low(); + + let (sender, message_reader): ( + Sender, + Receiver, + ) = mpsc::channel(); + + let mut ctx = self.context.write_safe()?; + let peer_registry = PeerRegistry::with_capacity( + ctx.actor_system.clone(), + sender.clone(), + self.username.clone(), + ctx.max_peers.clone(), + ); + ctx.peer_registry = Some(peer_registry); + ctx.operations = Some(sender.clone()); + + let listen_sender = sender.clone(); + + // Scan the shared directories once into the read-only index, and + // report the real folder/file counts to the server on login. + let roots: Vec = self + .shared_directories + .iter() + .filter(|dir| !dir.trim().is_empty()) + .map(std::path::PathBuf::from) + .collect(); + let shares = if roots.is_empty() { + Arc::new(Shares::empty()) + } else { + let scanned = Shares::scan_many(&roots); + info!( + "Sharing {} files in {} folders from {} directories", + scanned.file_count(), + scanned.folder_count(), + roots.len() + ); + Arc::new(scanned) + }; + let shared_folder_count = shares.folder_count(); + let shared_file_count = shares.file_count(); + ctx.shares = shares; + ctx.shared_directories.clone_from(&self.shared_directories); + + if self.cancel.is_cancelled() { + return Err(SoulseekRs::NotConnected); + } + + // Bind before logging in: the port we advertise has to be the port we + // hold, and a bind that fails outright is the caller's to see rather + // than a panic on a thread nobody is watching. + let listener = if self.enable_listen { + let listener = Listen::bind(self.listen_port)?; + self.bound_port = Some(listener.local_addr()?.port()); + Some(listener) + } else { + None + }; + self.listener_stopped = Arc::new(AtomicBool::new(false)); + + let mut server_actor = ServerActor::new( + self.address.clone(), + sender, + self.bound_port.unwrap_or(self.listen_port), + self.enable_listen, + shared_folder_count, + shared_file_count, + ); + server_actor.set_session_watch(self.session.clone()); + server_actor.set_cancel(self.cancel.clone()); + + self.server_handle = Some(ctx.actor_system.spawn_with_handle( + server_actor, + |actor, handle| { + actor.set_self_handle(handle); + }, + )); + + if let Some(listener) = listener { + let client_sender = listen_sender; + let context = self.context.clone(); + let own_username = self.username.clone(); + let stopped = self.listener_stopped.clone(); + let released = Arc::new(AtomicBool::new(false)); + self.listener_released = released.clone(); + + thread::spawn(move || { + Listen::serve( + &listener, + client_sender, + context, + own_username, + stopped, + ); + drop(listener); + released.store(true, Ordering::Relaxed); + }); + } + + Self::listen_to_client_operations( + message_reader, + self.context.clone(), + self.username.clone(), + ); + + 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 free again when this returns, unless + /// the listener cannot be woken within two seconds. + 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() + && TcpStream::connect(("127.0.0.1", port)).is_ok() + { + let deadline = Instant::now() + LISTENER_RELEASE_TIMEOUT; + while !self.listener_released.load(Ordering::Relaxed) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(5)); + } + } + } + + /// Log in and wait for the server's verdict. + /// + /// The wait is bounded: the actor only answers once it has a working + /// connection, so a server that never completes the TCP handshake would + /// otherwise block the caller forever. A caller that hits + /// [`SoulseekRs::Timeout`] can retry or give up, but it always regains + /// control. + pub fn login(&self) -> Result { + info!("Logging in as {}", self.username); + let Some(handle) = &self.server_handle else { + return Err(SoulseekRs::NotConnected); + }; + if self.cancel.is_cancelled() { + return Err(SoulseekRs::NotConnected); + } + let (tx, rx) = std::sync::mpsc::channel(); + let _ = handle.send(ServerMessage::Login { + username: self.username.clone(), + password: self.password.clone(), + version: self.version, + response: tx, + }); + + let deadline = Instant::now() + LOGIN_RESPONSE_TIMEOUT; + loop { + match rx.recv_timeout(LOGIN_POLL_INTERVAL) { + Ok(result) => return result, + Err(RecvTimeoutError::Timeout) + if self.cancel.is_cancelled() => + { + return Err(SoulseekRs::NotConnected); + } + Err(RecvTimeoutError::Timeout) if Instant::now() < deadline => { + } + Err(_) => return Err(SoulseekRs::Timeout), + } + } + } + + /// Ask the server for a peer's address and open a direct control + /// connection to it. Downloads queued for that peer are sent automatically + /// once the connection is established. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn connect_peer(&self, username: &str) -> Result<()> { + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; + handle + .send(ServerMessage::GetPeerAddress(username.to_string())) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + pub fn set_max_peers(&self, max_peers: usize) { + if let Ok(ctx) = self.context.read_safe() { + ctx.max_peers + .store(max_peers.max(1), std::sync::atomic::Ordering::Relaxed); + } + } + + #[allow(dead_code)] + pub fn remove_peer(&self, username: &str) { + let context = match self.context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] remove_peer: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = registry.remove_peer(username) + { + let _ = handle.stop(); + } + } + + pub(crate) fn register_peer_or_fail( + client_context: &Arc>, + peer: Peer, + stream: Option, + reader: Option, + ) -> bool { + let username = peer.username.clone(); + let registered = { + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] register peer read: {}", e); + return false; + } + }; + if let Some(ref registry) = context.peer_registry { + let protected = context.protected_peers(); + match registry + .register_peer_protected(peer, stream, reader, &protected) + { + Ok(_) => true, + Err(e) => { + warn!( + "[client] refusing peer connection for {:?}: {:?}", + username, e + ); + false + } + } + } else { + trace!("PeerRegistry not initialized"); + false + } + }; + if !registered { + Self::fail_queued_downloads(client_context, &username); + } + registered + } + + /// Tell the server we could not reach the peer it asked us to connect to, + /// quoting the token that peer is waiting on. + pub(crate) fn report_cant_connect( + client_context: &Arc>, + token: u32, + username: &str, + ) { + let sender = match client_context.read_safe() { + Ok(ctx) => ctx.server_sender.clone(), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_cant_connect_to_peer( + token, username, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + + pub(crate) fn connect_to_peer( + peer: Peer, + client_context: Arc>, + own_username: String, + stream: Option, + ) { + let peer_clone = peer.clone(); + trace!( + "[client] connecting to {}, with connection_type: {}, and token {:?}", + peer.username, peer.connection_type, peer.token + ); + match peer.connection_type { + ConnectionType::P => { + Self::register_peer_or_fail( + &client_context, + peer_clone, + stream, + None, + ); + } + + ConnectionType::F => { + trace!( + "[client] downloading from: {}, {:?}", + peer.username, peer.token + ); + let Some(token) = peer.token else { + error!( + "[client] F connection from {} without token", + peer.username + ); + return; + }; + let download_peer = DownloadPeer::new( + peer.username, + peer.host, + peer.port, + token, + false, + own_username, + ); + + let brokered = peer_clone.brokered; + let username = peer_clone.username; + match download_peer.download_file( + client_context.clone(), + None, + None, + ) { + Ok((download, filename)) => { + trace!( + "[client] downloaded {} bytes {:?} ", + filename, download.size + ); + } + 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 => { + // ponytail: we never dial out to adopt a child — children + // reach us through the listener. Tell the peer rather than + // leave it waiting; wire the outbound half if firewalled + // children turn out to matter. + if let (true, Some(token)) = (peer.brokered, peer.token) { + Self::report_cant_connect( + &client_context, + token, + &peer.username, + ); + } + } + } + } +} diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs new file mode 100644 index 0000000..9105f8c --- /dev/null +++ b/soulseek-rs-lib/src/client/context.rs @@ -0,0 +1,804 @@ +//! 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, AtomicBool, 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)), + download_speed_limit: Arc::default(), + 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(), + brokered_uploads: 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); + } + // Only a roster the server has sent in full is kept current: the + // joiner hears of their own arrival before the member list. + RoomEvent::UserJoined { room, username } => { + if let Some(members) = self.room_members.get_mut(room) + && let Err(at) = members.binary_search(username) + { + members.insert(at, username.clone()); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(members) = self.room_members.get_mut(room) + && let Ok(at) = members.binary_search(username) + { + members.remove(at); + } + } + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} + } + self.room_events.push(event); + } + + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + /// Returns the stored spelling, or `None` for an empty item — which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !list.contains(&item) { + list.push(item.clone()); + } + Some(item) + } + + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + Some(item) + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + + /// Record a `GetUserStatus` reply, merging it with any statistics already + /// received for that user. + pub fn apply_user_status( + &mut self, + username: String, + status: u32, + privileged: bool, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + + /// Forget what we know about `username`, so the next poll reports the + /// answer to the request being made now rather than the previous one. + pub fn invalidate_user_info(&mut self, username: &str) { + self.user_info.remove(username); + } + + /// Record a `GetUserStats` reply, merging it with any status already + /// received for that user. + pub fn apply_user_stats( + &mut self, + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .stats = Some(UserStats { + average_speed, + shared_files, + shared_folders, + }); + } + + /// Record that we are now watching `username`. + pub fn add_watched_user(&mut self, username: &str) { + self.watched_users.insert(username.to_string()); + } + + /// Forget a watch, dropping any snapshot we held for that user so a later + /// re-watch reports a fresh answer rather than a stale one. + pub fn remove_watched_user(&mut self, username: &str) { + self.watched_users.remove(username); + self.user_info.remove(username); + } + + /// Everyone we are currently watching, sorted by name. + #[must_use] + pub fn watched_users(&self) -> Vec { + let mut users: Vec = + self.watched_users.iter().cloned().collect(); + users.sort(); + users + } + + /// Record the initial snapshot from a `WatchUser` reply. A username the + /// server does not know carries no stats and is dropped from the watch + /// list, since the server will never push anything for it. + pub fn apply_watched_user( + &mut self, + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + ) { + if !exists { + self.watched_users.remove(&username); + return; + } + if let Some(status) = status { + let entry = self + .user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username.clone())); + // The watch reply carries no privileged flag, so keep whatever a + // previous GetUserStatus told us rather than asserting `false`. + let privileged = + entry.presence.as_ref().is_some_and(|p| p.privileged); + entry.presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = + (average_speed, shared_files, shared_folders) + { + self.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + } + } + + /// What the server has said about `username` so far. + #[must_use] + pub fn user_info(&self, username: &str) -> Option { + self.user_info.get(username).cloned() + } + + /// Who is currently in `room`, sorted, or empty when we are not in it. + #[must_use] + pub fn room_members(&self, room: &str) -> Vec { + self.room_members.get(room).cloned().unwrap_or_default() + } + + /// Record the per-member statistics of a room we just joined, replacing + /// any previous snapshot for it. + pub fn apply_room_member_stats( + &mut self, + room: String, + stats: Vec, + ) { + self.room_member_stats.insert(room, stats); + } + + /// What the server reported about the members of `room`, sorted by name, + /// or empty for a room we have not joined. + #[must_use] + pub fn room_member_stats(&self, room: &str) -> Vec { + let mut stats = self + .room_member_stats + .get(room) + .cloned() + .unwrap_or_default(); + stats.sort_by(|a, b| a.username.cmp(&b.username)); + stats + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + + /// Cache a peer's listen address learned from a GetPeerAddress response. + pub fn cache_peer_address( + &mut self, + username: &str, + host: String, + port: u32, + ) { + self.peer_addresses + .insert(username.to_string(), (host, port)); + } + + /// The cached listen address for `username`, if known. + #[must_use] + pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { + self.peer_addresses.get(username).cloned() + } + + /// Queue a peer message to send once a control connection to `username` is up. + pub fn queue_peer_message( + &mut self, + username: &str, + message: crate::message::Message, + ) { + let entry = self + .pending_peer_messages + .entry(username.to_string()) + .or_insert_with(|| (Instant::now(), Vec::new())); + entry.0 = Instant::now(); + entry.1.push(message); + } + + /// Remove and return the messages queued for `username`. + pub fn take_peer_messages( + &mut self, + username: &str, + ) -> Vec { + self.pending_peer_messages + .remove(username) + .map(|(_, messages)| messages) + .unwrap_or_default() + } + + /// Drop peer messages nobody could deliver within + /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. + pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { + self.pending_peer_messages.retain(|_, (since, _)| { + now.duration_since(*since) < PENDING_PEER_TTL + }); + } + + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.pending_browses.remove(&username); + self.browse_results.insert(username, directories); + } + + pub(crate) fn mark_browse_pending(&mut self, username: &str) { + let now = Instant::now(); + self.pending_browses.retain(|_, deadline| *deadline > now); + self.pending_browses + .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + + /// Remember that a server-brokered connection to `username` is pending under + /// `token`; the peer will quote it back in a PierceFirewall. + pub fn add_pending_connect(&mut self, token: u32, username: String) { + self.pending_connect_tokens + .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); + } + + /// Resolve and consume the peer expected for a brokered connection `token`. + pub fn take_pending_connect(&mut self, token: u32) -> Option { + self.pending_connect_tokens + .remove(&token) + .map(|(username, _)| username) + } + + /// Returns the claim flag the dialling route races against. + pub(crate) fn add_brokered_upload( + &mut self, + connect_token: u32, + token: u32, + real_path: std::path::PathBuf, + ) -> Arc { + let claimed = Arc::new(AtomicBool::new(false)); + self.brokered_uploads.insert( + connect_token, + super::BrokeredUpload { + token, + real_path, + deadline: Instant::now() + BROKER_CONNECT_TIMEOUT, + claimed: claimed.clone(), + }, + ); + claimed + } + + pub(crate) fn take_brokered_upload( + &mut self, + connect_token: u32, + ) -> Option { + self.brokered_uploads.remove(&connect_token) + } + + /// Uploads whose downloader never connected back in time. + pub(crate) fn take_expired_brokered_uploads( + &mut self, + now: Instant, + ) -> Vec { + let (expired, live) = std::mem::take(&mut self.brokered_uploads) + .into_iter() + .partition(|(_, upload)| now >= upload.deadline); + self.brokered_uploads = live; + expired.into_values().collect() + } + + pub fn take_expired_connects(&mut self, now: Instant) -> Vec { + let mut expired = Vec::new(); + self.pending_connect_tokens + .retain(|_, (username, deadline)| { + if now >= *deadline { + expired.push(std::mem::take(username)); + return false; + } + true + }); + expired + } + + pub(crate) fn protected_peers(&self) -> HashSet { + let mut protected: HashSet = self + .downloads + .list() + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Queued + | DownloadStatus::InProgress { .. } + | DownloadStatus::Paused { .. } + ) + }) + .map(|d| d.username.clone()) + .collect(); + protected + .extend(self.active_uploads.values().map(|u| u.username.clone())); + protected.extend(self.uploads.values().map(|j| j.downloader.clone())); + protected + .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); + protected.extend(self.pending_serves.keys().cloned()); + protected.extend(self.pending_peer_messages.keys().cloned()); + let now = Instant::now(); + protected.extend( + self.pending_browses + .iter() + .filter(|(_, deadline)| **deadline > now) + .map(|(username, _)| username.clone()), + ); + protected + } + + /// Record a private message received from another user. + pub fn push_private_message(&mut self, message: UserMessage) { + self.private_messages.push(message); + } + + /// Remove and return all buffered private messages. + pub fn take_private_messages(&mut self) -> Vec { + std::mem::take(&mut self.private_messages) + } +} diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs new file mode 100644 index 0000000..98344cf --- /dev/null +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -0,0 +1,712 @@ +//! Our place in the distributed search network, as a leaf: which parent we +//! hang from, which candidates we are still sounding out, and what to tell the +//! server when that changes. + +use super::{Client, ClientOperation, Result, ServerMessage}; +use crate::message::MessageReader; +use crate::message::distributed::{self, Distributed}; +use crate::message::server::MessageFactory; +use crate::peer::ConnectionType; +use crate::utils::lock::RwLockExt; +use std::collections::HashMap; +use std::io::{self, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::time::{Duration, Instant}; + +/// A candidate that has not answered the dial in this long is not a parent. +const LINK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How often an idle link looks at its cancel flag. +const CANCEL_POLL: Duration = Duration::from_secs(1); +/// A parent relays the network's whole search stream; one that says nothing +/// for this long is gone, and holding its slot would block every later +/// candidate. +const LINK_IDLE: Duration = Duration::from_mins(1); +/// Moves of our branch are told to the server at most this often; a parent +/// that flaps its level must not make us flood the server. +const MOVE_INTERVAL: Duration = Duration::from_secs(1); +/// A distributed frame is a search or a branch fact; anything bigger is not +/// one of ours. +const MAX_FRAME: usize = 64 * 1024; +/// The server offers at most ten parents at a time; more candidates than +/// that in flight is a server that keeps offering, and each one is a thread. +const MAX_CANDIDATES: usize = 10; +/// Searches admitted from the parent per second. Every search costs a scan +/// of everything we share, and the tree carries the whole network's stream. +// ponytail: a flat budget; a word index over the shares is the upgrade path. +const SEARCH_BUDGET: u32 = 50; + +/// Where we sit in the tree, as told to the server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Branch { + pub root: String, + pub level: u32, +} + +impl Branch { + #[must_use] + pub fn parentless(own: &str) -> Self { + Self::under(own, 0) + } + + #[must_use] + pub fn under(root: &str, level: u32) -> Self { + Self { + root: root.to_string(), + level, + } + } +} + +struct Candidate { + link: u64, + level: Option, + root: Option, + cancel: Arc, +} + +/// A candidate to dial: the link number that tags everything it sends, and +/// the flag that tells its thread to stop. +pub struct Dial { + pub link: u64, + pub username: String, + pub host: String, + pub port: u16, + pub cancel: Arc, +} + +/// The leaf's view: at most one parent, adopted from the candidates the +/// server offered, the way Nicotine+ does it. Every method is a pure state +/// change so the rule is testable without a socket. Events carry the link +/// number they came from, so a stale thread cannot speak for a newer link +/// to the same user. +pub struct Leaf { + own: String, + parent: Option, + branch: Branch, + candidates: HashMap, + next_link: u64, + searches: (Instant, u32), + /// When we last told the server we moved, and whether a move since then + /// is still waiting to be told. + moved: Option, + move_pending: bool, +} + +impl Leaf { + #[must_use] + pub fn new(own: &str) -> Self { + Self { + own: own.to_string(), + parent: None, + branch: Branch::parentless(own), + candidates: HashMap::new(), + next_link: 1, + searches: (Instant::now(), 0), + moved: None, + move_pending: false, + } + } + + #[must_use] + pub fn branch(&self) -> Branch { + self.branch.clone() + } + + /// Whether `link` is the live link to our parent. + #[must_use] + pub fn is_parent(&self, username: &str, link: u64) -> bool { + self.parent.as_deref() == Some(username) + && self.has_link(username, link) + } + + /// New candidates to dial: those we are not already sounding out, up to + /// the cap, and none at all while we hang from a parent. + pub fn consider( + &mut self, + candidates: Vec<(String, String, u16)>, + ) -> Vec { + if self.parent.is_some() { + return Vec::new(); + } + let mut dials = Vec::new(); + for (username, host, port) in candidates { + if self.candidates.contains_key(&username) + || self.candidates.len() >= MAX_CANDIDATES + { + continue; + } + let link = self.next_link; + self.next_link += 1; + let cancel = Arc::new(AtomicBool::new(false)); + self.candidates.insert( + username.clone(), + Candidate { + link, + level: None, + root: None, + cancel: cancel.clone(), + }, + ); + dials.push(Dial { + link, + username, + host, + port, + cancel, + }); + } + dials + } + + /// A candidate said how deep it sits. A branch root does not always say + /// so separately: level 0 is its own root. A negative level is nonsense + /// and leaves the candidate unadoptable. When it is the parent talking, + /// our own place moves with it and the new stance comes back. + pub fn branch_level( + &mut self, + username: &str, + link: u64, + level: i32, + ) -> Option { + if !self.has_link(username, link) { + return None; + } + let candidate = self.candidates.get_mut(username)?; + candidate.level = u32::try_from(level).ok(); + if level == 0 { + candidate.root = Some(username.to_string()); + } + self.moved_with_parent(username) + } + + pub fn branch_root( + &mut self, + username: &str, + link: u64, + root: &str, + ) -> Option { + if !self.has_link(username, link) { + return None; + } + self.candidates.get_mut(username)?.root = Some(root.to_string()); + self.moved_with_parent(username) + } + + /// A search came down from `username`. The first one from a candidate + /// that has told us its place adopts it; the stance to announce comes back. + pub fn search_from(&mut self, username: &str, link: u64) -> Option { + if self.parent.is_some() || !self.has_link(username, link) { + return None; + } + let candidate = self.candidates.get(username)?; + let (Some(level), Some(root)) = + (candidate.level, candidate.root.clone()) + else { + return None; + }; + let parent = self.candidates.remove(username)?; + self.cancel_candidates(); + self.candidates.insert(username.to_string(), parent); + self.parent = Some(username.to_string()); + self.branch = Branch::under(&root, level.saturating_add(1)); + Some(self.branch.clone()) + } + + /// Whether to answer one more search from the parent now: at most + /// `SEARCH_BUDGET` a second, the rest dropped. + pub fn admit_search(&mut self, now: Instant) -> bool { + if now.duration_since(self.searches.0) >= Duration::from_secs(1) { + self.searches = (now, 0); + } + self.searches.1 += 1; + self.searches.1 <= SEARCH_BUDGET + } + + /// The link to `username` closed. True when that was the parent, which + /// puts us back to parentless. + pub fn closed(&mut self, username: &str, link: u64) -> bool { + if !self.has_link(username, link) { + return false; + } + self.candidates.remove(username); + if self.parent.as_deref() != Some(username) { + return false; + } + self.parent = None; + self.branch = Branch::parentless(&self.own); + true + } + + pub fn reset(&mut self) { + self.cancel_candidates(); + self.parent = None; + self.branch = Branch::parentless(&self.own); + } + + fn has_link(&self, username: &str, link: u64) -> bool { + self.candidates + .get(username) + .is_some_and(|c| c.link == link) + } + + fn moved_with_parent(&mut self, username: &str) -> Option { + if self.parent.as_deref() != Some(username) { + return None; + } + let candidate = self.candidates.get(username)?; + let branch = Branch::under( + candidate.root.as_deref()?, + candidate.level?.saturating_add(1), + ); + if branch == self.branch { + return None; + } + self.branch = branch; + self.move_pending = true; + self.due_announcement(Instant::now()) + } + + /// The branch to tell the server about, if a move is waiting and the + /// last one was told at least [`MOVE_INTERVAL`] ago. + pub fn due_announcement(&mut self, now: Instant) -> Option { + if !self.move_pending + || self + .moved + .is_some_and(|at| now.duration_since(at) < MOVE_INTERVAL) + { + return None; + } + self.move_pending = false; + self.moved = Some(now); + Some(self.branch.clone()) + } + + fn cancel_candidates(&mut self) { + for candidate in self.candidates.values() { + candidate.cancel.store(true, Ordering::Relaxed); + } + self.candidates.clear(); + } +} + +/// Tell the server where we sit now. +pub fn announce( + server: Option<&Sender>, + branch: &Branch, + has_parent: bool, + accept_children: bool, +) { + let Some(server) = server else { + return; + }; + for message in distributed::stance( + &branch.root, + branch.level, + has_parent, + 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) { + let closed = ClientOperation::ParentClosed { + parent: dial.username.clone(), + link: dial.link, + }; + let thread_ops = ops.clone(); + let spawned = std::thread::Builder::new() + .name("soulseek-parent".to_string()) + .spawn(move || { + if let Err(e) = link(&dial, &own, &thread_ops) + && !dial.cancel.load(Ordering::Relaxed) + { + crate::debug!( + "[distributed] link to {} ended: {}", + dial.username, + e + ); + let _ = thread_ops.send(ClientOperation::ParentClosed { + parent: dial.username, + link: dial.link, + }); + } + }); + if spawned.is_err() { + let _ = ops.send(closed); + } +} + +fn link( + dial: &Dial, + own: &str, + ops: &Sender, +) -> io::Result<()> { + let addr = (dial.host.as_str(), dial.port) + .to_socket_addrs()? + .next() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no address") + })?; + let mut stream = TcpStream::connect_timeout(&addr, LINK_CONNECT_TIMEOUT)?; + if dial.cancel.load(Ordering::Relaxed) { + return Ok(()); + } + stream.set_read_timeout(Some(CANCEL_POLL))?; + stream.write_all( + &MessageFactory::build_peer_init_message(own, ConnectionType::D, 0) + .get_buffer(), + )?; + let mut reader = MessageReader::new(); + let mut heard = Instant::now(); + loop { + if dial.cancel.load(Ordering::Relaxed) { + return Ok(()); + } + match reader.read_from_socket(&mut stream) { + Ok(()) => heard = Instant::now(), + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + if heard.elapsed() > LINK_IDLE { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "silent parent", + )); + } + continue; + } + Err(e) => return Err(e), + } + while let Some(mut frame) = reader.extract_message()? { + let Some(operation) = operation_of(dial, &mut frame) else { + continue; + }; + if ops.send(operation).is_err() { + return Ok(()); + } + } + if reader.buffer_len() > MAX_FRAME { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oversized frame", + )); + } + } +} + +fn operation_of( + dial: &Dial, + frame: &mut crate::message::Message, +) -> Option { + let parent = dial.username.clone(); + let link = dial.link; + Some(match distributed::parse(frame)? { + Distributed::BranchLevel(level) => ClientOperation::ParentBranchLevel { + parent, + link, + level, + }, + Distributed::BranchRoot(root) => { + ClientOperation::ParentBranchRoot { parent, link, root } + } + Distributed::Search { + username, + token, + query, + } => ClientOperation::ParentSearch { + parent, + link, + username, + token, + query, + }, + }) +} + +impl Client { + /// Offer parent candidates as the server does with PossibleParents: + /// `(username, host, port)`. The first candidate to send a search after + /// telling us its branch becomes our parent. + /// + /// # Errors + /// Returns [`crate::SoulseekRs::NotConnected`] before [`Client::connect`]. + pub fn consider_parents( + &self, + candidates: Vec<(String, String, u16)>, + ) -> Result<()> { + let ops = self + .context + .read_safe()? + .operations + .clone() + .ok_or(crate::SoulseekRs::NotConnected)?; + ops.send(ClientOperation::PossibleParents(candidates)) + .map_err(|_| crate::SoulseekRs::NotConnected) + } + + /// Where we sit in the distributed search network: the branch root's name + /// and our level, which is ourselves at 0 while we have no parent. + #[must_use] + pub fn distributed_branch(&self) -> (String, u32) { + self.context.read_safe().map_or_else( + |_| (self.username.clone(), 0), + |ctx| { + let branch = ctx.leaf.branch(); + (branch.root, branch.level) + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn leaf() -> Leaf { + Leaf::new("me") + } + + fn candidate(name: &str) -> (String, String, u16) { + (name.to_string(), "127.0.0.1".to_string(), 2234) + } + + /// Dial the candidates and return their link numbers by name. + fn dial(leaf: &mut Leaf, names: &[&str]) -> HashMap { + leaf.consider(names.iter().map(|n| candidate(n)).collect()) + .into_iter() + .map(|d| (d.username.clone(), d)) + .collect() + } + + #[test] + fn a_leaf_without_a_parent_is_its_own_root_at_level_zero() { + assert_eq!(leaf().branch(), Branch::parentless("me")); + } + + #[test] + fn candidates_are_dialled_only_while_there_is_no_parent() { + let mut leaf = leaf(); + let dials = dial(&mut leaf, &["alice", "bob"]); + assert_eq!(dials.len(), 2); + let alice = dials["alice"].link; + leaf.branch_level("alice", alice, 1); + leaf.branch_root("alice", alice, "rooty"); + assert!( + leaf.search_from("alice", alice).is_some(), + "alice becomes the parent" + ); + assert!(leaf.consider(vec![candidate("carol")]).is_empty()); + } + + #[test] + fn the_first_search_from_a_candidate_with_branch_info_adopts_it() { + let mut leaf = leaf(); + let dials = dial(&mut leaf, &["alice", "bob"]); + let (alice, bob) = (dials["alice"].link, dials["bob"].link); + assert_eq!(leaf.search_from("alice", alice), None, "no branch yet"); + leaf.branch_level("alice", alice, 3); + assert_eq!(leaf.search_from("alice", alice), None, "level, no root"); + leaf.branch_root("alice", alice, "rooty"); + assert_eq!( + leaf.search_from("alice", alice), + Some(Branch::under("rooty", 4)), + "level 3 parent puts us at level 4" + ); + assert_eq!(leaf.branch(), Branch::under("rooty", 4)); + assert!(leaf.is_parent("alice", alice)); + assert_eq!(leaf.search_from("alice", alice), None, "already parent"); + assert_eq!(leaf.search_from("bob", bob), None, "a loser is ignored"); + assert!( + dials["bob"].cancel.load(Ordering::Relaxed), + "the losing link is told to stop" + ); + assert!(!dials["alice"].cancel.load(Ordering::Relaxed)); + } + + #[test] + fn a_level_zero_candidate_is_its_own_root() { + let mut leaf = leaf(); + let alice = dial(&mut leaf, &["alice"])["alice"].link; + leaf.branch_level("alice", alice, 0); + assert_eq!( + leaf.search_from("alice", alice), + Some(Branch::under("alice", 1)) + ); + } + + #[test] + fn a_hostile_branch_level_neither_panics_nor_adopts() { + let mut leaf = leaf(); + let alice = dial(&mut leaf, &["alice"])["alice"].link; + leaf.branch_root("alice", alice, "rooty"); + leaf.branch_level("alice", alice, -1); + assert_eq!(leaf.search_from("alice", alice), None, "negative level"); + leaf.branch_level("alice", alice, i32::MAX); + assert_eq!( + leaf.search_from("alice", alice), + Some(Branch::under("rooty", 1 << 31)) + ); + } + + #[test] + fn the_parents_later_moves_move_us_too_but_are_told_once_a_second() { + let mut leaf = leaf(); + let alice = dial(&mut leaf, &["alice"])["alice"].link; + leaf.branch_level("alice", alice, 0); + leaf.search_from("alice", alice); + assert_eq!(leaf.branch_level("alice", alice, 0), None, "unchanged"); + assert_eq!( + leaf.branch_root("alice", alice, "grand"), + Some(Branch::under("grand", 1)) + ); + assert_eq!( + leaf.branch_level("alice", alice, 2), + None, + "a second move within the second waits" + ); + assert_eq!(leaf.branch(), Branch::under("grand", 3), "but it took"); + assert_eq!(leaf.due_announcement(Instant::now()), None); + assert_eq!( + leaf.due_announcement(Instant::now() + MOVE_INTERVAL), + Some(Branch::under("grand", 3)) + ); + assert_eq!( + leaf.due_announcement(Instant::now() + MOVE_INTERVAL), + None, + "told once" + ); + } + + #[test] + fn a_parent_that_becomes_a_root_is_the_root_from_then_on() { + let mut leaf = leaf(); + let alice = dial(&mut leaf, &["alice"])["alice"].link; + leaf.branch_root("alice", alice, "grand"); + leaf.branch_level("alice", alice, 5); + assert_eq!( + leaf.search_from("alice", alice), + Some(Branch::under("grand", 6)) + ); + assert_eq!( + leaf.branch_level("alice", alice, 0), + Some(Branch::under("alice", 1)) + ); + } + + #[test] + fn a_stale_link_cannot_speak_for_a_newer_one() { + let mut leaf = leaf(); + let old = dial(&mut leaf, &["alice"])["alice"].link; + assert!(!leaf.closed("alice", old + 7), "unknown link is ignored"); + leaf.closed("alice", old); + let new = dial(&mut leaf, &["alice"])["alice"].link; + assert_ne!(old, new); + leaf.branch_level("alice", old, 0); + assert_eq!(leaf.search_from("alice", old), None, "old link ignored"); + assert!( + !leaf.closed("alice", old), + "a late close leaves the new one" + ); + leaf.branch_level("alice", new, 0); + assert!(leaf.search_from("alice", new).is_some()); + } + + #[test] + fn a_later_batch_keeps_the_candidates_in_flight_and_stays_under_the_cap() { + let mut leaf = leaf(); + let first = dial(&mut leaf, &["alice"])["alice"].link; + let again = dial(&mut leaf, &["alice", "bob"]); + assert!( + !again.contains_key("alice"), + "alice is already being dialled" + ); + assert!(again.contains_key("bob")); + assert!(leaf.has_link("alice", first)); + let names: Vec = (0..20).map(|i| format!("p{i}")).collect(); + let more = leaf + .consider(names.iter().map(|n| candidate(n)).collect::>()); + assert_eq!(more.len(), MAX_CANDIDATES - 2); + } + + #[test] + fn losing_the_parent_or_a_reset_returns_to_parentless() { + let mut leaf = leaf(); + let dials = dial(&mut leaf, &["alice", "bob"]); + let alice = dials["alice"].link; + leaf.branch_level("alice", alice, 0); + leaf.search_from("alice", alice); + assert!(!leaf.closed("bob", dials["bob"].link), "not the parent"); + assert!(leaf.closed("alice", alice)); + assert_eq!(leaf.branch(), Branch::parentless("me")); + + let dials = dial(&mut leaf, &["alice", "carol"]); + let alice = dials["alice"].link; + leaf.branch_level("alice", alice, 0); + leaf.search_from("alice", alice); + leaf.reset(); + assert_eq!(leaf.branch(), Branch::parentless("me")); + assert!(dials["alice"].cancel.load(Ordering::Relaxed)); + assert!(dials["carol"].cancel.load(Ordering::Relaxed)); + assert!(!leaf.consider(vec![candidate("bob")]).is_empty()); + } + + #[test] + fn searches_beyond_the_budget_are_dropped_until_the_next_second() { + let mut leaf = leaf(); + let now = Instant::now(); + for _ in 0..SEARCH_BUDGET { + assert!(leaf.admit_search(now)); + } + assert!(!leaf.admit_search(now)); + assert!(leaf.admit_search(now + Duration::from_secs(1))); + } + + #[test] + 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, false); + let mut sent: Vec = Vec::new(); + while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() { + sent.push(m); + } + let codes: Vec = sent + .iter() + .map(|m| u32::from_le_bytes(m.get_data()[..4].try_into().unwrap())) + .collect(); + assert_eq!(codes, vec![71, 127, 126, 100]); + assert_eq!(sent[0].get_data()[4], 0, "HaveNoParent is false"); + let mut root = sent[1].clone(); + root.set_pointer(4); + assert_eq!(root.read_string(), "rooty"); + assert_eq!(&sent[2].get_data()[4..8], &4u32.to_le_bytes()); + assert_eq!(sent[3].get_data()[4], 0, "AcceptChildren is false"); + } +} diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs new file mode 100644 index 0000000..e47db5d --- /dev/null +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -0,0 +1,241 @@ +use super::{ + Arc, Client, ClientContext, Download, DownloadMetadata, DownloadStatus, + Receiver, Result, RwLock, RwLockExt, Sender, error, info, mpsc, + next_download_token, +}; +use crate::peer::part_path_of; + +impl Client { + #[must_use] + pub fn get_all_downloads(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.get_downloads().clone()) + .unwrap_or_default() + } + + pub fn set_download_speed_limit(&self, bytes_per_second: u64) { + if let Ok(ctx) = self.context.read_safe() { + ctx.download_speed_limit.set(bytes_per_second); + } + } + + #[must_use] + pub fn pause_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.pause_by_file(username, filename), + Err(e) => { + error!("[client] pause_download: {}", e); + false + } + } + } + + #[must_use] + pub fn resume_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.resume_by_file(username, filename), + Err(e) => { + error!("[client] resume_download: {}", e); + false + } + } + } + + #[must_use] + pub fn cancel_download(&self, username: &str, filename: &str) -> bool { + let cancelled = match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.cancel_by_file(username, filename), + Err(e) => { + error!("[client] cancel_download: {}", e); + None + } + }; + let Some(download) = cancelled else { + return false; + }; + if let Some(part) = part_path_of(&download) { + let _ = std::fs::remove_file(part); + } + true + } + + #[must_use] + pub fn remove_queued_download( + &self, + username: &str, + filename: &str, + ) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.remove_queued_by_file(username, filename) + } + Err(e) => { + error!("[client] remove_queued_download: {}", e); + false + } + } + } + + /// Remove every download for `username`/`filename` regardless of status. + /// Useful before re-issuing [`Client::download`] for a failed download, so + /// the list does not accumulate dead entries for the same file. + /// + /// Returns whether anything was removed. + #[must_use] + pub fn remove_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.remove_by_file(username, filename), + Err(e) => { + error!("[client] remove_download: {}", e); + false + } + } + } + + pub fn download( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + ) -> Result<(Download, Receiver)> { + self.download_with_metadata( + filename, + username, + size, + download_directory, + DownloadMetadata::default(), + ) + } + + pub fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)> { + info!("[client] Downloading {} from {}", filename, username); + + let token = next_download_token(); + + let (download_sender, download_receiver): ( + Sender, + Receiver, + ) = mpsc::channel(); + + let download = Download { + username: username.clone(), + filename, + token, + size, + download_directory, + status: DownloadStatus::Queued, + sender: download_sender, + queue_position: None, + metadata, + }; + + let mut context = self.context.write_safe()?; + context.add_download(download.clone()); + + // If we already have a control connection to this peer, queue the + // upload immediately. Otherwise open one directly (server GetPeerAddress + // → outbound PeerInit → PeerConnected → the queued upload is flushed). + let peer_registered = context + .peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)); + let queued_now = peer_registered + && context.peer_registry.as_ref().is_some_and(|r| { + r.queue_upload(&username, download.filename.clone()).is_ok() + }); + + drop(context); + + let failed = if peer_registered { + !queued_now + } else { + // No existing connection: initiate one. Only a genuinely + // unconnected client (no server handle) fails outright here. + self.connect_peer(&username).is_err() + }; + + if failed { + let reason = if peer_registered { + "The user declined the download" + } else { + "Could not connect to the user" + }; + let _ = download + .sender + .send(DownloadStatus::Failed(Some(reason.to_string()))); + self.context.write_safe()?.update_download_with_status( + token, + DownloadStatus::Failed(Some(reason.to_string())), + ); + } + + Ok((download, download_receiver)) + } + + /// Fail every still-`Queued` download for `username`, both on the caller's + /// status channel (so a blocked `Receiver` unblocks) and in the store. + pub(crate) fn fail_queued_downloads( + client_context: &Arc>, + username: &str, + ) { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] fail_queued_downloads write: {}", e); + return; + } + }; + let doomed: Vec<(u32, Sender)> = context + .get_downloads() + .iter() + .filter(|d| { + d.username == username + && matches!(d.status, DownloadStatus::Queued) + }) + .map(|d| (d.token, d.sender.clone())) + .collect(); + for (token, sender) in doomed { + let reason = Some("The user went offline".to_string()); + let _ = sender.send(DownloadStatus::Failed(reason.clone())); + context.update_download_with_status( + token, + DownloadStatus::Failed(reason), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::next_download_token; + use std::collections::HashSet; + + // The download store is keyed by token and removes *every* entry matching + // one, so two downloads sharing a token means queueing one silently evicts + // the other — which is what fetching the same filename from two peers does + // if the token depends on the filename. + #[test] + fn download_tokens_are_unique_and_below_the_upload_range() { + let tokens: Vec = + (0..10_000).map(|_| next_download_token()).collect(); + + assert_eq!( + tokens.iter().collect::>().len(), + tokens.len(), + "every download must get its own token" + ); + assert!( + tokens.iter().all(|t| *t < 0x8000_0000), + "download tokens must not reach into the upload range" + ); + } +} diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs new file mode 100644 index 0000000..79f4dda --- /dev/null +++ b/soulseek-rs-lib/src/client/mod.rs @@ -0,0 +1,897 @@ +use crate::actor::ActorHandle; +use crate::actor::server_actor::{ + PeerAddress, ServerActor, ServerMessage, UserMessage, +}; +use crate::download_store::{DownloadStore, collect_failed_tokens}; +use crate::types::{ + ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent, + RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch, + SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, +}; +use crate::utils::logger; +use crate::utils::rate_limit::RateLimit; +use crate::{ + Transfer, + actor::{ActorSystem, peer_registry::PeerRegistry}, + error::{Result, SoulseekRs}, + message::peer::{FileEntry, SharedDirectory, build_file_search_response}, + peer::{ConnectionType, DownloadPeer, Peer, PeerMessage, listen::Listen}, + shares::Shares, + types::{Download, Search, SearchResult}, + utils::lock::RwLockExt, +}; +use std::{ + collections::{HashMap, HashSet}, + net::TcpStream, + sync::{ + Arc, RwLock, + atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}, + mpsc::{self, Receiver, Sender}, + }, + thread::{self, sleep}, + time::{Duration, Instant}, +}; +use upload_queue::QueuedUpload; + +use crate::{debug, error, info, trace, warn}; +const DEFAULT_LISTEN_PORT: u16 = 2234; + +/// What to assume between wishlist searches until the server announces its own. +/// +/// Twelve minutes is what Soulseek servers give an unprivileged account (code +/// 104); guessing lower only gets the searches dropped. +pub const DEFAULT_WISHLIST_INTERVAL: Duration = Duration::from_mins(12); + +/// How many uploads run at once by default. +/// +/// The cap is what makes the queue real — with unlimited slots there is nothing +/// for a privileged peer to jump — but it must not be the thing throttling a +/// modern connection. On Soulseek the per-transfer rate is usually set by the +/// *other* end, so concurrency is what fills an uplink: ten peers at a typical +/// few hundred KiB/s each is a few MiB/s, which a 50 Mbit uplink carries and a +/// pair of slots leaves mostly idle. Ten still divides a modest uplink into +/// shares big enough that no peer times out waiting. +/// +/// Measured on the stress benchmark (64 waiting peers, loopback): two slots +/// took 24.2s, eight took 6.2s, thirty-two took 3.1s. +pub const DEFAULT_UPLOAD_SLOTS: usize = 10; + +/// How long to wait for a server-brokered (firewalled) peer to connect back +/// before giving up and failing the download. Matches the direct-dial timeout. +const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +const DEFAULT_MAX_PEERS: usize = 512; +/// How long a message waits for a control connection to its peer. +const PENDING_PEER_TTL: Duration = Duration::from_mins(1); + +const BROWSE_PROTECT_WINDOW: Duration = Duration::from_mins(5); + +/// Source of non-zero correlation tokens for server-brokered connections. +static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1); + +fn next_connect_token() -> u32 { + NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1) +} + +/// Upload tokens are minted in the high half of the space so they never collide +/// with download tokens, which are always < 2^31. +static NEXT_UPLOAD_TOKEN: AtomicU32 = AtomicU32::new(0x8000_0000); + +fn next_upload_token() -> u32 { + NEXT_UPLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) +} + +/// Source of download tokens, kept in the low half of the space. +/// +/// A counter, not a hash of the filename: the download store is keyed by token +/// and removes every entry matching one, so any two downloads sharing a token +/// destroy each other — and the same filename from two peers is an ordinary +/// thing to queue. +static NEXT_DOWNLOAD_TOKEN: AtomicU32 = AtomicU32::new(1); + +fn next_download_token() -> u32 { + NEXT_DOWNLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) % 0x8000_0000 +} + +/// Source of search tokens. +/// +/// A counter, not a hash of the query: a peer's answer is routed to the first +/// search holding its token, so two queries sharing one would pour results +/// into each other. +static NEXT_SEARCH_TOKEN: AtomicU32 = AtomicU32::new(1); + +fn next_search_token() -> u32 { + NEXT_SEARCH_TOKEN.fetch_add(1, Ordering::Relaxed) +} + +/// A file we have agreed to serve to a peer, awaiting their TransferResponse. +struct UploadJob { + downloader: String, + real_path: std::path::PathBuf, + virtual_path: String, + size: u64, + /// When the offer went out; `None` once the peer has answered it. + offered: Option, +} + +/// An upload waiting for its downloader to connect back to our listener while +/// we also dial them. Whichever socket arrives first claims the transfer. +pub(crate) struct BrokeredUpload { + pub(crate) token: u32, + pub(crate) real_path: std::path::PathBuf, + deadline: Instant, + claimed: Arc, +} + +impl BrokeredUpload { + /// Take the transfer for this route; false if another route already did. + pub(crate) fn claim(&self) -> bool { + self.claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} + +/// Live bookkeeping for an upload being served (or recently finished). +struct ActiveUpload { + username: String, + filename: String, + size: u64, + bytes_sent: Arc, + cancel: Arc, + status: crate::types::UploadStatus, + /// When streaming began, so a snapshot can report a transfer rate. + started: Instant, +} + +/// Transfer rate of an in-progress upload, in bytes per second. Finished, +/// cancelled and failed uploads report zero, matching how a download reports +/// its speed only while running. +/// +/// an average over the whole transfer rather than the rolling window +/// the download path samples. Enough to fill the Speed column; sample a window +/// if the figure ever needs to track sudden stalls. +fn upload_speed( + status: &crate::types::UploadStatus, + bytes_sent: u64, + started: Instant, +) -> f64 { + if !matches!(status, crate::types::UploadStatus::InProgress) { + return 0.0; + } + let elapsed = started.elapsed().as_secs_f64(); + if elapsed <= 0.0 { + return 0.0; + } + bytes_sent as f64 / elapsed +} + +/// 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, + token: u32, + query: &str, + free_slot: bool, + speed: u32, + queue_length: u32, + excluded_phrases: &[String], +) -> Option { + let matches = shares.search(query); + if matches.is_empty() { + return None; + } + // A file whose path carries a phrase the server excludes (code 160) is + // left out of the reply, the way Nicotine+ does it: the exclusion is the + // server policing what travels the search network, and answering with one + // anyway is what it is asking us not to do. + let entries: Vec = matches + .iter() + .filter(|f| { + !path_carries_excluded_phrase(&f.virtual_path, excluded_phrases) + }) + .take(crate::types::MAX_SEARCH_REPLY_FILES) + .map(|f| FileEntry { + name: &f.virtual_path, + size: f.size, + attribs: &f.attributes, + }) + .collect(); + if entries.is_empty() { + return None; + } + Some(build_file_search_response( + own_username, + token, + &entries, + u8::from(free_slot), + speed, + queue_length, + )) +} + +/// 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, + pub password: String, + pub server_address: PeerAddress, + pub enable_listen: bool, + pub listen_port: u16, + /// Directories whose files are shared with (uploaded to) other peers. + /// Empty means nothing is shared. + pub shared_directories: Vec, + /// Whether to serve children in the distributed search network: peers + /// hang from us with a `D` connection and we pass every search we receive + /// down to them. Off by default — it costs a socket and the network's + /// whole search stream per child. + pub accept_children: bool, + /// The version reported to the server on login. Defaults to the + /// soulseek-rs major version with minor version 1 ("unidentified"); + /// clients built on this library should reserve their own minor + /// version (see [`ClientVersion`]). + pub version: ClientVersion, +} + +impl ClientSettings { + pub fn new( + username: impl Into, + password: impl Into, + ) -> Self { + Self { + username: username.into(), + password: password.into(), + ..Default::default() + } + } +} + +impl Default for ClientSettings { + fn default() -> Self { + Self { + username: String::new(), + password: String::new(), + server_address: PeerAddress::new( + "server.slsknet.org".to_string(), + 2416, + ), + enable_listen: true, + listen_port: DEFAULT_LISTEN_PORT, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + } + } +} + +/// The client loop's mailbox. Non-exhaustive for the same reason as +/// [`ServerMessage`]: new protocol coverage adds variants. +#[derive(Debug)] +#[non_exhaustive] +pub enum ClientOperation { + ConnectToPeer(Peer), + SearchResult(SearchResult), + PeerDisconnected(u64, String, Option), + DownloadFromPeer(u32, Peer, bool), + UpdateDownloadTokens(Transfer, String), + GetPeerAddressResponse { + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + }, + UploadFailed(String, String), + PlaceInQueueUpdate { + username: String, + filename: String, + place: u32, + }, + SetServerSender(Sender), + PrivateMessageReceived(UserMessage), + /// The server answered `GetUserStatus` for a user we asked about. + UserStatusReceived { + username: String, + status: u32, + privileged: bool, + }, + /// The server answered `GetUserStats` for a user we asked about. + UserStatsReceived { + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + }, + /// The server answered `WatchUser` for a user we started watching. + WatchedUserReceived { + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + }, + PeerConnected(String), + /// A search distributed to us by the server; reply if our shares match. + IncomingSearch { + 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. + QueueUpload { + requester_key: String, + filename: String, + }, + /// The peer accepted our upload offer for `token`; start streaming. + StartUpload { + token: u32, + }, + /// A peer asked to browse our shared files; send our SharedFileListResponse. + ShareListRequested { + requester_key: String, + }, + /// A peer asked what we say about ourselves; send a UserInfoResponse. + UserInfoRequested { + requester_key: String, + }, + /// A peer asked for one folder of our shares; send a FolderContentsResponse. + FolderContentsRequested { + requester_key: String, + 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, + }, + /// A peer we are browsing returned their shared-file listing. + BrowseResult { + username: String, + directories: Vec, + }, + /// 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 and, + /// when the dial was one the server asked us to make, the token that peer + /// quoted: that dial is answered with a `CantConnectToPeer` instead of a + /// broker request, since the peer is already waiting on the server. + PeerConnectFailed(u64, String, Option), + /// Something happened in the chat-room subsystem (list refreshed, a room + /// joined/left, a message said, a member joined/left). + RoomEvent(RoomEvent), + /// Per-member statistics from a `JoinRoom` reply. + RoomMemberStats { + room: String, + stats: Vec, + }, + /// The server announced how many seconds must pass between wishlist + /// searches. + WishlistInterval(u32), + /// Everyone the server counts as privileged; they sort ahead of others in + /// our upload queue. + PrivilegedUsers(Vec), + /// Seconds of our own privileges left. + OwnPrivileges(u32), + /// A peer asked where their queued file sits. + PlaceInQueueRequested { + requester_key: String, + filename: String, + }, + /// Distributed-network parent candidates, from the server or a host. + 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, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendations { + item: String, + recommendations: Vec, + }, + /// Users the server considers similar to us (code 110). + SimilarUsers(Vec), + /// Users who like one item (code 112). + ItemSimilarUsers { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterests(UserInterests), + /// A peer we asked the server to broker gave up reaching us (code 1001). + CantConnectToPeer { + token: u32, + }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), + /// The minimum upload speed for carrying children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), + /// A parent candidate told us how deep it sits. `link` says which dial + /// to that user is talking. + ParentBranchLevel { + parent: String, + link: u64, + level: i32, + }, + /// A parent candidate told us whose branch it is on. + ParentBranchRoot { + parent: String, + link: u64, + root: String, + }, + /// A search came down the tree from `parent`. + ParentSearch { + parent: String, + link: u64, + username: String, + token: u32, + query: String, + }, + /// The link to a parent or candidate is gone. + ParentClosed { + parent: String, + link: u64, + }, +} +pub struct ClientContext { + pub peer_registry: Option, + pub downloads: DownloadStore, + server_sender: Option>, + /// Where anything outside the operations loop posts operations. + operations: Option>, + /// Our place in the distributed search network. + leaf: distributed::Leaf, + /// The children hanging from us, when the client serves any. + pub(crate) children: children::Children, + /// The last `AcceptChildren` answer we gave the server, so the same + /// answer is not repeated: it is a standing state, not a heartbeat. + announced_accept_children: Option, + /// The upload speed the server records for us, once it has told us. The + /// child limit is derived from it. + own_average_speed: Option, + /// The speed a parent needs (code 83) and the divisor turning speed into + /// a child count (code 84), as the server announced them. + parent_min_speed: u32, + parent_speed_ratio: u32, + searches: HashMap, + private_messages: Vec, + /// Correlation tokens for server-brokered (firewalled) connections, mapping + /// a token we sent in a ConnectToPeer to the peer we expect back. + pending_connect_tokens: HashMap, + max_peers: Arc, + pub(crate) download_speed_limit: Arc, + /// Files we share with peers (read-only after connect). + pub shares: Arc, + /// The directories the current share index was built from. + pub shared_directories: Vec, + /// Peer listen addresses learned from GetPeerAddress responses. + peer_addresses: HashMap, + /// Peer messages waiting for a control connection to that peer, and + /// when the last was queued: a peer that never answers must not keep + /// them forever. + pending_peer_messages: + HashMap)>, + /// Uploads we have offered, keyed by our transfer token. + uploads: HashMap, + active_uploads: HashMap, + /// Upload tokens waiting for the downloader's address to be resolved. + pending_serves: HashMap>, + /// Uploads whose downloader we could not dial, keyed by the correlation + /// token we sent in `ConnectToPeer(F)`; the peer quotes it back in a + /// `PierceFirewall` and we serve over that socket instead. + brokered_uploads: HashMap, + /// Shared-file listings received from peers we browsed. + browse_results: HashMap>, + /// One-folder listings received from peers, keyed by peer and folder. + folder_contents: HashMap<(String, String), Vec>, + /// What peers said about themselves (peer code 16), keyed by peer. + peer_infos: HashMap, + pending_browses: HashMap, + /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). + room_list: Vec, + /// Chat-room events awaiting consumption by the client/UI. + room_events: Vec, + /// Who is in each room we have joined, kept current from the membership + /// the server sends on join plus the later join/leave events. + room_members: HashMap>, + /// Per-member statistics for each joined room, from the stat vectors the + /// server sends alongside the membership list. + room_member_stats: HashMap>, + /// Who may enter each private room we belong to (code 133), kept current + /// from the later roster changes. + private_room_members: HashMap>, + /// Who runs each of those rooms (code 148). + private_room_operators: HashMap>, + /// The ticker board of each joined room, kept current from the board sent + /// on join (code 113) and the later add/remove events (114/115). + room_tickers: HashMap>, + /// The latest recommendations from our own interests (code 54), as + /// (recommended, recommended-against). + recommendations: Option<(Vec, Vec)>, + /// The latest server-wide recommendations (code 56). + global_recommendations: Option<(Vec, Vec)>, + /// Per-item recommendations (code 111), keyed by the item asked about. + item_recommendations: HashMap>, + /// The latest similar-user answer (code 110). + similar_users: Vec, + /// Who likes an item (code 112), keyed by the item asked about. + item_similar_users: HashMap>, + /// Interests of other users (code 57), keyed by username. + user_interests: HashMap, + /// What 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, + /// Users we asked the server to watch (code 5), so it keeps pushing their + /// status changes. Kept so a UI can render the watch list and so an + /// unwatch can be rejected for someone we never watched. + watched_users: HashSet, + /// Seconds the server wants between wishlist searches (code 104), once it + /// has told us. + wishlist_interval: Option, + /// Everyone the server listed as privileged (code 69). They sort ahead of + /// other peers in [`Self::upload_queue`]. + privileged_users: HashSet, + /// Seconds of our own privileges left (code 92), once we have asked. + own_privileges: Option, + /// Phrases the server excludes from the search network (code 160). Files + /// whose path carries one are left out of the replies we send. + excluded_search_phrases: Vec, + /// Peers waiting for one of our upload slots. + upload_queue: Vec, + /// Arrival counter for the queue's first-come tie-break. + upload_seq: u64, + /// How many uploads may be in flight at once. + upload_slots: usize, + /// Bytes per second of the last completed upload, advertised in search + /// replies; zero until one has finished. + last_upload_speed: u32, + /// Queued-upload states that came and went between two polls of + /// [`Client::uploads`]. A caller sampling that snapshot would otherwise + /// never see a peer that queued and was served inside one poll interval, + /// and "it waited" is exactly the fact a transfer log must not lose. + upload_events: Vec, + actor_system: Arc, +} +/// A roster sorted and de-duplicated, so membership lookups can binary-search +/// it and a repeated name cannot appear twice. +fn sorted_unique(users: &[String]) -> Vec { + let mut users = users.to_vec(); + users.sort(); + users.dedup(); + users +} + +impl Default for ClientContext { + fn default() -> Self { + Self::new() + } +} + +// Thin delegating shims so existing callers (peer/listen, peer/download_peer, +// tests) keep working while download state lives in DownloadStore. +impl ClientContext { + pub fn add_download(&mut self, download: Download) { + self.downloads.add(download); + } + pub fn remove_download(&mut self, token: u32) { + self.downloads.remove(token); + } + #[must_use] + pub fn get_download_by_token(&self, token: u32) -> Option<&Download> { + self.downloads.get_by_token(token) + } + pub fn get_download_by_token_mut( + &mut self, + token: u32, + ) -> Option<&mut Download> { + self.downloads.get_by_token_mut(token) + } + #[must_use] + pub fn get_download_tokens(&self) -> Vec { + self.downloads.tokens() + } + #[must_use] + pub const fn get_downloads(&self) -> &Vec { + self.downloads.list() + } + pub fn update_download_with_status( + &mut self, + token: u32, + status: DownloadStatus, + ) { + self.downloads.update_status(token, status); + } +} + +pub struct Client { + enable_listen: bool, + listen_port: u16, + /// The port the listener actually holds, known once [`Client::connect`] + /// has bound it. + bound_port: Option, + address: PeerAddress, + username: String, + password: String, + version: ClientVersion, + shared_directories: Vec, + server_handle: Option>, + context: Arc>, + session: SessionWatch, + /// Tells the peer listener to stop, so a disconnected client releases the + /// port it bound. + listener_stopped: Arc, + listener_released: Arc, + cancel: CancelHandle, +} + +impl Drop for Client { + fn drop(&mut self) { + self.disconnect(); + } +} + +impl Client { + pub fn new( + username: impl Into, + password: impl Into, + ) -> Self { + Self::with_settings(ClientSettings::new(username, password)) + } + + #[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(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)), + listener_released: Arc::new(AtomicBool::new(false)), + cancel: CancelHandle::new(), + } + } + + /// The children currently hanging from us in the distributed search + /// network, by username. Empty unless the client was built with + /// [`ClientSettings::accept_children`]. + #[must_use] + pub fn children(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.children.usernames(), + Err(e) => { + error!("[client] children: {}", e); + Vec::new() + } + } + } + + /// The username we log in as, for attributing our own messages. + #[must_use] + pub fn username(&self) -> &str { + &self.username + } + + /// The port peers can reach this client on, or `None` when it is not + /// listening (or has not connected yet). This is the port that was really + /// bound, which is not always the one that was configured. + #[must_use] + pub const fn listen_port(&self) -> Option { + self.bound_port + } + + /// Why the server session ended, or `None` while it is alive. + /// + /// A lost session sees nothing on the network, so an empty result set from + /// one says nothing about what the network holds. + #[must_use] + pub fn session_loss(&self) -> Option { + self.session.loss() + } + + #[must_use] + pub fn cancel_handle(&self) -> CancelHandle { + self.cancel.clone() + } + + /// The directories whose files are currently shared with other peers. + #[must_use] + pub fn shared_directories(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shared_directories.clone()) + .unwrap_or_default() + } + + /// The share index as a peer receives it: the same directory listing + /// `GetShareFileList` answers with, so what this returns is what the + /// network sees. + #[must_use] + pub fn shared_listing(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shares.directories()) + .unwrap_or_default() + } + + /// `(folders, files)` currently shared with peers. + #[must_use] + pub fn shared_counts(&self) -> (u32, u32) { + self.context.read_safe().map_or((0, 0), |ctx| { + (ctx.shares.folder_count(), ctx.shares.file_count()) + }) + } + + /// Every upload this session knows about: the ones streaming or finished, + /// followed by the peers still waiting for a slot, in the order they will + /// be served. + #[must_use] + pub fn uploads(&self) -> Vec { + self.context.read_safe().map_or_else( + |_| Vec::new(), + |ctx| { + let mut tokens: Vec<&u32> = ctx.active_uploads.keys().collect(); + tokens.sort_unstable(); + let mut all: Vec = tokens + .into_iter() + .map(|token| { + let upload = &ctx.active_uploads[token]; + let bytes_sent = upload + .bytes_sent + .load(std::sync::atomic::Ordering::Relaxed); + crate::types::UploadInfo { + username: upload.username.clone(), + filename: upload.filename.clone(), + size: upload.size, + bytes_sent, + speed_bytes_per_sec: upload_speed( + &upload.status, + bytes_sent, + upload.started, + ), + status: upload.status.clone(), + } + }) + .collect(); + all.extend(ctx.queued_uploads()); + all + }, + ) + } + + /// Ask an in-progress upload to `username` of `filename` to stop. + /// Returns whether a matching in-progress upload was found. + #[must_use = "returns whether a matching upload was found"] + pub fn cancel_upload(&self, username: &str, filename: &str) -> bool { + self.context.read_safe().is_ok_and(|ctx| { + let mut found = false; + for upload in ctx.active_uploads.values() { + if upload.username == username + && upload.filename == filename + && upload.status == crate::types::UploadStatus::InProgress + { + upload + .cancel + .store(true, std::sync::atomic::Ordering::Relaxed); + found = true; + } + } + found + }) + } + + /// Replace the shared directories at runtime: rescan into a fresh + /// index (served to peers from then on) and re-announce the new + /// folder/file counts to the server. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn set_shared_directories(&self, dirs: Vec) -> Result<()> { + let roots: Vec = dirs + .iter() + .filter(|dir| !dir.trim().is_empty()) + .map(std::path::PathBuf::from) + .collect(); + let shares = if roots.is_empty() { + Shares::empty() + } else { + Shares::scan_many(&roots) + }; + info!( + "Now sharing {} files in {} folders from {} directories", + shares.file_count(), + shares.folder_count(), + roots.len() + ); + let folder_count = shares.folder_count(); + let file_count = shares.file_count(); + { + let mut ctx = self.context.write_safe()?; + ctx.shares = Arc::new(shares); + ctx.shared_directories = dirs; + } + self.send_server_message( + crate::message::server::MessageFactory::build_shared_folders_message( + folder_count, + file_count, + ), + ) + } +} + +mod cancel; +mod children; +mod connection; +mod context; +mod distributed; +mod downloads; +mod operations; +mod rooms; +mod search; +mod social; +mod upload_queue; +mod uploads; + +pub use cancel::CancelHandle; + +#[cfg(test)] +mod tests; diff --git a/soulseek-rs-lib/src/client/operations/mod.rs b/soulseek-rs-lib/src/client/operations/mod.rs new file mode 100644 index 0000000..35f0341 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/mod.rs @@ -0,0 +1,816 @@ +use super::{ + Arc, Client, ClientContext, ClientOperation, ConnectionType, Download, + DownloadPeer, DownloadStatus, Duration, Instant, Peer, PeerMessage, + PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage, + build_search_response, debug, error, info, mpsc, next_connect_token, + thread, trace, warn, +}; +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, + client_context: Arc>, + own_username: String, + ) { + 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 { + last_sweep = Instant::now(); + Self::sweep_expired_connects(&client_context); + Self::sweep_stale_offers(&client_context); + if let Ok(mut ctx) = client_context.write_safe() { + ctx.expire_pending_peer_messages(Instant::now()); + if let Some(branch) = + ctx.leaf.due_announcement(Instant::now()) + { + 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, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + match operation { + ClientOperation::ConnectToPeer(peer) => { + if matches!(peer.connection_type, ConnectionType::F) { + let client_context_clone = client_context.clone(); + let own_username_clone = own_username.clone(); + + thread::spawn(move || { + Self::connect_to_peer( + peer, + client_context_clone, + own_username_clone, + None, + ); + }); + } else { + Self::connect_to_peer( + peer, + client_context.clone(), + own_username.clone(), + None, + ); + } + } + ClientOperation::SearchResult(search_result) => { + trace!("[client] SearchResult {:?}", search_result); + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] SearchResult write: {}", e); + continue; + } + }; + let result_token = search_result.token; + + // Find the search with matching token + for search in context.searches.values_mut() { + if search.token == result_token { + search.accept(search_result); + break; + } + } + } + operation @ (ClientOperation::PeerDisconnected(..) + | ClientOperation::PeerConnectFailed(..) + | ClientOperation::ChildConnected { .. }) => { + Self::on_peers( + operation, + &client_context, + &own_username, + ); + } + operation @ (ClientOperation::DownloadFromPeer(..) + | ClientOperation::UpdateDownloadTokens(..) + | ClientOperation::StartUpload { .. } + | ClientOperation::QueueUpload { .. }) => { + Self::on_transfers( + operation, + &client_context, + &own_username, + ); + } + ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + } => { + debug!( + "Received peer address for {}: {}:{} (obf_type: {}, obf_port: {})", + username, + host, + port, + obfuscation_type, + obfuscated_port + ); + + // Port 0 is the server saying it does not know + // where this user listens (no SetWaitPort yet, + // or firewalled). It is not an address: caching + // it poisons every later lookup, and an upload + // dialled at it dies with "Can't assign + // requested address" and is dropped on the + // floor. Leave those uploads queued for a later + // resolution instead. + // + // The connect attempt further down still runs: + // its failure is exactly what makes an + // unreachable peer fall back to the + // server-brokered path. + if port == 0 { + warn!( + "[client] server reports no listening port for {}", + username + ); + } + let waiting_serves = match client_context.write_safe() { + Ok(mut ctx) => { + if port != 0 { + ctx.cache_peer_address( + &username, + host.clone(), + port, + ); + } + ctx.pending_serves + .remove(&username) + .unwrap_or_default() + } + Err(_) => Vec::new(), + }; + for token in waiting_serves { + Self::spawn_serve( + &client_context, + &own_username, + token, + host.clone(), + port, + ); + } + + let peer_exists = match client_context.read_safe() { + Ok(ctx) => ctx + .peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)), + Err(e) => { + error!( + "[client] GetPeerAddressResponse read: {}", + e + ); + continue; + } + }; + + // Existing peer: skip re-registration. Reconnect + // policy on conflict is intentionally undecided. + if !peer_exists { + let peer = Peer::new( + username, + ConnectionType::P, + host, + port, + None, + 0, + // obfuscation_type is a small enum; a + // real obfuscated_port is a full u16 and + // must not be truncated into a u8 (which + // panicked and took down the ops thread). + u8::try_from(obfuscation_type).unwrap_or(0), + obfuscated_port, + ); + Self::connect_to_peer( + peer, + client_context.clone(), + own_username.clone(), + None, + ); + } + } + ClientOperation::UploadFailed(username, filename) => { + Self::process_failed_uploads( + client_context.clone(), + &username, + Some(&filename), + ); + } + ClientOperation::PlaceInQueueUpdate { + username, + filename, + place, + } => match client_context.write_safe() { + Ok(mut ctx) => { + let updated = ctx.downloads.update_queue_position( + &username, &filename, place, + ); + if !updated { + debug!( + "[client] PlaceInQueueUpdate: no matching download for {}/{}", + username, filename + ); + } + } + Err(e) => { + error!("[client] PlaceInQueueUpdate write: {}", e); + } + }, + ClientOperation::SetServerSender(sender) => { + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.server_sender = Some(sender); + debug!("[client] Server sender initialized"); + } + Err(e) => { + error!("[client] SetServerSender write: {}", e); + } + } + } + ClientOperation::PrivateMessageReceived(user_message) => { + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.push_private_message(user_message); + } + Err(e) => error!( + "[client] PrivateMessageReceived write: {}", + e + ), + } + } + ClientOperation::UserStatusReceived { + username, + status, + privileged, + } => match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_user_status(username, status, privileged); + } + Err(e) => { + error!("[client] UserStatusReceived write: {}", e); + } + }, + ClientOperation::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + } => { + // 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, + status, + average_speed, + shared_files, + shared_folders, + } => { + 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), + Err(e) => error!("[client] RoomEvent write: {}", e), + } + } + ClientOperation::RoomMemberStats { room, stats } => { + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_room_member_stats(room, stats); + } + Err(e) => { + error!("[client] RoomMemberStats write: {}", e); + } + } + } + ClientOperation::WishlistInterval(seconds) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.wishlist_interval = Some(seconds); + } + } + ClientOperation::PeerConnected(username) => { + // A control connection just came up — one we dialled, + // or an inbound one the listener registered. Flush any + // downloads that were queued for this peer while it + // was unreachable. Collect under a read guard, then + // act without it held. + let (registry, files): ( + Option, + Vec, + ) = match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.get_downloads() + .iter() + .filter(|d| { + d.username == username + && matches!( + d.status, + DownloadStatus::Queued + ) + }) + .map(|d| d.filename.clone()) + .collect(), + ), + Err(e) => { + error!("[client] PeerConnected read: {}", e); + continue; + } + }; + // Also flush any peer messages (e.g. search + // responses) queued while connecting. + let queued_messages = client_context + .write_safe() + .map(|mut ctx| ctx.take_peer_messages(&username)) + .unwrap_or_default(); + if let Some(registry) = registry { + for filename in files { + let _ = + registry.queue_upload(&username, filename); + } + for message in queued_messages { + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(message), + ); + } + } + } + operation @ ClientOperation::IncomingSearch { .. } => { + Self::on_search( + operation, + &client_context, + &own_username, + ); + } + ClientOperation::PlaceInQueueRequested { + requester_key, + filename, + } => { + let (registry, place) = match client_context.read_safe() + { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.place_in_queue(&requester_key, &filename), + ), + Err(_) => continue, + }; + // Silence would leave the peer guessing; a file + // no longer queued is being served, which is + // place 0 by the same convention Nicotine+ uses. + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &requester_key, + PeerMessage::SendMessage( + MessageFactory::build_place_in_queue_response( + &filename, + place.unwrap_or(0), + ), + ), + ); + } + } + ClientOperation::PossibleParents(candidates) => { + let (dials, ops) = match client_context.write_safe() { + Ok(mut ctx) => ( + ctx.leaf.consider(candidates), + ctx.operations.clone(), + ), + Err(_) => continue, + }; + let Some(ops) = ops else { continue }; + for dial in dials { + super::distributed::spawn_link( + dial, + own_username.clone(), + ops.clone(), + ); + } + } + operation @ (ClientOperation::ParentSearch { .. } + | ClientOperation::ParentBranchLevel { + .. + } + | ClientOperation::ParentBranchRoot { + .. + } + | ClientOperation::ParentClosed { .. } + | ClientOperation::ResetDistributed) => { + Self::on_tree(operation, &client_context); + } + ClientOperation::PrivilegedUsers(users) => { + if let Ok(mut ctx) = client_context.write_safe() { + debug!( + "[client] {} privileged users listed", + users.len() + ); + ctx.set_privileged_users(users); + } + // Someone already waiting may have just been + // outranked, but a slot that is free should + // still be filled. + Self::pump_upload_queue(&client_context); + } + ClientOperation::OwnPrivileges(seconds) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.own_privileges = Some(seconds); + } + } + ClientOperation::Recommendations { + global, + recommended, + unrecommended, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_recommendations( + global, + recommended, + unrecommended, + ); + } + } + ClientOperation::ItemRecommendations { + item, + recommendations, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_recommendations( + item, + recommendations, + ); + } + } + ClientOperation::SimilarUsers(users) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_similar_users(users); + } + } + ClientOperation::ItemSimilarUsers { item, usernames } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_similar_users(item, usernames); + } + } + ClientOperation::UserInterests(interests) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_user_interests(interests); + } + } + ClientOperation::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(ctx) => { + (ctx.server_sender.clone(), ctx.own_interests()) + } + Err(_) => continue, + }; + let Some(sender) = sender else { continue }; + // Watch ourselves: the server pushes a user's stats + // to whoever watches them, and our own recorded + // upload speed is what the distributed child limit is + // derived from. Nicotine+ reads the same figure from + // the stats the server sends about the logged-in + // user. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_watch_user(&own_username), + )); + for item in &interests.likes { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_like(item), + )); + } + 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, upload) = + match client_context.write_safe() { + Ok(mut ctx) => ( + ctx.take_pending_connect(token), + ctx.take_brokered_upload(token), + ), + Err(e) => { + error!( + "[client] CantConnectToPeer write: {}", + e + ); + (None, None) + } + }; + if let Some(upload) = upload + && upload.claim() + { + Self::fail_upload( + &client_context, + upload.token, + "peer could not connect back", + ); + } + if let Some(username) = username { + debug!( + "[client] {} cannot connect back (token {})", + username, token + ); + Self::fail_queued_downloads( + &client_context, + &username, + ); + } + } + ClientOperation::ShareListRequested { requester_key } => { + Self::reply_to_peer( + &client_context, + &requester_key, + |ctx| { + crate::message::peer::build_shared_file_list( + &ctx.shares.directories(), + ) + }, + ); + } + ClientOperation::UserInfoRequested { requester_key } => { + Self::reply_to_peer( + &client_context, + &requester_key, + |ctx| { + crate::message::peer::build_user_info( + ctx.upload_slots as u32, + ctx.upload_queue.len() as u32, + ctx.has_free_upload_slot(), + ) + }, + ); + } + ClientOperation::FolderContentsRequested { + requester_key, + token, + folder, + } => { + // ponytail: walks the whole share for one folder, as a + // browse does; index by directory if it ever shows up. + Self::reply_to_peer( + &client_context, + &requester_key, + |ctx| { + let dirs: Vec<_> = ctx + .shares + .directories() + .into_iter() + .filter(|dir| dir.name == folder) + .collect(); + crate::message::peer::build_folder_contents( + token, &folder, &dirs, + ) + }, + ); + } + ClientOperation::BrowseResult { + username, + directories, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_browse_result(username, directories); + } + } + } + } + }); + } + + /// Ask every peer holding a queued download of ours where it sits. The + /// answers arrive as `PlaceInQueueResponse` and land on the downloads. + fn poll_queue_positions(client_context: &Arc>) { + let (registry, queued) = match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.get_downloads() + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .map(|d| (d.username.clone(), d.filename.clone())) + .collect::>(), + ), + Err(_) => return, + }; + let Some(registry) = registry else { return }; + for (username, filename) in queued { + // Only over a connection we already hold: a peer we cannot reach + // is a connection problem, not a queue question. + if !registry.contains(&username) { + continue; + } + let request = + MessageFactory::build_place_in_queue_request(&filename); + let _ = registry + .send_to_peer(&username, PeerMessage::SendMessage(request)); + } + } + + /// Tell the server whether we would take another child right now + /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the + /// server only offers us to peers while it is true. + fn announce_child_capacity(client_context: &Arc>) { + let (sender, has_room) = match client_context.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>, + requester_key: &str, + build: impl FnOnce(&ClientContext) -> crate::message::Message, + ) { + let (registry, message) = match client_context.read_safe() { + Ok(ctx) => (ctx.peer_registry.clone(), build(&ctx)), + Err(_) => return, + }; + if let Some(registry) = registry { + let _ = registry + .send_to_peer(requester_key, PeerMessage::SendMessage(message)); + } + } + + fn sweep_stale_offers(client_context: &Arc>) { + let freed = client_context + .write_safe() + .is_ok_and(|mut ctx| ctx.expire_stale_offers(Instant::now())); + if freed { + Self::pump_upload_queue(client_context); + } + } + + fn sweep_expired_connects(client_context: &Arc>) { + let expired = match client_context.write_safe() { + Ok(mut ctx) => ctx.take_expired_connects(Instant::now()), + Err(_) => return, + }; + for username in expired { + Self::fail_queued_downloads(client_context, &username); + } + let expired = match client_context.write_safe() { + Ok(mut ctx) => ctx.take_expired_brokered_uploads(Instant::now()), + Err(_) => return, + }; + for upload in expired.iter().filter(|u| u.claim()) { + Self::fail_upload( + client_context, + upload.token, + "peer never connected back", + ); + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/peers.rs b/soulseek-rs-lib/src/client/operations/peers.rs new file mode 100644 index 0000000..41028d5 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/peers.rs @@ -0,0 +1,196 @@ +//! Dispatch arms about a peer connection appearing or going away. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation, + ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error, + next_connect_token, warn, +}; + +impl Client { + pub(super) fn on_peers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::PeerDisconnected(id, username, error) => { + // Scope the read guard: process_failed_uploads + // below acquires a write lock on the same + // RwLock, which would self-deadlock the entire + // client ops loop if this read guard were still + // held on this thread. Evict only if this exact + // actor still occupies the slot, so a replaced + // actor's shutdown can't remove its successor. + { + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] PeerDisconnected read: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = + registry.remove_peer_if(&username, id) + { + let _ = handle.stop(); + } + } + // Only an error is evidence the peer is gone. A clean + // close — our idle reaper, or a remote client tidying + // an idle socket while it waits in our queue — must + // not throw away everything that peer has queued: the + // connection comes back, the queue cannot. A departed + // peer wedging uploads shut is still covered: its + // erroring transfer fails within the socket deadlines + // and the error branch here frees its slots. + if let Some(error) = error { + warn!( + "[client] Peer {} disconnected with error: {:?}", + username, error + ); + Self::process_failed_uploads( + client_context.clone(), + &username, + None, + ); + Self::release_upload_slots(client_context, &username); + } + } + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context.write_safe() { + Ok(ctx) => { + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads(client_context, &username); + return; + } + + // Direct connect failed: ask the server to + // broker it. Register a correlation token, then + // send ConnectToPeer so the (firewalled) peer + // connects back to our listener quoting it. + let token = next_connect_token(); + let server_sender = match client_context.write_safe() { + Ok(mut ctx) => { + // Reap the dead outbound actor so it + // stops pinning a pool worker and no + // longer shadows the brokered reconnect + // (a stale registry entry would make + // later downloads queue into a dead, + // streamless actor and hang). Identity- + // aware so a newer namesake is untouched. + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.add_pending_connect(token, username.clone()); + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + let Some(sender) = server_sender else { + return; + }; + let msg = crate::message::server::MessageFactory::build_connect_to_peer( + token, + &username, + ConnectionType::P, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if !ctx.children.has_room() { + return; + } + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { + return; + } + if username == own_username { + return; // we cannot hang from ourselves + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(client_context); + } + } + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/search.rs b/soulseek-rs-lib/src/client/operations/search.rs new file mode 100644 index 0000000..526633b --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/search.rs @@ -0,0 +1,118 @@ +//! The dispatch arm that answers a search reaching us from the network. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock, + RwLockExt, ServerMessage, build_search_response, error, +}; + +impl Client { + pub(super) fn on_search( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + let ClientOperation::IncomingSearch { + username, + token, + query, + from_parent, + } = operation + else { + return; + }; + { + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all — + // Nicotine+ refuses them until something feeds it — + // and passing the search down is the whole duty. + let (capacity_changed, answer) = match client_context.write_safe() { + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search(&username, token, &query); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = + !from_parent || ctx.leaf.admit_search(Instant::now()); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + (!was_fed || ctx.children.len() != before, answer) + } + Err(_) => (false, false), + }; + if capacity_changed { + Self::announce_child_capacity(client_context); + } + if !answer { + return; + } + + // Don't answer our own distributed search. + if username == own_username { + return; + } + let response = match client_context.read_safe() { + Ok(ctx) => build_search_response( + &ctx.shares, + &own_username, + token, + &query, + ctx.has_free_upload_slot(), + ctx.last_upload_speed, + ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases, + ), + Err(e) => { + error!("[client] IncomingSearch read: {}", e); + return; + } + }; + let Some(message) = response else { + return; // no matching shares + }; + + // Deliver to the searcher: send now if we have a + // control connection, else open one and queue. + let (connected, registry, server_sender) = + match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)), + ctx.peer_registry.clone(), + ctx.server_sender.clone(), + ), + Err(_) => return, + }; + if connected { + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(message), + ); + } + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.queue_peer_message(&username, message); + } + if let Some(sender) = server_sender { + let _ = + sender.send(ServerMessage::GetPeerAddress(username)); + } + } + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/transfers.rs b/soulseek-rs-lib/src/client/operations/transfers.rs new file mode 100644 index 0000000..d41fd63 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/transfers.rs @@ -0,0 +1,219 @@ +//! Dispatch arms that move bytes: what a peer allows, what it asks of us, +//! and the tokens tying the two together. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Download, DownloadError, + DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock, + RwLockExt, ServerMessage, debug, error, info, thread, trace, +}; + +impl Client { + pub(super) fn on_transfers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::DownloadFromPeer(token, peer, allowed) => { + let maybe_download = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.claim_for_peer(token, &peer.username) + } + Err(e) => { + error!("[client] DownloadFromPeer write: {}", e); + return; + } + }; + trace!( + "[client] DownloadFromPeer token: {} peer: {:?}", + token, peer + ); + let Some(download) = maybe_download else { + debug!( + "[client] transfer token {} is missing, belongs \ + to another peer, or is already claimed; ignoring", + token, + ); + return; + }; + + let client_context = client_context.clone(); + + thread::spawn(move || { + let download_peer = DownloadPeer::new( + download.username.clone(), + peer.host.clone(), + peer.port, + token, + allowed, + own_username, + ); + match download_peer.download_file( + client_context, + Some(download.clone()), + None, + ) { + Ok((download, filename)) => { + info!( + "Successfully downloaded {} bytes to {}", + download.size, filename + ); + } + Err(DownloadError::Cancelled) => {} + Err(e) => { + error!( + "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", + download.filename, + peer.host, + peer.port, + download.token, + e + ); + } + } + }); + } + ClientOperation::UpdateDownloadTokens(transfer, username) => { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] UpdateDownloadTokens write: {}", e); + return; + } + }; + + let download_to_update = + context.get_downloads().iter().find_map(|d| { + if d.username == username + && d.filename == transfer.filename + { + Some((d.token, d.clone())) + } else { + None + } + }); + + let cancelled = + download_to_update.as_ref().is_some_and(|(_, d)| { + matches!(d.status, DownloadStatus::Cancelled) + }); + if !cancelled + && let Some((old_token, download)) = download_to_update + { + trace!( + "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", + transfer + ); + + context.add_download(Download { + token: transfer.token, + size: transfer.size, + ..download + }); + context.remove_download(old_token); + } + + // Only now invite the file connection: it is + // matched by this token, which is recorded as + // of the line above. Answering any earlier + // races the peer's connection against our own + // bookkeeping. + let registry = context.peer_registry.clone(); + drop(context); + if let Some(registry) = registry { + let response = if cancelled { + MessageFactory::build_transfer_denial_message( + transfer.token, + "Cancelled", + ) + } else { + MessageFactory::build_transfer_response_message( + transfer, + ) + }; + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(response), + ); + } + } + ClientOperation::StartUpload { token } => { + // The peer accepted our offer: resolve their + // address (from the code-9 GetPeerAddress) and + // stream the file, or queue until it resolves. + let (job_addr, downloader) = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.mark_offer_answered(token); + let Some(job) = ctx.uploads.get(&token) else { + return; + }; + ( + ctx.peer_address(&job.downloader), + job.downloader.clone(), + ) + } + Err(_) => return, + }; + if let Some((host, port)) = job_addr { + Self::spawn_serve( + client_context, + &own_username, + token, + host, + port, + ); + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.pending_serves + .entry(downloader.clone()) + .or_default() + .push(token); + } + if let Ok(ctx) = client_context.read_safe() + && let Some(sender) = ctx.server_sender.clone() + { + let _ = sender + .send(ServerMessage::GetPeerAddress(downloader)); + } + } + } + ClientOperation::QueueUpload { + requester_key, + filename, + } => { + // The peer served next may not be this one. + match client_context.write_safe() { + Ok(mut ctx) => { + let Some(file) = ctx.shares.get(&filename) else { + debug!( + "[client] QueueUpload for unknown file {}", + filename + ); + return; + }; + let size = file.size; + let real_path = file.real_path.clone(); + ctx.enqueue_upload( + &requester_key, + &filename, + real_path, + size, + ); + } + Err(e) => { + error!("[client] QueueUpload write: {}", e); + return; + } + } + Self::pump_upload_queue(client_context); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/tree.rs b/soulseek-rs-lib/src/client/operations/tree.rs new file mode 100644 index 0000000..68a4d36 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/tree.rs @@ -0,0 +1,103 @@ +//! Dispatch arms for our place in the distributed search tree. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it. + +use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt}; + +impl Client { + pub(super) fn on_tree( + operation: ClientOperation, + client_context: &Arc>, + ) { + match operation { + ClientOperation::ParentSearch { + parent, + link, + username, + token, + query, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.search_from(&parent, link) { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children.broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } + } + } + ClientOperation::ParentBranchLevel { + parent, + link, + level, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = + ctx.leaf.branch_level(&parent, link, level) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentBranchRoot { parent, link, root } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentClosed { parent, link } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if ctx.leaf.closed(&parent, link) { + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + } + // Also what a fresh login means: whatever tree we hung + // from belongs to the old session. + ClientOperation::ResetDistributed => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs new file mode 100644 index 0000000..671ff53 --- /dev/null +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -0,0 +1,308 @@ +use super::{ + Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage, + SharedDirectory, SoulseekRs, UserMessage, error, +}; +use crate::types::{RoomUserStats, UserInfo}; + +impl Client { + /// Send a private message to another user via the server. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn send_private_message( + &self, + username: &str, + message: &str, + ) -> Result<()> { + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; + let msg = crate::message::server::MessageFactory::build_message_user( + username, message, + ); + handle + .send(ServerMessage::SendMessage(msg)) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + /// Send a raw server message via the server actor, mapping a dead channel + /// to [`SoulseekRs::NotConnected`]. + pub(super) fn send_server_message( + &self, + message: crate::message::Message, + ) -> Result<()> { + self.server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)? + .send(ServerMessage::SendMessage(message)) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + /// Ask the server for the list of public chat rooms. The response arrives + /// asynchronously; read it with [`Client::room_list`] or by draining + /// [`Client::take_room_events`] for a [`RoomEvent::List`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn request_room_list(&self) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_room_list_request(), + ) + } + + /// Join a public chat room. The membership list and subsequent messages + /// arrive via [`Client::take_room_events`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn join_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_join_room( + room, false, + ), + ) + } + + /// Leave a chat room previously joined with [`Client::join_room`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn leave_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_leave_room(room), + ) + } + + /// Say `message` in chat room `room`. The server echoes it back as a + /// [`RoomEvent::Message`], so the UI should render from that echo rather + /// than optimistically. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_say_chatroom( + room, message, + ), + ) + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_list(), + Err(e) => { + error!("[client] room_list: {}", e); + Vec::new() + } + } + } + + /// Ask the server what it knows about `username`: online status and share + /// statistics. + /// + /// Any previous answer for `username` is discarded first, so + /// [`Client::user_info`] returns `None` until the replies to *this* + /// request arrive — polling it cannot mistake a stale snapshot for a + /// fresh one. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn request_user_info(&self, username: &str) -> Result<()> { + use crate::message::server::MessageFactory; + self.context.write_safe()?.invalidate_user_info(username); + self.send_server_message(MessageFactory::build_get_user_status( + username, + ))?; + self.send_server_message(MessageFactory::build_get_user_stats(username)) + } + + /// What the server has told us about `username` since the last + /// [`Client::request_user_info`], or `None` before any reply arrives. + /// + /// Presence and statistics are separate replies, so a snapshot can carry + /// one and not the other; [`UserInfo::is_complete`] reports when both + /// have landed. + #[must_use] + pub fn user_info(&self, username: &str) -> Option { + match self.context.read_safe() { + Ok(ctx) => ctx.user_info(username), + Err(e) => { + error!("[client] user_info: {}", e); + None + } + } + } + + /// Ask the server to watch `username` (code 5). + /// + /// The server replies with that user's current status and share + /// statistics — readable with [`Client::user_info`] — and then keeps + /// pushing their status changes until [`Client::unwatch_user`]. Watching + /// a user the server does not know is answered with a "does not exist" + /// flag, and that user is dropped from [`Client::watched_users`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn watch_user(&self, username: &str) -> Result<()> { + use crate::message::server::MessageFactory; + // Held across the send: the reply is applied under this lock, so it + // cannot land first and have its "does not exist" undone by the add. + let mut context = self.context.write_safe()?; + self.send_server_message(MessageFactory::build_watch_user(username))?; + context.add_watched_user(username); + Ok(()) + } + + /// Stop watching `username` (code 6) and drop what we knew about them. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn unwatch_user(&self, username: &str) -> Result<()> { + use crate::message::server::MessageFactory; + self.send_server_message(MessageFactory::build_unwatch_user(username))?; + self.context.write_safe()?.remove_watched_user(username); + Ok(()) + } + + /// Everyone this client is currently watching, sorted by name. + #[must_use] + pub fn watched_users(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.watched_users(), + Err(e) => { + error!("[client] watched_users: {}", e); + Vec::new() + } + } + } + + /// Who is currently in `room`, sorted by name. + /// + /// The roster is built from the membership the server sends when + /// [`Client::join_room`] succeeds and kept current by the join and leave + /// events after it, so it is populated shortly after joining rather than + /// immediately. Returns an empty list for a room this client has not + /// joined. + #[must_use] + pub fn room_members(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_members(room), + Err(e) => { + error!("[client] room_members: {}", e); + Vec::new() + } + } + } + + /// What the server reported about the members of `room` when we joined: + /// their status, share statistics, free upload slots and country. + /// + /// Populated from the same reply that fills [`Client::room_members`], so + /// it lands shortly after joining. Unlike the roster it is not updated by + /// later join and leave events -- it is the snapshot taken at join time. + /// Returns an empty list for a room this client has not joined. + #[must_use] + pub fn room_member_stats(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_member_stats(room), + Err(e) => { + error!("[client] room_member_stats: {}", e); + Vec::new() + } + } + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&self) -> Vec { + match self.context.write_safe() { + Ok(mut ctx) => ctx.take_room_events(), + Err(e) => { + error!("[client] take_room_events: {}", e); + Vec::new() + } + } + } + + /// Request a peer's shared-file listing. When it arrives it can be + /// retrieved with [`Client::take_browse_result`]. + /// + /// # Errors + /// Returns an error if the client's context lock is poisoned. + 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 ctx = self.context.read_safe()?; + ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(username)), + ctx.peer_registry.clone(), + ) + }; + if connected { + if let Some(registry) = registry { + let _ = registry + .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); + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); + } + Ok(()) + } + + /// Remove and return a peer's shared-file listing requested via + /// [`Client::browse_user`], if it has arrived. + #[must_use] + pub fn take_browse_result( + &self, + username: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_browse_result(username)) + } + + /// Remove and return all private messages received since the last call. + #[must_use] + pub fn take_private_messages(&self) -> Vec { + match self.context.write_safe() { + Ok(mut ctx) => ctx.take_private_messages(), + Err(e) => { + error!("[client] take_private_messages: {}", e); + Vec::new() + } + } + } +} diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs new file mode 100644 index 0000000..3b44d20 --- /dev/null +++ b/soulseek-rs-lib/src/client/search.rs @@ -0,0 +1,238 @@ +use super::{ + Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap, + Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage, + SoulseekRs, info, next_search_token, sleep, +}; + +impl Client { + pub fn search( + &self, + query: &str, + timeout: Duration, + ) -> Result> { + self.search_with_cancel(query, timeout, None) + } + + /// Send `query` as a wishlist search (server code 103) and return at once. + /// + /// Results accumulate under `query`, so the caller reads them back with + /// [`Client::get_search_results`] after waiting however long it wants to. + /// Starting several wishes and then waiting once is the whole point: waiting + /// per wish would cost one full search window each. + /// + /// The server rate-limits these to the interval it announced — see + /// [`Client::wishlist_interval`] — so a wish re-sent sooner than that comes + /// back empty. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn start_wishlist_search(&self, query: &str) -> Result<()> { + self.send_search(query, true) + } + + /// How long to wait between wishlist searches: what the server announced in + /// code 104, or [`DEFAULT_WISHLIST_INTERVAL`] until it has. + #[must_use] + pub fn wishlist_interval(&self) -> Duration { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.wishlist_interval) + .map_or(DEFAULT_WISHLIST_INTERVAL, |seconds| { + Duration::from_secs(u64::from(seconds)) + }) + } + + pub fn search_with_cancel( + &self, + query: &str, + timeout: Duration, + cancel_flag: Option>, + ) -> Result> { + self.send_search(query, false)?; + Self::collect_for(timeout, cancel_flag); + Ok(self.get_search_results(query)) + } + + /// Register `query` and put its search on the wire. Returns as soon as the + /// message is queued; nothing has answered yet. + 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 query = query.to_string(); + let _ = handle.send(if wishlist { + ServerMessage::WishlistSearch { token, query } + } else { + ServerMessage::FileSearch { token, query } + }); + Ok(()) + } + + /// Search one user's shares (server code 42) and return at once. + /// + /// Results arrive from that user alone and accumulate under `query`, read + /// back with [`Client::get_search_results`] — the same place a plain + /// search's results land, since the replies are ordinary + /// `FileSearchResponse` frames. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_user(&self, username: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_user_search( + username, token, query, + ), + ) + } + + /// Search the shares of everyone in `room` (server code 120), returning at + /// once. Results accumulate under `query` exactly as above. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_room(&self, room: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_room_search( + room, token, query, + ), + ) + } + + /// Take a token for `query` and register it, so replies quoting that token + /// are filed under `query` when they arrive. + fn register_search(&self, query: &str) -> Result { + if self.server_handle.is_none() { + return Err(SoulseekRs::NotConnected); + } + let token = next_search_token(); + self.context.write_safe()?.searches.insert( + query.to_string(), + Search { + token, + results: Vec::new(), + }, + ); + Ok(token) + } + + /// The phrases the server excludes from the search network (code 160), as + /// last announced. Files whose path carries one are left out of the + /// replies this client sends. Empty until the server has said. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.context + .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 + /// to it, so there is nothing to await — the window is the whole protocol. + /// Late responders are often the better sources, which is why this runs the + /// window out rather than returning on the first hit. + pub fn collect_for( + timeout: Duration, + cancel_flag: Option>, + ) { + let start = Instant::now(); + while start.elapsed() < timeout { + sleep(Duration::from_millis(100)); + if let Some(flag) = &cancel_flag + && flag.load(Ordering::Relaxed) + { + info!("Search cancelled by user"); + return; + } + } + } + + #[must_use] + pub fn get_search_results_count(&self, search_key: &str) -> usize { + self.context + .read_safe() + .ok() + .and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.len()) + }) + .unwrap_or(0) + } + + #[must_use] + pub fn get_search_results(&self, search_key: &str) -> Vec { + self.context + .read_safe() + .ok() + .and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.clone()) + }) + .unwrap_or_default() + } + + /// Non-blocking variant that returns None if the lock is unavailable + #[must_use] + pub fn try_get_search_results( + &self, + search_key: &str, + ) -> Option> { + self.context.try_read().ok().and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.clone()) + }) + } + + /// Drop a search and everything it collected. + /// + /// Returns whether there was one to drop. A client that stays up for days + /// would otherwise hold every result set it has ever seen, and a caller + /// that has dismissed a search has no other way to say so. + #[must_use] + pub fn forget_search(&self, search_key: &str) -> bool { + self.context + .write_safe() + .is_ok_and(|mut ctx| ctx.searches.remove(search_key).is_some()) + } + + #[must_use] + pub fn get_all_searches(&self) -> HashMap { + self.context + .read_safe() + .map(|ctx| ctx.searches.clone()) + .unwrap_or_default() + } + + /// Every registered search and how many files it has collected, without + /// copying the result sets. + /// + /// This is the accessor to poll. [`Client::get_all_searches`] clones every + /// result of every search, which for a popular query is megabytes per + /// call — a caller that only wants to know what exists and whether + /// anything new arrived reads the counts instead. + #[must_use] + pub fn search_file_counts(&self) -> Vec<(String, usize)> { + self.context + .read_safe() + .map(|ctx| { + ctx.searches + .iter() + .map(|(query, search)| { + ( + query.clone(), + search + .results + .iter() + .map(|result| result.files.len()) + .sum(), + ) + }) + .collect() + }) + .unwrap_or_default() + } +} diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs new file mode 100644 index 0000000..89b1322 --- /dev/null +++ b/soulseek-rs-lib/src/client/social.rs @@ -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 { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_members(room), + Err(e) => { + error!("[client] private_room_members: {}", e); + Vec::new() + } + } + } + + /// Who runs the private room `room`. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_operators(room), + Err(e) => { + error!("[client] private_room_operators: {}", e); + Vec::new() + } + } + } + + /// The private rooms we belong to. + #[must_use] + pub fn private_rooms(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_rooms(), + Err(e) => { + error!("[client] private_rooms: {}", e); + Vec::new() + } + } + } + + /// Set our online status (server code 28): away, or back to online. + /// + /// Other clients see it in their `GetUserStatus` and watch replies, and + /// 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 { + 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> { + 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 { + 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 { + 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, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(false)) + } + + /// The last reply to [`Client::request_global_recommendations`]. + #[must_use] + pub fn global_recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(true)) + } + + /// Ask what else people who like `item` like (code 111). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_recommendations(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_recommendations( + item, + )) + } + + /// The last per-item recommendations received for `item`. + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_recommendations(item), + Err(e) => { + error!("[client] item_recommendations: {}", e); + Vec::new() + } + } + } + + /// Ask which users share our interests (code 110). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_similar_users(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_similar_users()) + } + + /// The last similar-user answer, weighted by how much we have in common. + #[must_use] + pub fn similar_users(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.similar_users(), + Err(e) => { + error!("[client] similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask which users like `item` (code 112). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_similar_users(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_similar_users(item)) + } + + /// The last answer to [`Client::request_item_similar_users`] for `item`. + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_similar_users(item), + Err(e) => { + error!("[client] item_similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask what `username` likes and hates (code 57). + /// + /// Any previous answer for `username` is dropped first, so + /// [`Client::user_interests`] reports the reply to *this* request rather + /// than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_user_interests(&self, username: &str) -> Result<()> { + self.context + .write_safe()? + .invalidate_user_interests(username); + self.send_server_message(MessageFactory::build_user_interests(username)) + } + + /// What the server last told us `username` likes and hates. + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.user_interests(username)) + } + + /// Give `days` of our own privileges to `username` (code 123). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> { + self.send_server_message(MessageFactory::build_give_privileges( + username, days, + )) + } + + /// Change our account password (code 142). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn change_password(&self, password: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_change_password( + password, + )) + } + + /// Send one private message to several users at once (code 149). Each + /// recipient receives it as an ordinary private message. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn send_private_message_to_many( + &self, + usernames: &[String], + message: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_message_users( + usernames, message, + )) + } +} diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs new file mode 100644 index 0000000..0862c05 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -0,0 +1,52 @@ +//! The client's context, exercised directly. +//! +//! This file is the harness the topic modules below share. + +mod peers; +mod rooms; +mod search; +mod transfers; +mod users; + +use super::*; + +fn download( + username: &str, + filename: &str, + token: u32, + status: DownloadStatus, + sender: Sender, +) -> Download { + Download { + username: username.to_string(), + filename: filename.to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + } +} + +fn peer_files(count: usize) -> SearchResult { + SearchResult { + token: 1, + files: (0..count) + .map(|i| crate::types::File { + username: "bob".to_string(), + name: format!("song-{i}.mp3"), + size: 1, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +use crate::types::{RoomEvent, UploadStatus}; + +use std::time::{Duration, Instant}; diff --git a/soulseek-rs-lib/src/client/tests/peers.rs b/soulseek-rs-lib/src/client/tests/peers.rs new file mode 100644 index 0000000..4f214bd --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/peers.rs @@ -0,0 +1,173 @@ +//! Uploads, broker connects, and the peers a disconnect may not drop. + +use super::*; + +#[test] +fn upload_speed_is_reported_only_while_running() { + let two_seconds_ago = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("clock supports a 2s offset"); + + // 1 KiB over ~2s is ~512 B/s. + let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); + assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); + + // A finished upload reports no rate, exactly as a finished download + // does, so the Speed column renders "-" rather than a stale figure. + for status in [ + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".to_string()), + ] { + let rate = upload_speed(&status, 1024, two_seconds_ago); + assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); + } + + // A just-started upload must not divide by a zero elapsed time. + let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); + assert!( + rate.is_finite() && rate.abs() < f64::EPSILON, + "unexpected rate {rate}" + ); +} + +// A clean disconnect — the idle reaper, or a remote client tidying an idle +// socket while it waits in our queue — must keep the peer's queued uploads; +// only an error disconnect is evidence the peer is gone. + +#[test] +fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { + let client = Client::new("test-user", "test-password"); + let (ops, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "me".to_string(), + ); + + client.context.write().unwrap().enqueue_upload( + "amy", + "@@share\\f.mp3", + std::path::PathBuf::from("/tmp/f.mp3"), + 4096, + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + None, + )) + .unwrap(); + // The loop is serial, so once this fence op is visible the disconnect + // before it has been handled. + ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while client.own_privilege_seconds() != Some(7) { + assert!(Instant::now() < deadline, "ops loop never caught up"); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"), + Some(1), + "a clean disconnect must keep the queue" + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + Some(crate::error::SoulseekRs::NotConnected), + )) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let place = client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"); + if place.is_none() { + break; + } + assert!( + Instant::now() < deadline, + "an error disconnect must drop the queue" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_pending_connect_expires_after_the_broker_timeout() { + let mut context = ClientContext::new(); + context.add_pending_connect(7, "ghost".to_string()); + + assert!(context.take_expired_connects(Instant::now()).is_empty()); + + let after_deadline = + Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); + assert_eq!( + context.take_expired_connects(after_deadline), + vec!["ghost".to_string()] + ); + assert!(context.take_pending_connect(7).is_none()); +} + +#[test] +fn protected_peers_covers_downloads_uploads_and_pending_serves() { + let mut context = ClientContext::new(); + context.add_download(download( + "downloader", + "song.mp3", + 1, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "done", + "old.mp3", + 2, + DownloadStatus::Completed, + mpsc::channel().0, + )); + context + .pending_serves + .insert("waiting".to_string(), vec![9]); + context.mark_browse_pending("browsed"); + + let protected = context.protected_peers(); + assert!(protected.contains("downloader")); + assert!(protected.contains("waiting")); + assert!(protected.contains("browsed")); + assert!(!protected.contains("done")); + + context.store_browse_result("browsed".to_string(), Vec::new()); + assert!(!context.protected_peers().contains("browsed")); +} + +#[test] +fn a_peer_message_nobody_could_deliver_expires() { + let mut ctx = ClientContext::for_user("me"); + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now()); + assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); + + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); + assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); +} + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs new file mode 100644 index 0000000..d504839 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -0,0 +1,316 @@ +//! 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_join_announced_before_the_member_list_does_not_start_a_roster() { + // soulfind tells a room about a joiner, the joiner included, before it + // sends them the member list. A roster started from that echo reads as a + // room holding nobody else. + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "me".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); + + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["me".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "me"]); +} + +#[test] +fn a_user_joining_twice_is_listed_once() { + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["alice".to_string()], + }); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "alice".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice"]); +} + +#[test] +fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: true, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats( + "jazz".to_string(), + vec![stat("carol"), stat("alice")], + ); + + let names: Vec = context + .room_member_stats("jazz") + .into_iter() + .map(|s| s.username) + .collect(); + assert_eq!(names, vec!["alice", "carol"]); + assert!(context.room_member_stats("unjoined").is_empty()); +} + +#[test] +fn rejoining_replaces_the_previous_member_stats() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: false, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); + context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); + + let stats = context.room_member_stats("jazz"); + assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); + assert_eq!(stats[0].username, "bob"); +} + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into(), "bob".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/src/client/tests/search.rs b/soulseek-rs-lib/src/client/tests/search.rs new file mode 100644 index 0000000..f18fe93 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/search.rs @@ -0,0 +1,167 @@ +//! Answering a search against our shares, and what the client keeps of +//! the answers it gets back. + +use super::*; + +#[test] +fn build_search_response_matches_shares_and_echoes_token() { + let dir = std::env::temp_dir() + .join(format!("soulseek-searchresp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + let response = + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) + .expect("a matching share yields a response"); + let mut decoded = + crate::message::Message::new_with_data(response.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + assert_eq!(result.username, "me"); + assert_eq!(result.token, 99); + assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); + + assert!( + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) + .is_none() + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn search_file_counts_cover_every_search_without_the_results() { + // The daemon polls these counts many times a second; they must come from + // a walk of the cache, never a copy of it. + let client = Client::new("u", "p"); + { + let mut context = client.context.write().unwrap(); + context.searches.insert( + "aphex twin".to_string(), + Search { + token: 1, + results: vec![peer_files(2), peer_files(1)], + }, + ); + context.searches.insert( + "nothing yet".to_string(), + Search { + token: 2, + results: Vec::new(), + }, + ); + } + + let mut counts = client.search_file_counts(); + counts.sort(); + assert_eq!( + counts, + [ + ("aphex twin".to_string(), 3), + ("nothing yet".to_string(), 0) + ] + ); +} + +#[test] +fn a_search_stops_collecting_once_it_has_enough_responses() { + // A popular query on the live network draws answers for minutes — + // nearly a million files and gigabytes of memory for one search. + // Surplus responders are dropped, not archived. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { + search.accept(peer_files(1)); + } + assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); +} + +#[test] +fn a_flood_of_files_fills_a_search_before_the_response_cap() { + // A handful of whales with huge matching collections must not add up + // to an unbounded set just because the responses are few. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..10 { + search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); + } + assert_eq!( + search.results.len(), + 2, + "two half-cap responses fill the search; the rest are dropped" + ); +} + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file — and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs new file mode 100644 index 0000000..dbabdd3 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -0,0 +1,329 @@ +//! Downloads through the context: what is queued, paused, cancelled, +//! and what a peer's answer is allowed to start. + +use super::*; + +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + context.add_download(download( + "test", + "test.txt", + token, + DownloadStatus::Queued, + mpsc::channel().0, + )); + assert!(context.get_download_by_token(123).is_some()); + assert_eq!(context.get_download_tokens(), vec![123]); + assert_eq!(context.get_downloads().len(), 1); + if let Some(download) = context.get_download_by_token_mut(token) { + assert_eq!(download.token, token); + download.token = new_token; + } + assert!(context.get_download_by_token(new_token).is_some()); + assert_eq!(context.get_download_tokens(), vec![new_token]); + context.remove_download(new_token); + assert_eq!(context.get_downloads().len(), 0); + assert!(context.get_download_by_token(1234).is_none()); +} + +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + + client.context.write().unwrap().add_download(download( + "peer", + "song.mp3", + 123, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + download_sender, + )); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn download_without_a_connection_resolves_failed() { + // A client that never connected has no server handle and no peer registry, + // so it cannot open a connection to the peer: the download must resolve to + // Failed rather than hang Queued forever. + let client = Client::new("test-user", "test-password"); + let (_download, receiver) = client + .download( + "song.mp3".to_string(), + "peer".to_string(), + 100, + "test".to_string(), + ) + .expect("download() should return a handle"); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(DownloadStatus::Failed(_)) + )); +} + +#[test] +fn fail_queued_downloads_notifies_receiver_and_store() { + // When a brokered connect times out, every Queued download for the peer + // must resolve to Failed both on its channel and in the store. + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + + Client::fail_queued_downloads(&client.context, "peer"); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(7) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + { + let mut context = client.context.write().unwrap(); + context.add_download(download( + "peer", + "queued.mp3", + 123, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "peer", + "active.mp3", + 456, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + mpsc::channel().0, + )); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + +#[test] +fn an_expired_broker_connect_fails_the_queued_downloads() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + { + let mut ctx = client.context.write().unwrap(); + ctx.add_download(download( + "ghost", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + ctx.pending_connect_tokens + .insert(7, ("ghost".to_string(), Instant::now())); + } + let (_ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let status = receiver.recv_timeout(Duration::from_secs(5)); + assert!( + matches!(status, Ok(DownloadStatus::Failed(_))), + "the sweep must fail the queued download, got {status:?}" + ); +} + +#[test] +fn a_replayed_transfer_response_does_not_start_a_second_transfer() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let download_dir = std::env::temp_dir() + .join(format!("soulseek-replayed-transfer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&download_dir).unwrap(); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + let mut queued = + download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); + queued.download_directory = download_dir.display().to_string(); + client.context.write().unwrap().add_download(queued); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) + .unwrap(); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut connections = 0; + while Instant::now() < deadline { + match listener.accept() { + Ok(_) => { + connections += 1; + if connections == 1 { + thread::sleep(Duration::from_millis(500)); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if connections >= 1 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + assert_eq!( + connections, 1, + "a replayed TransferResponse must not dial the peer again" + ); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Cancelled, + sender, + )); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + thread::sleep(Duration::from_millis(500)); + assert!( + matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), + "a cancelled download must not dial the peer" + ); +} + +#[test] +fn cancel_download_marks_the_store_and_answers_whether_it_matched() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Queued, + sender, + )); + + assert!(client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "other.mp3")); + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); + assert!(matches!( + client.get_all_downloads()[0].status, + DownloadStatus::Cancelled + )); +} diff --git a/soulseek-rs-lib/src/client/tests/users.rs b/soulseek-rs-lib/src/client/tests/users.rs new file mode 100644 index 0000000..4094b04 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/users.rs @@ -0,0 +1,200 @@ +//! What the server tells us about people: watches, interests, and the +//! two halves of a user snapshot. + +use super::*; + +#[test] +fn a_fresh_request_discards_the_previous_answer() { + // Without this, a poll after a second request returns the old + // snapshot immediately and the caller cannot tell stale from fresh. + let mut context = ClientContext::new(); + context.apply_user_status("alice".to_string(), 2, false); + context.apply_user_stats("alice".to_string(), 10, 20, 30); + assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); + + context.invalidate_user_info("alice"); + assert!( + context.user_info("alice").is_none(), + "a new request must not be answerable from the old reply" + ); +} + +#[test] +fn each_reply_fills_only_its_own_half() { + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + + let info = context.user_info("bob").expect("a snapshot"); + assert!(!info.is_complete(), "stats have not arrived"); + assert_eq!( + info.presence.map(|p| p.status), + Some(crate::types::UserStatus::Away) + ); + assert!(info.stats.is_none(), "must not invent statistics"); + + context.apply_user_stats("bob".to_string(), 5, 6, 7); + let info = context.user_info("bob").expect("a snapshot"); + assert!(info.is_complete()); + assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); + assert_eq!( + info.presence.map(|p| p.privileged), + Some(true), + "the earlier half must survive the merge" + ); +} + +#[test] +fn a_watch_reply_fills_both_halves_of_the_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_watched_user( + "alice".to_string(), + true, + Some(2), + Some(1024), + Some(20), + Some(3), + ); + + let info = context.user_info("alice").expect("a snapshot"); + assert!(info.is_complete(), "a watch reply carries status and stats"); + assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); + assert_eq!(context.watched_users(), vec!["alice".to_string()]); +} + +#[test] +fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { + // WatchUser has no privileged field, so it must not overwrite what + // GetUserStatus already told us with a fabricated `false`. + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + context.apply_watched_user( + "bob".to_string(), + true, + Some(2), + Some(1), + Some(2), + Some(3), + ); + + let info = context.user_info("bob").expect("a snapshot"); + assert_eq!(info.presence.map(|p| p.privileged), Some(true)); +} + +#[test] +fn watching_an_unknown_user_drops_them_from_the_list() { + // The server will never push status for a name it does not know, so + // keeping it in the watch list would show a permanently blank row. + let mut context = ClientContext::new(); + context.add_watched_user("ghost"); + context.apply_watched_user( + "ghost".to_string(), + false, + None, + None, + None, + None, + ); + + assert!(context.watched_users().is_empty()); + assert!(context.user_info("ghost").is_none()); +} + +#[test] +fn unwatching_forgets_the_users_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_user_status("alice".to_string(), 2, false); + + context.remove_watched_user("alice"); + assert!(context.watched_users().is_empty()); + assert!( + context.user_info("alice").is_none(), + "a later re-watch must report a fresh answer" + ); +} + +#[test] +fn watched_users_are_listed_in_a_stable_order() { + let mut context = ClientContext::new(); + context.add_watched_user("carol"); + context.add_watched_user("alice"); + context.add_watched_user("bob"); + assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); +} + +// A reply queued for a searcher the server cannot place waits a minute, +// not forever: every such search would otherwise pin a map entry and a +// protected slot in the peer registry. + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} diff --git a/soulseek-rs-lib/src/client/upload_queue.rs b/soulseek-rs-lib/src/client/upload_queue.rs new file mode 100644 index 0000000..4c59f85 --- /dev/null +++ b/soulseek-rs-lib/src/client/upload_queue.rs @@ -0,0 +1,641 @@ +//! The queue peers wait in for one of our upload slots. +//! +//! Soulseek's rules ask an alternative client for "respect / recognition of +//! privileges": a user who has donated is meant to be served before one who +//! has not. That only means anything if there is a queue to jump — serving +//! every request the instant it arrives treats a privileged peer exactly like +//! everybody else — so the slot cap and the ordering are one feature. +//! +//! Ordering is privileged-first, then first-come. Nothing else: a fancier +//! policy (per-user fairness, small-file-first) would be a different promise +//! than the one the rules ask for. + +use super::{ClientContext, Duration, Instant, PeerRegistry, UploadJob}; +use crate::types::{UploadInfo, UploadStatus}; +use std::path::PathBuf; + +/// Cap on remembered queued-upload states, so a caller that never drains +/// them cannot grow the list without bound. +const MAX_UPLOAD_EVENTS: usize = 256; + +/// How long an offered slot waits for the peer's TransferResponse. A peer that +/// never answers, having gone away or hung, would otherwise hold the slot for +/// the life of the process; with two slots, two of them shut uploads down. +const OFFER_TIMEOUT: Duration = Duration::from_secs(30); + +/// A queued upload that has just been given a slot, ready to be offered to the +/// peer that asked for it. +#[derive(Debug, Clone)] +pub struct UploadOffer { + pub requester_key: String, + pub token: u32, + pub virtual_path: String, + pub size: u64, +} + +/// A file a peer has asked us for and is waiting on. +#[derive(Debug, Clone)] +pub struct QueuedUpload { + /// The asking peer's username — also the registry key of its actor. + pub downloader: String, + pub virtual_path: String, + pub real_path: PathBuf, + pub size: u64, + /// Whether the server listed this user as privileged when they asked. + pub privileged: bool, + /// Arrival order, so the tie-break among equals is first-come. + pub seq: u64, +} + +/// What decides the order: donors first, then whoever asked first. +const fn rank(job: &QueuedUpload) -> (bool, u64) { + (!job.privileged, job.seq) +} + +/// Indices of `queue` in the order they will be served. +#[must_use] +pub fn serve_order(queue: &[QueuedUpload]) -> Vec { + let mut order: Vec = (0..queue.len()).collect(); + order.sort_by_key(|&i| rank(&queue[i])); + order +} + +/// Which entry to serve next, or `None` when nobody is waiting. +#[must_use] +pub fn next_to_serve(queue: &[QueuedUpload]) -> Option { + queue + .iter() + .enumerate() + .min_by_key(|(_, job)| rank(job)) + .map(|(index, _)| index) +} + +/// A queue position as the protocol reports it: counting from 1. +fn as_place(zero_based: usize) -> u32 { + u32::try_from(zero_based + 1).unwrap_or(u32::MAX) +} + +/// Where `filename` sits for `downloader`, counting from 1 as the protocol's +/// `PlaceInQueueResponse` does. `None` when that peer is not waiting for it, +/// which is also the answer once the file has been handed over. +#[must_use] +pub fn place_in_queue( + queue: &[QueuedUpload], + downloader: &str, + filename: &str, +) -> Option { + serve_order(queue) + .into_iter() + .position(|i| { + queue[i].downloader == downloader + && queue[i].virtual_path == filename + }) + .map(as_place) +} + +/// The queue as the client drives it: taking a request, filling slots, and +/// answering where a peer sits. +impl ClientContext { + /// Take `filename` from a peer's request and put them in the queue. The + /// caller pumps afterwards; queueing and serving are separate so a request + /// for a file we do not have never reaches the queue. + pub fn enqueue_upload( + &mut self, + downloader: &str, + filename: &str, + real_path: std::path::PathBuf, + size: u64, + ) { + self.upload_seq += 1; + self.upload_queue.push(QueuedUpload { + downloader: downloader.to_string(), + virtual_path: filename.to_string(), + real_path, + size, + privileged: self.privileged_users.contains(downloader), + seq: self.upload_seq, + }); + } + + /// Move as many queued uploads into free slots as will fit, and return the + /// offers to send. Ordering is privileged-first, then first-come. + /// + /// The registry comes back with them so the caller can send after dropping + /// the context lock: a peer actor's mailbox is not something to hold it + /// across. + pub fn pump_uploads( + &mut self, + mut next_token: impl FnMut() -> u32, + ) -> (Option, Vec) { + let mut offers = Vec::new(); + while self.has_free_upload_slot() { + let Some(index) = next_to_serve(&self.upload_queue) else { + break; + }; + let job = self.upload_queue.remove(index); + let token = next_token(); + offers.push(UploadOffer { + requester_key: job.downloader.clone(), + token, + virtual_path: job.virtual_path.clone(), + size: job.size, + }); + self.uploads.insert( + token, + UploadJob { + downloader: job.downloader, + real_path: job.real_path, + virtual_path: job.virtual_path, + size: job.size, + offered: Some(Instant::now()), + }, + ); + } + + // Whatever is still waiting had to wait. Record it, because a caller + // polling `uploads()` on an interval cannot see a peer that queued and + // was served between two polls — and with a small slot count that is + // the common case, not the rare one. + for waiting in self.queued_uploads() { + let already = self.upload_events.iter().any(|seen| { + seen.username == waiting.username + && seen.filename == waiting.filename + }); + if !already { + self.upload_events.push(waiting); + } + } + // A caller that never drains must not grow this without bound. + if self.upload_events.len() > MAX_UPLOAD_EVENTS { + let excess = self.upload_events.len() - MAX_UPLOAD_EVENTS; + self.upload_events.drain(..excess); + } + + (self.peer_registry.clone(), offers) + } + + /// Take the queued-upload states recorded since the last call. + pub fn take_upload_events(&mut self) -> Vec { + std::mem::take(&mut self.upload_events) + } + + /// Uploads occupying a slot: offered but not yet accepted, plus those + /// actually streaming. + /// + /// `active_uploads` keeps finished transfers so `uploads()` can report them, + /// so only the in-progress ones count — counting the whole table would let + /// a few completed transfers wedge the queue shut forever. + fn uploads_in_flight(&self) -> usize { + self.uploads.len() + + self + .active_uploads + .values() + .filter(|upload| { + matches!(upload.status, UploadStatus::InProgress) + }) + .count() + } + + pub(super) fn has_free_upload_slot(&self) -> bool { + self.uploads_in_flight() < self.upload_slots + } + + /// The peers still waiting, in the order they will be served, each carrying + /// its own 1-based place. + pub(super) fn queued_uploads(&self) -> Vec { + serve_order(&self.upload_queue) + .into_iter() + .enumerate() + .map(|(place, index)| { + let job = &self.upload_queue[index]; + UploadInfo { + username: job.downloader.clone(), + filename: job.virtual_path.clone(), + size: job.size, + bytes_sent: 0, + speed_bytes_per_sec: 0.0, + status: UploadStatus::Queued(as_place(place)), + } + }) + .collect() + } + + /// Where `downloader`'s queued `filename` sits, counting from 1. `None` + /// once it has left the queue for a slot. + #[must_use] + pub fn place_in_queue( + &self, + downloader: &str, + filename: &str, + ) -> Option { + place_in_queue(&self.upload_queue, downloader, filename) + } + + /// Replace the privileged set, re-ranking anyone already waiting: the list + /// arrives at login, which can be after a peer has queued something. + pub fn set_privileged_users(&mut self, users: Vec) { + self.privileged_users = users.into_iter().collect(); + for job in &mut self.upload_queue { + job.privileged = self.privileged_users.contains(&job.downloader); + } + } + + #[must_use] + pub fn is_privileged(&self, username: &str) -> bool { + self.privileged_users.contains(username) + } + + /// Drop offers older than [`OFFER_TIMEOUT`], recording each as a failed + /// upload so an operator sees why the peer never got its file. Returns + /// whether anything was dropped, which is the caller's cue to pump again. + pub fn expire_stale_offers(&mut self, now: Instant) -> bool { + let stale: Vec = self + .uploads + .iter() + .filter(|(_, job)| { + job.offered.is_some_and(|sent| { + now.duration_since(sent) >= OFFER_TIMEOUT + }) + }) + .map(|(&token, _)| token) + .collect(); + for token in &stale { + let Some(job) = self.uploads.remove(token) else { + continue; + }; + self.upload_events.push(UploadInfo { + username: job.downloader, + filename: job.virtual_path, + size: job.size, + bytes_sent: 0, + speed_bytes_per_sec: 0.0, + status: UploadStatus::Failed( + "no answer to the transfer offer".to_string(), + ), + }); + } + !stale.is_empty() + } + + /// The peer accepted the offer for `token`; it may still take a while to + /// resolve their address and connect, and that wait must not expire it. + pub fn mark_offer_answered(&mut self, token: u32) { + if let Some(job) = self.uploads.get_mut(&token) { + job.offered = None; + } + } + + /// Forget everything `downloader` was waiting for or had been offered, and + /// report whether that freed anything. + /// + /// An offer sits in `uploads` counting against the slot cap until the peer + /// accepts it. A peer that never will — because it has gone — would hold + /// that slot for the life of the process, so leaving is what releases it. + pub fn release_upload_slots(&mut self, downloader: &str) -> bool { + let before = self.upload_queue.len() + self.uploads.len(); + self.upload_queue.retain(|job| job.downloader != downloader); + self.uploads.retain(|_, job| job.downloader != downloader); + before != self.upload_queue.len() + self.uploads.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn queued(user: &str, privileged: bool, seq: u64) -> QueuedUpload { + QueuedUpload { + downloader: user.to_string(), + virtual_path: format!("@@share\\{user}.mp3"), + real_path: PathBuf::from("/tmp/x.mp3"), + size: 1, + privileged, + seq, + } + } + + #[test] + fn an_empty_queue_serves_nobody() { + assert_eq!(next_to_serve(&[]), None); + } + + #[test] + fn equals_are_served_in_the_order_they_asked() { + let queue = [queued("amy", false, 1), queued("bob", false, 2)]; + assert_eq!(next_to_serve(&queue), Some(0)); + assert_eq!(serve_order(&queue), [0, 1]); + } + + #[test] + fn a_privileged_peer_is_served_before_one_that_asked_first() { + let queue = [ + queued("early_plain", false, 1), + queued("late_donor", true, 2), + ]; + assert_eq!( + next_to_serve(&queue), + Some(1), + "the donor jumps the plain user who was already waiting" + ); + } + + #[test] + fn privileged_peers_keep_first_come_order_among_themselves() { + let queue = [ + queued("donor_late", true, 9), + queued("plain", false, 2), + queued("donor_early", true, 5), + ]; + let order: Vec<&str> = serve_order(&queue) + .into_iter() + .map(|i| queue[i].downloader.as_str()) + .collect(); + assert_eq!(order, ["donor_early", "donor_late", "plain"]); + } + + #[test] + fn the_reported_place_counts_from_one() { + let queue = [queued("amy", false, 1), queued("bob", false, 2)]; + assert_eq!(place_in_queue(&queue, "amy", "@@share\\amy.mp3"), Some(1)); + assert_eq!(place_in_queue(&queue, "bob", "@@share\\bob.mp3"), Some(2)); + } + + #[test] + fn the_reported_place_reflects_being_overtaken() { + let mut queue = vec![queued("plain", false, 1)]; + assert_eq!( + place_in_queue(&queue, "plain", "@@share\\plain.mp3"), + Some(1) + ); + queue.push(queued("donor", true, 2)); + assert_eq!( + place_in_queue(&queue, "plain", "@@share\\plain.mp3"), + Some(2), + "a peer that got overtaken must be told so, not left on 1" + ); + } + + #[test] + fn a_file_nobody_queued_has_no_place() { + let queue = [queued("amy", false, 1)]; + assert_eq!(place_in_queue(&queue, "amy", "@@share\\other.mp3"), None); + assert_eq!(place_in_queue(&queue, "zoe", "@@share\\amy.mp3"), None); + } +} + +/// Tests of the queue as the client actually drives it: what a request, a +/// privileged-user list, and a finished transfer do to a real +/// [`super::ClientContext`]. +/// +/// These sit here rather than in the e2e suite because a Soulseek server only +/// ever names privileged users it knows about, and the soulfind used for e2e +/// declares nobody — so the one behaviour the rules are about would otherwise +/// go untested. +#[cfg(test)] +mod context_tests { + use super::OFFER_TIMEOUT; + use crate::client::{ClientContext, DEFAULT_UPLOAD_SLOTS}; + use crate::types::UploadStatus; + use std::path::PathBuf; + + /// A context with `slots` upload slots and a token counter that hands out + /// 1, 2, 3… so an assertion can name a token. + fn context(slots: usize) -> (ClientContext, impl FnMut() -> u32) { + let mut ctx = ClientContext::new(); + ctx.upload_slots = slots; + let mut next = 0; + (ctx, move || { + next += 1; + next + }) + } + + fn ask(ctx: &mut ClientContext, user: &str, file: &str) { + ctx.enqueue_upload(user, file, PathBuf::from("/tmp/x"), 4096); + } + + /// Who the pump offered to, by the order it offered them. + fn offered( + ctx: &mut ClientContext, + token: &mut impl FnMut() -> u32, + ) -> Vec { + ctx.pump_uploads(token) + .1 + .into_iter() + .map(|offer| offer.requester_key) + .collect() + } + + #[test] + fn a_fresh_context_starts_with_the_default_cap() { + assert_eq!(ClientContext::new().upload_slots, DEFAULT_UPLOAD_SLOTS); + } + + #[test] + fn the_pump_fills_the_slots_and_leaves_the_rest_queued() { + let (mut ctx, mut token) = context(2); + for user in ["a", "b", "c"] { + ask(&mut ctx, user, "f.mp3"); + } + + assert_eq!(offered(&mut ctx, &mut token), ["a", "b"]); + assert_eq!( + ctx.place_in_queue("c", "f.mp3"), + Some(1), + "the third asker waits, now at the front of the queue" + ); + assert!( + offered(&mut ctx, &mut token).is_empty(), + "pumping again must not oversubscribe the slots" + ); + } + + #[test] + fn a_privileged_list_arriving_late_re_ranks_who_is_waiting() { + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "blocker", "f.mp3"); + ask(&mut ctx, "plain", "f.mp3"); + ask(&mut ctx, "donor", "f.mp3"); + assert_eq!(offered(&mut ctx, &mut token), ["blocker"]); + + // Before the server has said anything, first-come holds. + assert_eq!(ctx.place_in_queue("plain", "f.mp3"), Some(1)); + assert_eq!(ctx.place_in_queue("donor", "f.mp3"), Some(2)); + + ctx.set_privileged_users(vec!["donor".to_string()]); + + assert!(ctx.is_privileged("donor")); + assert!(!ctx.is_privileged("plain")); + assert_eq!( + ctx.place_in_queue("donor", "f.mp3"), + Some(1), + "the donor should overtake the peer that asked first" + ); + assert_eq!( + ctx.place_in_queue("plain", "f.mp3"), + Some(2), + "and the plain peer should be told they were overtaken" + ); + } + + #[test] + fn the_privileged_peer_is_the_one_served_next() { + let (mut ctx, mut token) = context(1); + ctx.set_privileged_users(vec!["donor".to_string()]); + + // Someone else already holds the only slot: a transfer in progress is + // never preempted, so the donor's turn is the *next* one. + ask(&mut ctx, "blocker", "f.mp3"); + assert_eq!(offered(&mut ctx, &mut token), ["blocker"]); + + ask(&mut ctx, "plain", "f.mp3"); + ask(&mut ctx, "donor", "f.mp3"); + + ctx.uploads.clear(); // the blocker's transfer finished + assert_eq!( + offered(&mut ctx, &mut token), + ["donor"], + "the freed slot goes to the donor, not the longer-waiting peer" + ); + } + + #[test] + fn a_finished_transfer_frees_its_slot_for_the_next_in_line() { + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "first", "f.mp3"); + ask(&mut ctx, "second", "f.mp3"); + assert_eq!(offered(&mut ctx, &mut token), ["first"]); + + ctx.uploads.clear(); + assert_eq!(offered(&mut ctx, &mut token), ["second"]); + assert_eq!(ctx.place_in_queue("second", "f.mp3"), None); + } + + #[test] + fn an_offer_nobody_answers_frees_its_slot_after_the_timeout() { + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "gone", "f.mp3"); + ask(&mut ctx, "next", "f.mp3"); + assert_eq!(offered(&mut ctx, &mut token), ["gone"]); + + let now = std::time::Instant::now(); + assert!(!ctx.expire_stale_offers(now), "a fresh offer stays"); + assert!( + ctx.expire_stale_offers(now + OFFER_TIMEOUT), + "an unanswered offer expires" + ); + assert_eq!(offered(&mut ctx, &mut token), ["next"]); + assert!(ctx.take_upload_events().iter().any(|event| { + event.username == "gone" + && matches!(event.status, UploadStatus::Failed(_)) + })); + } + + #[test] + fn an_accepted_offer_waiting_on_an_address_does_not_expire() { + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "slow", "f.mp3"); + offered(&mut ctx, &mut token); + + ctx.mark_offer_answered(1); + let later = std::time::Instant::now() + OFFER_TIMEOUT * 2; + assert!(!ctx.expire_stale_offers(later), "an answered offer stays"); + assert!(ctx.uploads.contains_key(&1)); + } + + #[test] + fn completed_transfers_do_not_keep_occupying_slots() { + // active_uploads keeps finished transfers so `uploads()` can report + // them; counting those would wedge the queue shut for the session. + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "first", "f.mp3"); + offered(&mut ctx, &mut token); + + let job = ctx.uploads.remove(&1).expect("the offer was recorded"); + ctx.active_uploads.insert( + 1, + crate::client::ActiveUpload { + username: job.downloader, + filename: job.virtual_path, + size: job.size, + bytes_sent: std::sync::Arc::new( + std::sync::atomic::AtomicU64::new(job.size), + ), + cancel: std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ), + status: UploadStatus::Completed, + started: std::time::Instant::now(), + }, + ); + + ask(&mut ctx, "second", "f.mp3"); + assert_eq!( + offered(&mut ctx, &mut token), + ["second"], + "a completed upload must not hold its slot" + ); + } + + #[test] + fn queued_uploads_are_reported_in_the_order_they_will_be_served() { + let (mut ctx, mut token) = context(1); + ctx.set_privileged_users(vec!["donor".to_string()]); + ask(&mut ctx, "blocker", "f.mp3"); + offered(&mut ctx, &mut token); + ask(&mut ctx, "plain", "f.mp3"); + ask(&mut ctx, "donor", "f.mp3"); + + let reported: Vec<(String, UploadStatus)> = ctx + .queued_uploads() + .into_iter() + .map(|upload| (upload.username, upload.status)) + .collect(); + assert_eq!( + reported, + [ + ("donor".to_string(), UploadStatus::Queued(1)), + ("plain".to_string(), UploadStatus::Queued(2)), + ] + ); + } + + // A peer that waits and is then served inside one poll interval must still + // be reportable as queued: `serve` samples on an interval, so a state that + // only ever existed between two samples would otherwise vanish from the + // transfer log entirely. + #[test] + fn a_queued_upload_is_recorded_even_after_it_leaves_the_queue() { + let (mut ctx, mut token) = context(1); + ask(&mut ctx, "amy", "@@share\\amy.mp3"); + ask(&mut ctx, "bob", "@@share\\bob.mp3"); + + // One slot, so bob waits behind amy. + let _ = ctx.pump_uploads(&mut token); + assert!( + ctx.queued_uploads().iter().any(|u| u.username == "bob"), + "bob should be waiting while amy holds the only slot" + ); + + // Amy finishes and bob is served, so nothing is waiting any more... + ctx.uploads.clear(); + ctx.active_uploads.clear(); + let _ = ctx.pump_uploads(&mut token); + assert!( + ctx.queued_uploads().is_empty(), + "the queue should have drained" + ); + + // ...but the fact that bob waited survives for whoever asks next. + let events = ctx.take_upload_events(); + assert!( + events.iter().any(|u| u.username == "bob" + && matches!(u.status, UploadStatus::Queued(_))), + "bob's wait should still be reportable, got {events:?}" + ); + assert!( + ctx.take_upload_events().is_empty(), + "draining twice must not repeat the same events" + ); + } +} diff --git a/soulseek-rs-lib/src/client/uploads.rs b/soulseek-rs-lib/src/client/uploads.rs new file mode 100644 index 0000000..297756d --- /dev/null +++ b/soulseek-rs-lib/src/client/uploads.rs @@ -0,0 +1,362 @@ +use super::{ + ActiveUpload, Arc, BrokeredUpload, Client, ClientContext, DownloadStatus, + PeerMessage, RwLock, RwLockExt, ServerMessage, collect_failed_tokens, + debug, error, next_connect_token, next_upload_token, thread, +}; +use crate::message::server::MessageFactory; +use crate::peer::{ConnectionType, upload_peer}; +use crate::types::UploadStatus; +use std::net::TcpStream; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +impl Client { + /// Whether the server listed `username` as privileged. Privileged peers are + /// served before everyone else. + #[must_use] + pub fn is_privileged(&self, username: &str) -> bool { + self.context + .read_safe() + .is_ok_and(|ctx| ctx.is_privileged(username)) + } + + /// Ask the server how much of our own privilege time is left (code 92). The + /// answer arrives asynchronously; read it with + /// [`Client::own_privilege_seconds`]. + /// + /// # Errors + /// [`crate::error::SoulseekRs::NotConnected`] without a server connection. + pub fn check_privileges(&self) -> crate::error::Result<()> { + let Some(handle) = &self.server_handle else { + return Err(crate::error::SoulseekRs::NotConnected); + }; + let _ = handle.send(super::ServerMessage::CheckPrivileges); + Ok(()) + } + + /// Seconds of our own privileges left, or `None` until the server has + /// answered a [`Client::check_privileges`]. + #[must_use] + pub fn own_privilege_seconds(&self) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.own_privileges) + } + + /// Queued-upload states recorded since the last call, for a caller that + /// samples [`Client::uploads`] and would otherwise miss a peer that queued + /// and was served between two samples. + #[must_use] + pub fn take_upload_events(&self) -> Vec { + self.context + .write_safe() + .map(|mut ctx| ctx.take_upload_events()) + .unwrap_or_default() + } + + /// How many uploads run at once. Lowering it does not interrupt transfers + /// already in flight; the queue simply refills more slowly. + pub fn set_upload_slots(&self, slots: usize) { + if let Ok(mut ctx) = self.context.write_safe() { + ctx.upload_slots = slots.max(1); + } + } + + /// Drop everything `username` was queued or offered, then hand their slot to + /// whoever is next. + pub(crate) fn release_upload_slots( + client_context: &Arc>, + username: &str, + ) { + let freed = client_context + .write_safe() + .is_ok_and(|mut ctx| ctx.release_upload_slots(username)); + if freed { + Self::pump_upload_queue(client_context); + } + } + + /// Fill every free upload slot from the queue and send the offers. + /// + /// Called whenever the queue or the slot count could have changed: a new + /// request, a finished transfer, or a privileged list that re-ranked who is + /// waiting. + pub(crate) fn pump_upload_queue( + client_context: &Arc>, + ) { + let (registry, offers) = match client_context.write_safe() { + Ok(mut ctx) => ctx.pump_uploads(next_upload_token), + Err(e) => { + error!("[client] pump_upload_queue write: {}", e); + return; + } + }; + let Some(registry) = registry else { + return; + }; + for offer in offers { + let _ = registry.send_to_peer( + &offer.requester_key, + PeerMessage::ServeUpload { + token: offer.token, + filename: offer.virtual_path, + size: offer.size, + }, + ); + } + } + + /// Consume the upload job for `token` and stream the file to `host:port` + /// on a background thread. + pub(crate) fn spawn_serve( + client_context: &Arc>, + own_username: &str, + token: u32, + host: String, + port: u32, + ) { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + let Some(job) = ctx.uploads.remove(&token) else { + return; + }; + let bytes_sent = Arc::new(AtomicU64::new(0)); + let cancel = Arc::new(AtomicBool::new(false)); + ctx.active_uploads.insert( + token, + ActiveUpload { + username: job.downloader.clone(), + filename: job.virtual_path.clone(), + size: job.size, + bytes_sent: bytes_sent.clone(), + cancel: cancel.clone(), + status: UploadStatus::InProgress, + started: std::time::Instant::now(), + }, + ); + drop(ctx); + let own = own_username.to_string(); + let real_path = job.real_path; + let downloader = job.downloader; + let context = client_context.clone(); + thread::spawn(move || { + // Most downloaders sit behind a NAT, so dialling them is a gamble + // that costs a 20s timeout when it fails. Like the reference + // clients, ask the server to have them connect back at the same + // time; the first socket to arrive claims the transfer, and port 0 + // (the server not knowing where they listen) skips the dial. + let Some((connect_token, claimed)) = + Self::broker_upload(&context, token, &real_path, &downloader) + else { + return; + }; + if port == 0 { + return; + } + let stream = match upload_peer::dial(&host, port) { + Ok(stream) => stream, + Err(e) => { + debug!( + "[client] cannot dial {downloader} at {host}:{port} \ + for upload {token} ({e}); waiting for them to connect" + ); + return; + } + }; + if claimed + .compare_exchange( + false, + true, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return; + } + if let Ok(mut ctx) = context.write_safe() { + ctx.take_brokered_upload(connect_token); + } + let result = upload_peer::serve_direct( + stream, + &own, + token, + &real_path, + &bytes_sent, + &cancel, + ); + Self::finish_upload(&context, token, &real_path, result); + }); + } + + /// Park the upload under a correlation token and ask the server to have + /// `downloader` connect back to us with it. Returns the token and the claim + /// flag the dialling route races against; `None` fails the upload. + fn broker_upload( + context: &Arc>, + token: u32, + real_path: &Path, + downloader: &str, + ) -> Option<(u32, Arc)> { + let connect_token = next_connect_token(); + let (claimed, sender) = match context.write_safe() { + Ok(mut ctx) => ( + ctx.add_brokered_upload( + connect_token, + token, + real_path.to_path_buf(), + ), + ctx.server_sender.clone(), + ), + Err(_) => return None, + }; + let Some(sender) = sender else { + Self::fail_upload(context, token, "not connected to the server"); + return None; + }; + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_connect_to_peer( + connect_token, + downloader, + ConnectionType::F, + ), + )); + Some((connect_token, claimed)) + } + + /// The downloader of a brokered upload connected back: stream the file + /// over its socket on a background thread, unless the dial beat it. + pub(crate) fn serve_pierced( + client_context: &Arc>, + upload: BrokeredUpload, + stream: TcpStream, + ) { + if !upload.claim() { + return; + } + let Some((bytes_sent, cancel)) = + client_context.write_safe().ok().and_then(|mut ctx| { + let active = ctx.active_uploads.get_mut(&upload.token)?; + active.started = std::time::Instant::now(); + Some((active.bytes_sent.clone(), active.cancel.clone())) + }) + else { + return; + }; + let context = client_context.clone(); + thread::spawn(move || { + let result = upload_peer::serve_pierced( + stream, + upload.token, + &upload.real_path, + &bytes_sent, + &cancel, + ); + Self::finish_upload( + &context, + upload.token, + &upload.real_path, + result, + ); + }); + } + + /// Mark upload `token` failed and hand its slot to the next in line. + pub(crate) fn fail_upload( + client_context: &Arc>, + token: u32, + reason: &str, + ) { + if let Ok(mut ctx) = client_context.write_safe() + && let Some(upload) = ctx.active_uploads.get_mut(&token) + { + error!("[client] upload {token} to {}: {reason}", upload.username); + upload.status = UploadStatus::Failed(reason.to_string()); + } + Self::pump_upload_queue(client_context); + } + + /// Record how a served upload ended, report its rate to the server and free + /// its slot. + fn finish_upload( + context: &Arc>, + token: u32, + real_path: &Path, + result: std::io::Result, + ) { + let streamed = result.as_ref().ok().copied(); + let status = match &result { + Ok(_) => UploadStatus::Completed, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { + UploadStatus::Cancelled + } + Err(e) => { + error!("[client] serve {}: {}", real_path.display(), e); + UploadStatus::Failed(e.to_string()) + } + }; + let mut report = None; + if let Ok(mut ctx) = context.write_safe() + && let Some(upload) = ctx.active_uploads.get_mut(&token) + { + let secs = upload.started.elapsed().as_secs_f64(); + upload.status = status; + if let Some(bytes) = streamed + && secs > 0.0 + { + let speed = (bytes as f64 / secs) as u32; + ctx.last_upload_speed = speed; + report = ctx.server_sender.clone().map(|s| (s, speed)); + } + } + // The server folds each finished upload's rate into the average + // speed it shows other users for us. + if let Some((server, speed)) = report { + let _ = server.send(ServerMessage::SendMessage( + MessageFactory::build_send_upload_speed(speed), + )); + } + // The slot this transfer held is free now, so whoever is next in + // line gets it without waiting for another request to arrive. + Self::pump_upload_queue(context); + } + + pub(crate) fn process_failed_uploads( + client_context: Arc>, + username: &str, + filename: Option<&str>, + ) { + let failed_tokens = match client_context.read_safe() { + Ok(context) => { + collect_failed_tokens(&context.downloads, username, filename) + } + Err(e) => { + error!("[client] process_failed_uploads read: {}", e); + return; + } + }; + + if failed_tokens.is_empty() { + return; + } + + match client_context.write_safe() { + Ok(mut context) => { + for token in failed_tokens { + context.downloads.update_status( + token, + DownloadStatus::Failed(Some( + "The upload failed on the other side".to_string(), + )), + ); + context.downloads.remove(token); + } + } + Err(e) => { + error!("[client] process_failed_uploads write: {}", e); + } + } + } +} diff --git a/soulseek-rs-lib/src/dispatcher.rs b/soulseek-rs-lib/src/dispatcher.rs new file mode 100644 index 0000000..916771c --- /dev/null +++ b/soulseek-rs-lib/src/dispatcher.rs @@ -0,0 +1,76 @@ +use crate::message::{Message, handlers::Handlers}; +use std::sync::mpsc::Sender; + +use crate::debug; + +pub struct MessageDispatcher { + owner_name: String, + sender: Sender, + handlers: Handlers, +} + +impl MessageDispatcher { + #[must_use] + pub const fn new( + owner_name: String, + sender: Sender, + handlers: Handlers, + ) -> Self { + Self { + owner_name, + sender, + handlers, + } + } + + pub fn dispatch(&self, message: &mut Message) { + let code = message.get_message_code(); + + if let Some(handler) = self.handlers.get_handler(code) { + message.set_pointer(8); + handler.handle(message, self.sender.clone()); + } else { + debug!( + "[{}:dispatcher] No handler found for message code: {code}", + self.owner_name + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::MessageHandler; + use std::sync::mpsc::channel; + + struct Echo(u32); + + impl MessageHandler for Echo { + fn get_code(&self) -> u32 { + self.0 + } + fn handle(&self, _: &mut Message, sender: Sender) { + let _ = sender.send(self.0); + } + } + + fn framed(code: u32) -> Message { + Message::new_with_data(Message::new().write_int32(code).get_buffer()) + } + + #[test] + fn a_code_above_255_is_not_confused_with_its_low_byte() { + let (sender, received) = channel(); + let mut handlers = Handlers::new(); + handlers.register_handler(Echo(1)); + handlers.register_handler(Echo(1001)); + let dispatcher = + MessageDispatcher::new("test".to_string(), sender, handlers); + + dispatcher.dispatch(&mut framed(257)); + dispatcher.dispatch(&mut framed(1001)); + + assert_eq!(received.try_iter().collect::>(), [1001]); + } +} diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs new file mode 100644 index 0000000..02f274a --- /dev/null +++ b/soulseek-rs-lib/src/download_store.rs @@ -0,0 +1,589 @@ +use crate::types::{Download, DownloadStatus}; + +#[derive(Default)] +pub struct DownloadStore { + downloads: Vec, +} + +impl DownloadStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, download: Download) { + self.downloads.retain(|d| { + !(d.is_finished() + && d.username == download.username + && d.filename == download.filename) + }); + self.downloads.push(download); + } + + pub fn remove(&mut self, token: u32) { + self.downloads.retain(|d| d.token != token); + } + + #[must_use] + pub fn get_by_token(&self, token: u32) -> Option<&Download> { + self.downloads.iter().find(|d| d.token == token) + } + + pub fn get_by_token_mut(&mut self, token: u32) -> Option<&mut Download> { + self.downloads.iter_mut().find(|d| d.token == token) + } + + pub fn get_by_file_mut( + &mut self, + username: &str, + filename: &str, + ) -> Option<&mut Download> { + self.downloads + .iter_mut() + .find(|d| d.username == username && d.filename == filename) + } + + #[must_use] + pub fn tokens(&self) -> Vec { + self.downloads.iter().map(|d| d.token).collect() + } + + #[must_use] + pub const fn list(&self) -> &Vec { + &self.downloads + } + + pub fn update_status( + &mut self, + token: u32, + status: DownloadStatus, + ) -> bool { + let Some(download) = self.get_by_token_mut(token) else { + return false; + }; + if download.status.is_terminal() + || matches!( + (&download.status, &status), + ( + DownloadStatus::Paused { .. }, + DownloadStatus::InProgress { .. } + ) + ) + { + return false; + } + download.status = status; + true + } + + /// Atomically reserve a queued download for the peer that owns it. + /// + /// Every file-connection direction uses this before opening a partial, so + /// two sockets cannot both observe `Queued` and write the same target. + pub fn claim_for_peer( + &mut self, + token: u32, + username: &str, + ) -> Option { + let download = self.get_by_token_mut(token)?; + if download.username != username + || !matches!(download.status, DownloadStatus::Queued) + { + return None; + } + + download.status = DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: download.size, + speed_bytes_per_sec: 0.0, + }; + Some(download.clone()) + } + + pub fn cancel_by_file( + &mut self, + username: &str, + filename: &str, + ) -> Option { + let download = self.get_by_file_mut(username, filename)?; + if download.is_finished() { + return None; + } + download.status = DownloadStatus::Cancelled; + let _ = download.sender.send(DownloadStatus::Cancelled); + Some(download.clone()) + } + + pub fn update_queue_position( + &mut self, + username: &str, + filename: &str, + position: u32, + ) -> bool { + let Some(download) = self.get_by_file_mut(username, filename) else { + return false; + }; + download.queue_position = Some(position); + true + } + + pub fn remove_queued_by_file( + &mut self, + username: &str, + filename: &str, + ) -> bool { + let Some(index) = self.downloads.iter().position(|download| { + download.username == username + && download.filename == filename + && matches!(download.status, DownloadStatus::Queued) + }) else { + return false; + }; + + self.downloads.remove(index); + true + } + + /// Remove every download matching `username`/`filename` regardless of + /// status. Used before retrying a failed download so the stale entry (whose + /// md5-derived token collides with the retry's) can't shadow the fresh one. + /// Returns whether anything was removed. + pub fn remove_by_file(&mut self, username: &str, filename: &str) -> bool { + let before = self.downloads.len(); + self.downloads + .retain(|d| !(d.username == username && d.filename == filename)); + self.downloads.len() != before + } + + pub fn pause_by_file(&mut self, username: &str, filename: &str) -> bool { + let Some(download) = self.get_by_file_mut(username, filename) else { + return false; + }; + + let paused_status = match &download.status { + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + } => DownloadStatus::Paused { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + }, + DownloadStatus::Paused { .. } => return true, + _ => return false, + }; + + download.status = paused_status.clone(); + let _ = download.sender.send(paused_status); + true + } + + pub fn resume_by_file(&mut self, username: &str, filename: &str) -> bool { + let Some(download) = self.get_by_file_mut(username, filename) else { + return false; + }; + + let resumed_status = match &download.status { + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => DownloadStatus::InProgress { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + speed_bytes_per_sec: 0.0, + }, + DownloadStatus::InProgress { .. } => return true, + _ => return false, + }; + + download.status = resumed_status.clone(); + let _ = download.sender.send(resumed_status); + true + } +} + +/// Returns the tokens of queued downloads matching `username` (and optionally +/// a `filename`) after notifying their senders of `Failed`. +/// +/// Caller is responsible for then calling `update_status` and `remove` for +/// each token, typically under a write lock. +#[must_use] +pub fn collect_failed_tokens( + store: &DownloadStore, + username: &str, + filename: Option<&str>, +) -> Vec { + store + .list() + .iter() + .filter(|d| { + d.username == username && filename.is_none_or(|f| d.filename == *f) + }) + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .map(|d| { + let _ = d.sender.send(DownloadStatus::Failed(Some( + "The upload failed on the other side".to_string(), + ))); + d.token + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::DownloadMetadata; + use std::sync::mpsc; + + fn make_download(token: u32, status: DownloadStatus) -> Download { + Download { + username: "peer".to_string(), + filename: format!("file-{token}.mp3"), + token, + size: 100, + download_directory: "test".to_string(), + status, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + } + } + + #[test] + fn add_get_remove_roundtrip() { + let mut store = DownloadStore::new(); + store.add(make_download(123, DownloadStatus::Queued)); + + assert!(store.get_by_token(123).is_some()); + assert_eq!(store.tokens(), vec![123]); + assert_eq!(store.list().len(), 1); + + store.remove(123); + assert!(store.get_by_token(123).is_none()); + assert!(store.list().is_empty()); + } + + #[test] + fn update_queue_position_sets_field_when_match() { + let mut store = DownloadStore::new(); + let mut download = make_download(1, DownloadStatus::Queued); + download.username = "peer".to_string(); + download.filename = "song.mp3".to_string(); + store.add(download); + + assert!(store.update_queue_position("peer", "song.mp3", 42)); + assert_eq!(store.get_by_token(1).unwrap().queue_position, Some(42)); + + assert!(!store.update_queue_position("peer", "missing.mp3", 1)); + assert!(!store.update_queue_position("other", "song.mp3", 1)); + } + + #[test] + fn cancel_by_file_flips_an_unfinished_download_and_tells_its_channel() { + let mut store = DownloadStore::new(); + let (tx, rx) = mpsc::channel(); + let mut queued = make_download(1, DownloadStatus::Queued); + queued.sender = tx; + store.add(queued); + store.add(make_download( + 2, + DownloadStatus::InProgress { + bytes_downloaded: 5, + total_bytes: 100, + speed_bytes_per_sec: 1.0, + }, + )); + store.add(make_download( + 3, + DownloadStatus::Paused { + bytes_downloaded: 5, + total_bytes: 100, + }, + )); + store.add(make_download(4, DownloadStatus::Completed)); + store.add(make_download(5, DownloadStatus::Failed(None))); + + assert!(store.cancel_by_file("peer", "file-1.mp3").is_some()); + assert!(matches!(rx.try_recv(), Ok(DownloadStatus::Cancelled))); + assert!(store.cancel_by_file("peer", "file-2.mp3").is_some()); + assert!(store.cancel_by_file("peer", "file-3.mp3").is_some()); + assert!(store.cancel_by_file("peer", "file-4.mp3").is_none()); + assert!(store.cancel_by_file("peer", "file-5.mp3").is_none()); + assert!(store.cancel_by_file("peer", "file-9.mp3").is_none()); + for token in 1..=3 { + let download = store.get_by_token(token).unwrap(); + assert!(matches!(download.status, DownloadStatus::Cancelled)); + assert!(download.is_finished()); + } + } + + #[test] + fn a_cancelled_download_stays_cancelled() { + let mut store = DownloadStore::new(); + store.add(make_download(1, DownloadStatus::Queued)); + assert!(store.cancel_by_file("peer", "file-1.mp3").is_some()); + assert!(!store.update_status( + 1, + DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: 100, + speed_bytes_per_sec: 0.0, + }, + )); + assert!( + !store + .update_status(1, DownloadStatus::Failed(Some("late".into()))) + ); + assert!(matches!( + store.get_by_token(1).unwrap().status, + DownloadStatus::Cancelled + )); + assert!(store.cancel_by_file("peer", "file-1.mp3").is_none()); + assert!(!store.resume_by_file("peer", "file-1.mp3")); + assert!(!store.pause_by_file("peer", "file-1.mp3")); + } + + #[test] + fn add_evicts_a_finished_entry_for_the_same_file() { + let mut store = DownloadStore::new(); + store.add(make_download(1, DownloadStatus::Cancelled)); + store.add(make_download(2, DownloadStatus::Queued)); + let mut fresh = make_download(3, DownloadStatus::Queued); + fresh.filename = "file-1.mp3".to_string(); + store.add(fresh); + let mut again = make_download(4, DownloadStatus::Queued); + again.filename = "file-2.mp3".to_string(); + store.add(again); + + assert_eq!(store.tokens(), vec![2, 3, 4], "only the dead row goes"); + assert!(store.cancel_by_file("peer", "file-1.mp3").is_some()); + assert!(matches!( + store.get_by_token(3).unwrap().status, + DownloadStatus::Cancelled + )); + } + + #[test] + fn pause_then_resume_in_progress_download() { + let mut store = DownloadStore::new(); + let (tx, rx) = mpsc::channel(); + let mut download = make_download( + 1, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + ); + download.sender = tx; + store.add(download); + + assert!(store.pause_by_file("peer", "file-1.mp3")); + assert!(matches!( + store.get_by_token(1).unwrap().status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + rx.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(store.resume_by_file("peer", "file-1.mp3")); + assert!(matches!( + store.get_by_token(1).unwrap().status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); + } + + #[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(); + store.add(make_download(123, DownloadStatus::Queued)); + store.add(make_download( + 456, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + )); + // Override second download's filename so they don't collide + store.get_by_token_mut(456).unwrap().filename = + "active.mp3".to_string(); + store.get_by_token_mut(123).unwrap().filename = + "queued.mp3".to_string(); + + assert!(store.remove_queued_by_file("peer", "queued.mp3")); + assert!(!store.remove_queued_by_file("peer", "active.mp3")); + assert!(store.get_by_token(123).is_none()); + assert!(store.get_by_token(456).is_some()); + } + + #[test] + fn remove_by_file_removes_regardless_of_status() { + let mut store = DownloadStore::new(); + // A failed download (the retry case) plus a same-name duplicate that a + // token-migration could have left behind — both must go. + let mut failed = make_download(1, DownloadStatus::Failed(None)); + failed.filename = "song.mp3".to_string(); + store.add(failed); + let mut dup = make_download(2, DownloadStatus::Queued); + dup.filename = "song.mp3".to_string(); + store.add(dup); + let mut other = make_download(3, DownloadStatus::Queued); + other.filename = "other.mp3".to_string(); + store.add(other); + + assert!(store.remove_by_file("peer", "song.mp3")); + assert!(store.get_by_token(1).is_none()); + assert!(store.get_by_token(2).is_none()); + assert!(store.get_by_token(3).is_some(), "other file untouched"); + assert!(!store.remove_by_file("peer", "song.mp3"), "idempotent"); + } + + #[test] + fn collect_failed_tokens_collects_only_queued_downloads() { + let mut store = DownloadStore::new(); + let (tx_active, rx_active) = mpsc::channel(); + let (tx_paused, rx_paused) = mpsc::channel(); + let (tx_completed, rx_completed) = mpsc::channel(); + let (tx_queued, _rx_queued) = mpsc::channel(); + + let mut active = make_download( + 1, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + ); + active.sender = tx_active; + + let mut paused = make_download( + 2, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100, + }, + ); + paused.sender = tx_paused; + + let mut completed = make_download(3, DownloadStatus::Completed); + completed.sender = tx_completed; + + let mut queued = make_download(4, DownloadStatus::Queued); + queued.sender = tx_queued; + + store.add(active); + store.add(paused); + store.add(completed); + store.add(queued); + + let tokens = collect_failed_tokens(&store, "peer", None); + + assert_eq!(tokens, vec![4], "only the queued download may fail"); + assert!( + rx_active.try_recv().is_err(), + "an in-progress transfer must not be notified of failure" + ); + assert!( + rx_paused.try_recv().is_err(), + "a paused transfer must not be notified of failure" + ); + assert!( + rx_completed.try_recv().is_err(), + "a completed download must not be notified of failure" + ); + } + + #[test] + fn collect_failed_tokens_notifies_and_lists_matching() { + let mut store = DownloadStore::new(); + let (tx_match, rx_match) = mpsc::channel(); + let (tx_other_user, _rx_other_user) = mpsc::channel(); + let (tx_other_file, _rx_other_file) = mpsc::channel(); + + let mut a = make_download(1, DownloadStatus::Queued); + a.sender = tx_match; + a.username = "peer".to_string(); + a.filename = "song.mp3".to_string(); + + let mut b = make_download(2, DownloadStatus::Queued); + b.sender = tx_other_user; + b.username = "other".to_string(); + b.filename = "song.mp3".to_string(); + + let mut c = make_download(3, DownloadStatus::Queued); + c.sender = tx_other_file; + c.username = "peer".to_string(); + c.filename = "different.mp3".to_string(); + + store.add(a); + store.add(b); + store.add(c); + + let tokens = collect_failed_tokens(&store, "peer", Some("song.mp3")); + + assert_eq!(tokens, vec![1]); + assert!(matches!( + rx_match.try_recv().unwrap(), + DownloadStatus::Failed(_) + )); + } +} diff --git a/soulseek-rs-lib/src/error.rs b/soulseek-rs-lib/src/error.rs new file mode 100644 index 0000000..b25df46 --- /dev/null +++ b/soulseek-rs-lib/src/error.rs @@ -0,0 +1,79 @@ +use std::{error::Error, fmt}; + +/// Custom error type for the Soulseek download library +#[derive(Debug)] +pub enum SoulseekRs { + NetworkError(std::io::Error), + /// Authentication failed during login + AuthenticationFailed, + /// Error parsing messages or data + ParseError(String), + /// Operation timed out + Timeout, + /// Connection was closed unexpectedly + ConnectionClosed, + /// Invalid message format or content + InvalidMessage(String), + /// Server not connected + NotConnected, + /// Compression/decompression error + CompressionError(String), + /// A lock was poisoned by a panic in another thread + LockPoisoned, +} + +impl fmt::Display for SoulseekRs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NetworkError(err) => { + write!(f, "Network error: {err}") + } + Self::AuthenticationFailed => { + write!(f, "Authentication failed") + } + Self::ParseError(msg) => write!(f, "Parse error: {msg}"), + Self::Timeout => write!(f, "Operation timed out"), + Self::ConnectionClosed => write!(f, "Connection closed"), + Self::InvalidMessage(msg) => { + write!(f, "Invalid message: {msg}") + } + Self::NotConnected => write!(f, "Not connected to server"), + Self::CompressionError(msg) => { + write!(f, "Compression error: {msg}") + } + Self::LockPoisoned => { + write!(f, "Lock poisoned by panicking thread") + } + } + } +} + +impl Error for SoulseekRs { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::NetworkError(err) => Some(err), + _ => None, + } + } +} + +impl From for SoulseekRs { + fn from(err: std::io::Error) -> Self { + Self::NetworkError(err) + } +} + +impl From for SoulseekRs { + fn from(err: std::num::ParseIntError) -> Self { + Self::ParseError(format!("Integer parse error: {err}")) + } +} + +impl From for SoulseekRs { + fn from(err: String) -> Self { + Self::CompressionError(err) + } +} + +/// Result type alias for the Soulseek library +pub type Result = std::result::Result; diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs new file mode 100644 index 0000000..45d3263 --- /dev/null +++ b/soulseek-rs-lib/src/lib.rs @@ -0,0 +1,44 @@ +// The README is the crate's front page, and `cargo test --doc` compiles every +// example in it, so a signature change cannot leave the advertised usage stale. +#![doc = include_str!("../README.md")] + +// The public surface is the client, its types, the wire codec and the share +// index. The actors, dispatcher, peer plumbing and download store are how the +// client is built, not what a host programs against, so they stay inside the +// crate and a change to them is not a change to the library's API. +pub(crate) mod actor; +pub mod client; +pub(crate) mod dispatcher; +pub(crate) mod download_store; +pub mod error; +pub mod message; +pub(crate) mod peer; +pub mod shares; +pub mod types; +#[macro_use] +pub mod utils; + +// Prelude module for commonly used items +pub mod prelude { + pub use crate::actor::server_actor::PeerAddress; + pub use crate::types::{ + 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}; +} + +// Re-export commonly used types +pub use actor::server_actor::{PeerAddress, UserMessage}; +pub use client::{CancelHandle, Client, ClientSettings}; +pub use error::{Result, SoulseekRs}; +pub use message::peer::{SharedDirectory, SharedFileEntry}; +pub use peer::{ConnectionType, ParseConnectionTypeError}; +pub use types::{ + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo, + RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats, + UserStatus, +}; diff --git a/soulseek-rs-lib/src/message/distributed.rs b/soulseek-rs-lib/src/message/distributed.rs new file mode 100644 index 0000000..9917ce6 --- /dev/null +++ b/soulseek-rs-lib/src/message/distributed.rs @@ -0,0 +1,249 @@ +//! The distributed search network's wire format. +//! +//! Frames carry a one-byte code, unlike the four-byte server and peer codes, +//! and a leaf only ever reads them: 3 Search, 4 BranchLevel, 5 BranchRoot, +//! 93 EmbeddedMessage. + +use crate::message::Message; +use crate::message::server::MessageFactory; + +/// A search relayed through the tree carries this in its first field; anything +/// else is a frame we do not understand and must not answer. +const SEARCH_IDENTIFIER: u32 = 49; + +/// What a parent sends a leaf. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Distributed { + Search { + username: String, + token: u32, + query: String, + }, + BranchLevel(i32), + BranchRoot(String), +} + +/// Decode one framed distributed message (length prefix included), or `None` +/// for a ping, an unknown code or a truncated frame. +#[must_use] +pub fn parse(message: &mut Message) -> Option { + if message.get_size() < 5 { + return None; + } + message.set_pointer(5); + parse_at(message, message.get_init_code()) +} + +/// Decode the body that follows a one-byte `code`, the pointer already past +/// it. A code-93 envelope is unwrapped exactly once, as Nicotine+ does: an +/// envelope inside an envelope is nobody's search. +#[must_use] +pub fn parse_at(message: &mut Message, code: u8) -> Option { + let code = if code == 93 { + message.read_int8() + } else { + code + }; + match code { + 3 => { + if remaining(message) < 4 + || message.read_int32() != SEARCH_IDENTIFIER + { + return None; + } + let username = message.read_string(); + if remaining(message) < 4 { + return None; + } + let token = message.read_int32(); + let query = message.read_string(); + (!username.is_empty() && !query.is_empty()).then_some( + Distributed::Search { + username, + token, + query, + }, + ) + } + 4 => (remaining(message) >= 4) + .then(|| Distributed::BranchLevel(message.read_int32() as i32)), + 5 => { + let root = message.read_string(); + (!root.is_empty()).then_some(Distributed::BranchRoot(root)) + } + _ => None, + } +} + +const fn remaining(message: &mut Message) -> usize { + message.get_size().saturating_sub(message.get_pointer()) +} + +/// A distributed frame with the one-byte `code`: what a parent (or a test +/// standing in for one) sends down the tree. +fn framed(code: u8, body: impl FnOnce(&mut Message)) -> Message { + let mut message = Message::new(); + message.write_int8(code); + body(&mut message); + message +} + +#[must_use] +pub fn build_search(username: &str, token: u32, query: &str) -> Message { + framed(3, |m| { + m.write_int32(SEARCH_IDENTIFIER) + .write_string(username) + .write_int32(token) + .write_string(query); + }) +} + +#[must_use] +pub fn build_branch_level(level: i32) -> Message { + framed(4, |m| { + m.write_int32(level as u32); + }) +} + +/// 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, + accept_children: bool, +) -> Vec { + vec![ + MessageFactory::build_have_no_parent(!has_parent), + MessageFactory::build_branch_root(root), + MessageFactory::build_branch_level(level), + MessageFactory::build_accept_children(accept_children), + ] +} + +#[must_use] +pub fn build_branch_root(root: &str) -> Message { + framed(5, |m| { + m.write_string(root); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_distributed_search_carries_who_asked_and_what_for() { + let frame = build_search("seeker", 77, "aphex twin"); + let mut message = Message::new_with_data(frame.get_buffer()); + assert_eq!( + parse(&mut message), + Some(Distributed::Search { + username: "seeker".to_string(), + token: 77, + query: "aphex twin".to_string(), + }) + ); + } + + #[test] + fn a_search_with_the_wrong_identifier_is_dropped() { + let mut body = Message::new(); + body.write_int8(3) + .write_int32(7) + .write_string("seeker") + .write_int32(1) + .write_string("q"); + let mut message = Message::new_with_data(body.get_buffer()); + assert_eq!(parse(&mut message), None); + } + + #[test] + fn branch_level_and_root_frames_parse() { + let mut level = + Message::new_with_data(build_branch_level(2).get_buffer()); + assert_eq!(parse(&mut level), Some(Distributed::BranchLevel(2))); + let mut root = + Message::new_with_data(build_branch_root("rooty").get_buffer()); + assert_eq!( + parse(&mut root), + Some(Distributed::BranchRoot("rooty".to_string())) + ); + } + + #[test] + fn an_embedded_search_unwraps_to_the_search_itself() { + let inner = build_search("seeker", 5, "q").get_data(); + let mut body = Message::new(); + body.write_int8(93).write_raw_bytes(inner); + let mut message = Message::new_with_data(body.get_buffer()); + assert!(matches!( + parse(&mut message), + Some(Distributed::Search { token: 5, .. }) + )); + } + + #[test] + fn unknown_or_truncated_frames_parse_to_nothing() { + let mut ping = + Message::new_with_data(Message::new().write_int8(0).get_buffer()); + assert_eq!(parse(&mut ping), None); + let mut short = Message::new_with_data(vec![1, 0, 0, 0, 3]); + assert_eq!(parse(&mut short), None); + let mut level = Message::new_with_data(vec![3, 0, 0, 0, 4, 1, 0]); + assert_eq!(parse(&mut level), None, "a level needs four bytes"); + let mut root = + Message::new_with_data(build_branch_root("").get_buffer()); + assert_eq!(parse(&mut root), None, "an empty root is no root"); + let mut blank = + Message::new_with_data(build_search("seeker", 1, "").get_buffer()); + assert_eq!(parse(&mut blank), None, "an empty query is no search"); + } + + // An envelope inside an envelope is unwrapped once and found empty; a + // frame that is nothing but envelopes must not recurse its way off the + // stack. + #[test] + fn a_nested_envelope_is_not_unwrapped_twice() { + let mut nested = Message::new(); + nested.write_int8(93).write_raw_bytes( + Message::new() + .write_int8(93) + .write_raw_bytes(build_search("seeker", 5, "q").get_data()) + .get_data(), + ); + let mut message = Message::new_with_data(nested.get_buffer()); + assert_eq!(parse(&mut message), None); + + let mut onions = Message::new_with_data( + Message::new() + .write_raw_bytes(vec![93u8; 64 * 1024]) + .get_buffer(), + ); + assert_eq!(parse(&mut onions), None); + } + + // The frame as the protocol documents it, byte for byte: code 3, the + // constant 49, then user, token and query. + #[test] + fn a_search_frame_matches_the_documented_layout() { + let mut bytes = vec![3u8, 49, 0, 0, 0]; + bytes.extend(2u32.to_le_bytes()); + bytes.extend(b"me"); + bytes.extend(9u32.to_le_bytes()); + bytes.extend(1u32.to_le_bytes()); + bytes.extend(b"q"); + let mut framed = (bytes.len() as u32).to_le_bytes().to_vec(); + framed.extend(&bytes); + assert_eq!(build_search("me", 9, "q").get_buffer(), framed); + assert_eq!( + parse(&mut Message::new_with_data(framed)), + Some(Distributed::Search { + username: "me".to_string(), + token: 9, + query: "q".to_string() + }) + ); + } +} diff --git a/soulseek-rs-lib/src/message/handlers.rs b/soulseek-rs-lib/src/message/handlers.rs new file mode 100644 index 0000000..1eb8ee5 --- /dev/null +++ b/soulseek-rs-lib/src/message/handlers.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; + +use crate::message::Message; +use std::sync::mpsc::Sender; + +pub trait MessageHandler: Send { + fn get_code(&self) -> u32; + fn handle(&self, message: &mut Message, sender: Sender); +} +pub struct Handlers { + handlers: HashMap + Send>>, +} + +impl Default for Handlers { + fn default() -> Self { + Self::new() + } +} + +impl Handlers { + #[must_use] + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + pub fn register_handler(&mut self, handler: H) -> &mut Self + where + H: 'static + MessageHandler + Send + Sync, + { + self.handlers.insert(handler.get_code(), Box::new(handler)); + self + } + #[must_use] + pub fn get_handler( + &self, + code: u32, + ) -> Option<&(dyn MessageHandler + Send)> { + self.handlers.get(&code).map(|v| &**v) + } +} diff --git a/soulseek-rs-lib/src/message/message_reader.rs b/soulseek-rs-lib/src/message/message_reader.rs new file mode 100644 index 0000000..33ccb07 --- /dev/null +++ b/soulseek-rs-lib/src/message/message_reader.rs @@ -0,0 +1,158 @@ +use std::io::{self, Read}; +use std::{collections::VecDeque, net::TcpStream}; + +use crate::message::Message; + +// Soulseek messages are length-prefixed (u32 LE size, then payload). TCP gives us +// arbitrary-sized chunks, so we accumulate into a buffer and only emit a Message +// once size + 4 bytes are available. + +pub struct MessageReader { + buffer: VecDeque, +} + +impl Default for MessageReader { + fn default() -> Self { + Self::new() + } +} + +impl MessageReader { + #[must_use] + pub const fn new() -> Self { + Self { + buffer: VecDeque::new(), + } + } + + #[cfg(test)] + #[must_use] + pub fn new_with_buffer(buffer: Vec) -> Self { + Self { + buffer: buffer.into(), + } + } + + pub fn read_from_socket( + &mut self, + stream: &mut TcpStream, + ) -> io::Result<()> { + let mut temp_buffer = [0; 1024]; // Temporary buffer for reading from the socket + let bytes_read = stream.read(&mut temp_buffer)?; + // On a TCP socket a zero-byte read is the other end hanging up. Passing + // that back as success left the caller polling a dead socket forever, + // which is how a server that closes cleanly went unnoticed. + if bytes_read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "the remote end closed the connection", + )); + } + + // Add the read bytes to the internal buffer + self.buffer.extend(&temp_buffer[..bytes_read]); + + Ok(()) + } + + #[must_use] + pub fn buffer_len(&self) -> usize { + self.buffer.len() + } + + pub fn get_buffer(&mut self) -> Vec { + self.buffer.drain(..).collect() + } + + pub fn extract_message(&mut self) -> io::Result> { + let bytes_read = self.buffer.len(); + if bytes_read < 4 { + return Ok(None); + } + + let message_size = u32::from_le_bytes([ + self.buffer[0], + self.buffer[1], + self.buffer[2], + self.buffer[3], + ]) as usize; + + let total_size = message_size + 4; + + if bytes_read < total_size { + return Ok(None); + } + + let message_buffer: Vec = self.buffer.drain(..total_size).collect(); + Ok(Some(Message::new_with_data(message_buffer))) + } +} + +#[cfg(test)] +mod tests { + use crate::message::MessageReader; + + #[test] + fn test_extract_message() { + let buffer: Vec = [ + 8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101, 8, 0, 0, 0, 112, + 97, 115, 115, 119, 111, 114, 100, 160, 0, 0, 0, 32, 0, 0, 0, 100, + 53, 49, 99, 57, 97, 55, 101, 57, 51, 53, 51, 55, 52, 54, 97, 54, + 48, 50, 48, 102, 57, 54, 48, 50, 100, 52, 53, 50, 57, 50, 57, 17, + 0, 0, 0, + ] + .to_vec(); + let mut buffered_reader = MessageReader::new_with_buffer(buffer); + let mut message = buffered_reader.extract_message().unwrap().unwrap(); + assert_eq!( + message.get_data(), + vec![8, 0, 0, 0, 117, 115, 101, 114, 110, 97, 109, 101] + ); + assert_eq!(message.read_string(), "username"); + } + #[test] + fn test_extract_message_incomplete_message() { + let incomplete_buffer = vec![1, 2, 3]; + let mut buffered_reader = + MessageReader::new_with_buffer(incomplete_buffer); + + let result = buffered_reader.extract_message(); + assert_eq!(None, result.unwrap()); + + let rest: Vec = buffered_reader + .buffer + .drain(..buffered_reader.buffer.len()) + .collect(); + + assert!(buffered_reader.buffer.is_empty()); + assert_eq!(vec![1, 2, 3], rest); + } + + /// A hangup has to reach the caller as an error. Reported as success it + /// leaves the actors polling a socket that will never speak again, which is + /// how a server that closed cleanly went unnoticed. + #[test] + fn a_closed_connection_reads_as_an_error_not_as_success() { + use std::io::Write; + use std::net::{TcpListener, TcpStream}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let mut ours = TcpStream::connect(addr).unwrap(); + let (mut theirs, _) = listener.accept().unwrap(); + + theirs.write_all(&[1, 2, 3]).unwrap(); + theirs.flush().unwrap(); + drop(theirs); + + let mut reader = MessageReader::new(); + // Whatever was sent before the close still arrives. + while reader.buffer_len() < 3 { + reader.read_from_socket(&mut ours).unwrap(); + } + let error = reader + .read_from_socket(&mut ours) + .expect_err("a closed connection is not a successful read"); + assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof); + } +} diff --git a/soulseek-rs-lib/src/message/mod.rs b/soulseek-rs-lib/src/message/mod.rs new file mode 100644 index 0000000..03d70d9 --- /dev/null +++ b/soulseek-rs-lib/src/message/mod.rs @@ -0,0 +1,448 @@ +pub use crate::{debug, error, info, trace, warn}; + +pub mod distributed; +pub mod handlers; +mod message_reader; +pub mod peer; +pub mod server; + +pub use handlers::{Handlers, MessageHandler}; +pub use message_reader::MessageReader; + +use std::str; + +#[derive(Debug, PartialEq, Eq)] +#[allow(dead_code)] +pub enum MessageType { + Server, + Peer, + PeerInit, + Distributed, +} + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Message { + data: Vec, + pointer: usize, +} + +impl Default for Message { + fn default() -> Self { + Self::new() + } +} + +impl Message { + #[must_use] + pub const fn new() -> Self { + Self { + data: Vec::new(), + pointer: 0, + } + } + + #[must_use] + pub fn get_message_code(&self) -> u32 { + if self.data.len() < 8 { + return 0; + } + self.data[4..8].try_into().map_or(0, u32::from_le_bytes) + } + + /// The one-byte code of a peer init frame (PierceFirewall 0, PeerInit 1), + /// the only messages whose code is not four bytes wide. + #[must_use] + pub fn get_init_code(&self) -> u8 { + self.data.get(4).copied().unwrap_or(0) + } + + #[must_use] + pub const fn new_with_data(data: Vec) -> Self { + Self { data, pointer: 0 } + } + pub const fn set_pointer(&mut self, pointer: usize) { + self.pointer = pointer; + } + + pub const fn get_pointer(&mut self) -> usize { + self.pointer + } + + pub const fn get_size(&mut self) -> usize { + self.data.len() + } + + #[must_use] + pub fn get_data(&self) -> Vec { + self.data.clone() + } + + #[must_use] + pub fn get_slice(&self, from: usize, to: usize) -> Vec { + self.data[from..to].to_vec() + } + + /// gets buffer with the message length prepended + #[must_use] + pub fn get_buffer(&self) -> Vec { + let mut b = vec![0u8; 4]; + let length = self.data.len() as u32; + b[0..4].copy_from_slice(&length.to_le_bytes()); + let mut combined = b; + combined.extend(&self.data); + combined + } + + pub fn read_string(&mut self) -> String { + if self.pointer + 4 > self.data.len() { + self.pointer = self.data.len(); + return String::new(); + } + let size = u32::from_le_bytes([ + self.data[self.pointer], + self.data[self.pointer + 1], + self.data[self.pointer + 2], + self.data[self.pointer + 3], + ]) as usize; + + self.pointer += 4; + // A truncated / malformed length field from an untrusted peer must not + // slice past the buffer end; treat it as an empty string and consume + // the rest of the message. + if self.pointer + size > self.data.len() { + self.pointer = self.data.len(); + return String::new(); + } + let data = &self.data[self.pointer..self.pointer + size]; + self.pointer += size; + + match String::from_utf8(data.to_vec()) { + Ok(s) => s, + Err(_) => data.iter().map(|&b| b as char).collect::(), + } + } + + pub fn read_int8(&mut self) -> u8 { + if self.pointer >= self.data.len() { + return 0; + } + let val = self.data[self.pointer]; + self.pointer += 1; + val + } + + #[allow(dead_code)] + pub fn read_int64(&mut self) -> u64 { + if self.pointer + 8 > self.data.len() { + return 0; + } + let val = u64::from_le_bytes([ + self.data[self.pointer], + self.data[self.pointer + 1], + self.data[self.pointer + 2], + self.data[self.pointer + 3], + self.data[self.pointer + 4], + self.data[self.pointer + 5], + self.data[self.pointer + 6], + self.data[self.pointer + 7], + ]); + self.pointer += 8; + val + } + + pub fn read_int32(&mut self) -> u32 { + if self.pointer + 4 > self.data.len() { + return 0; + } + + let val = u32::from_le_bytes([ + self.data[self.pointer], + self.data[self.pointer + 1], + self.data[self.pointer + 2], + self.data[self.pointer + 3], + ]); + self.pointer += 4; + val + } + + pub fn read_bool(&mut self) -> bool { + if self.pointer >= self.data.len() { + return false; + } + let val = self.data[self.pointer] == 1; + self.pointer += 1; + val + } + + pub fn write_string(&mut self, val: &str) -> &mut Self { + let length = val.len() as u32; + self.data.extend_from_slice(&length.to_le_bytes()); + self.data.extend_from_slice(val.as_bytes()); + self + } + + pub fn write_int8(&mut self, value: u8) -> &mut Self { + self.data.extend_from_slice(&value.to_le_bytes()); + self + } + + pub fn write_int32(&mut self, value: u32) -> &mut Self { + self.data.extend_from_slice(&value.to_le_bytes()); + self + } + + #[allow(dead_code)] + pub fn write_int64(&mut self, value: u64) -> &mut Self { + self.data.extend_from_slice(&value.to_le_bytes()); + self + } + pub fn write_raw_bytes(&mut self, value: Vec) -> &mut Self { + self.data.extend_from_slice(&value); + self + } + + pub fn write_bool(&mut self, value: bool) -> &mut Self { + self.data.push(u8::from(value)); + self + } + + pub fn get_message_name( + &self, + msg_type: MessageType, + code: u32, + ) -> Result<&str, Error> { + match msg_type { + MessageType::Server => match code { + 1 => Ok("Login"), + 2 => Ok("SetWaitPort"), + 3 => Ok("GetPeerAddress"), + 5 => Ok("WatchUser"), + 6 => Ok("UnwatchUser"), + 7 => Ok("GetUserStatus"), + 13 => Ok("SayChatroom"), + 14 => Ok("JoinRoom"), + 15 => Ok("LeaveRoom"), + 18 => Ok("ConnectToPeer"), + 22 => Ok("MessageUser"), + 23 => Ok("MessageAcked"), + 26 => Ok("FileSearch"), + 28 => Ok("SetStatus"), + 32 => Ok("ServerPing"), + 35 => Ok("SharedFoldersFiles"), + 36 => Ok("GetUserStats"), + 41 => Ok("Relogged"), + 42 => Ok("UserSearch"), + 64 => Ok("RoomList"), + 69 => Ok("PrivilegedUsers"), + 71 => Ok("HaveNoParent"), + 83 => Ok("ParentMinSpeed"), + 84 => Ok("ParentSpeedRatio"), + 92 => Ok("CheckPrivileges"), + 93 => Ok("EmbeddedMessage"), + 100 => Ok("AcceptChildren"), + 102 => Ok("PossibleParents"), + 104 => Ok("WishlistInterval"), + 121 => Ok("SendUploadSpeed"), + 160 => Ok("ExcludedSearchPhrases"), + 1001 => Ok("CantConnectToPeer"), + _ => Err(Error(format!("Unknown server message code: {code}"))), + }, + MessageType::PeerInit => match code { + 0 => Ok("PierceFireWall"), + 1 => Ok("PeerInit"), + _ => Err(Error(format!( + "Unknown peer init message code: {code}" + ))), + }, + MessageType::Peer => match code { + 1 => Ok("PeerInit"), + 4 => Ok("GetShareFileList"), + 5 => Ok("SharedFileListResponse"), + 9 => Ok("FileSearchResponse"), + 15 => Ok("UserInfoRequest"), + 16 => Ok("UserInfoResponse"), + 36 => Ok("FolderContentsRequest"), + 37 => Ok("FolderContentsResponse"), + 40 => Ok("TransferRequest"), + 41 => Ok("TransferResponse"), + 43 => Ok("QueueUpload"), + 44 => Ok("PlaceInQueueResponse"), + 46 => Ok("UploadFailed"), + 50 => Ok("UploadDenied"), + 51 => Ok("PlaceInQueueRequest"), + _ => Err(Error(format!("Unknown peer message code: {code}"))), + }, + MessageType::Distributed => match code { + 3 => Ok("SearchRequest"), + 4 => Ok("BranchLevel"), + 5 => Ok("BranchRoot"), + 93 => Ok("EmbeddedMessage"), + _ => Err(Error(format!( + "Unknown distributed message code: {code}" + ))), + }, + } + } +} + +/// Build a received message the way the dispatcher hands one to a handler: +/// 8 bytes of frame header, then the fields, with the pointer parked at the +/// start of the payload. +#[cfg(test)] +pub fn framed(build: impl FnOnce(&mut Message)) -> Message { + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + build(&mut message); + message.set_pointer(8); + message +} + +#[test] +fn test_get_buffer() { + let message = Message::new_with_data( + [ + 26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104, 101, 32, 119, + 101, 101, 107, 101, 110, 100, + ] + .to_vec(), + ); + assert_eq!( + message.get_buffer(), + [ + 23, 0, 0, 0, 26, 0, 0, 0, 219, 178, 47, 28, 11, 0, 0, 0, 116, 104, + 101, 32, 119, 101, 101, 107, 101, 110, 100, + ] + .to_vec() + ); + + let message = Message::new_with_data( + [ + 1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110, + 95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49, + 51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0, 50, 101, + 100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98, + 98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 17, 0, 0, + 0, + ] + .to_vec(), + ); + assert_eq!( + message.get_buffer(), + [ + 84, 0, 0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, + 95, 105, 110, 95, 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, + 0, 0, 0, 49, 51, 51, 55, 53, 49, 51, 55, 160, 0, 0, 0, 32, 0, 0, 0, + 50, 101, 100, 102, 53, 49, 100, 48, 51, 55, 57, 52, 51, 55, 56, + 102, 56, 98, 98, 54, 51, 49, 48, 100, 52, 54, 48, 99, 50, 50, 98, + 49, 17, 0, 0, 0, + ] + .to_vec() + ); +} +#[test] +fn test_read_string() { + let data = vec![ + 5, 0, 0, 0, // size = 5 + 72, 101, 108, 108, 111, // "Hello" + ]; + let mut test_data = Message::new_with_data(data); + assert_eq!(test_data.read_string(), "Hello"); +} + +// Robustness against malformed / truncated network input. A remote server or +// peer is untrusted and can send a message whose declared field lengths exceed +// the bytes actually present; parsing must not panic (which would unwind out of +// the actor job and permanently kill a thread-pool worker). +#[test] +fn read_string_with_oversized_length_does_not_panic() { + // size = 0xFFFFFFFF but no payload bytes follow. + let mut msg = Message::new_with_data(vec![255, 255, 255, 255]); + assert_eq!(msg.read_string(), ""); +} + +#[test] +fn read_string_with_truncated_payload_does_not_panic() { + // size = 10 but only 2 payload bytes present. + let mut msg = Message::new_with_data(vec![10, 0, 0, 0, 65, 66]); + assert_eq!(msg.read_string(), ""); +} + +#[test] +fn read_string_with_missing_length_prefix_does_not_panic() { + // Only 2 bytes: not even a full 4-byte length prefix. + let mut msg = Message::new_with_data(vec![1, 2]); + assert_eq!(msg.read_string(), ""); +} + +#[test] +fn get_message_code_on_short_message_does_not_panic() { + // A zero-length frame drains to exactly the 4-byte length prefix, leaving + // no room for a code after it. + let msg = Message::new_with_data(vec![0, 0, 0, 0]); + assert_eq!(msg.get_message_code(), 0); + assert_eq!(msg.get_init_code(), 0); +} + +#[test] +fn an_init_frame_reads_its_one_byte_code() { + let init = Message::new_with_data( + Message::new().write_int8(1).write_string("ab").get_buffer(), + ); + assert_eq!(init.get_init_code(), 1); + assert_ne!(init.get_message_code(), 1); +} + +#[test] +fn read_int_primitives_are_bounds_checked() { + let mut msg = Message::new_with_data(vec![1, 2]); + assert_eq!(msg.read_int64(), 0); + let mut msg = Message::new_with_data(vec![]); + assert_eq!(msg.read_int8(), 0); + assert!(!msg.read_bool()); +} + +#[test] +fn test_read_string_2() { + let data = vec![ + 50, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 81, 170, 162, 77, 32, 0, 0, 0, + 101, 102, 99, 97, 51, 52, 102, 99, 52, 99, 56, 98, 101, 56, 98, 55, + 101, 102, 51, 56, 97, 102, 50, 54, 50, 52, 100, 101, 53, 52, 54, 52, 0, + ]; + let mut test_data = Message::new_with_data(data); + test_data.set_pointer(9); + assert_eq!(test_data.read_string(), ""); +} + +#[test] +fn test_read_string_invalid_utf82() { + let data = [ + 128, 0, 0, 0, 103, 58, 92, 100, 105, 115, 107, 52, 92, 115, 101, 109, + 105, 114, 97, 109, 105, 115, 92, 99, 104, 105, 108, 108, 44, 32, 100, + 117, 98, 44, 32, 100, 111, 119, 110, 98, 101, 97, 116, 44, 32, 97, 109, + 98, 105, 101, 110, 116, 92, 118, 97, 114, 105, 111, 117, 115, 32, 97, + 114, 116, 105, 115, 116, 115, 92, 112, 111, 116, 116, 32, 104, 101, 97, + 100, 122, 32, 45, 32, 100, 111, 112, 101, 32, 115, 109, 111, 107, 105, + 110, 180, 98, 101, 97, 116, 115, 32, 107, 98, 115, 32, 49, 50, 56, 32, + 49, 57, 57, 54, 92, 48, 53, 32, 45, 32, 98, 108, 117, 101, 32, 116, + 114, 97, 105, 110, 46, 109, 112, 51, + ] + .to_vec(); + + let mut test_data = Message::new_with_data(data); + let str = test_data.read_string(); + assert_eq!( + str, + r"g:\disk4\semiramis\chill, dub, downbeat, ambient\various artists\pott headz - dope smokin´beats kbs 128 1996\05 - blue train.mp3" + ); +} diff --git a/soulseek-rs-lib/src/message/peer/file_search_response.rs b/soulseek-rs-lib/src/message/peer/file_search_response.rs new file mode 100644 index 0000000..10a8134 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/file_search_response.rs @@ -0,0 +1,153 @@ +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; +use crate::types::SearchResult; +use crate::utils::zlib::deflate; +use std::sync::mpsc::Sender; + +/// A borrowed view of one file to advertise in a search response, kept +/// independent of the shares module so the message layer stays decoupled. +pub struct FileEntry<'a> { + pub name: &'a str, + pub size: u64, + pub attribs: &'a [(u32, u32)], +} + +/// Build a `FileSearchResponse` (peer code 9): the zlib-compressed reply we send +/// to a peer whose search matched our shared files. The payload is the exact +/// inverse of [`SearchResult::new_from_message`]. +#[must_use] +pub fn build_file_search_response( + own_username: &str, + token: u32, + files: &[FileEntry], + slots: u8, + speed: u32, + queue_length: u32, +) -> Message { + let mut payload = Message::new(); + payload + .write_string(own_username) + .write_int32(token) + .write_int32(files.len() as u32); + for file in files { + payload + .write_int8(1) + .write_string(file.name) + .write_int64(file.size) + .write_string("") // extension (decoder reads and discards) + .write_int32(file.attribs.len() as u32); + for &(code, value) in file.attribs { + payload.write_int32(code).write_int32(value); + } + } + payload + .write_int8(slots) + .write_int32(speed) + .write_int32(queue_length); + + let compressed = deflate(&payload.get_data()); + Message::new() + .write_int32(9) + .write_raw_bytes(compressed) + .clone() +} + +pub struct FileSearchResponse; +impl MessageHandler for FileSearchResponse { + fn get_code(&self) -> u32 { + 9 + } + fn handle(&self, message: &mut Message, sender: Sender) { + // Skip malformed search results + let Ok(file_search) = SearchResult::new_from_message(message) else { + return; + }; + + let _ = sender.send(PeerMessage::FileSearchResult(file_search)); + } +} + +#[test] +fn test_new_from_message() { + let data: Vec = [ + 229, 0, 0, 0, 9, 0, 0, 0, 120, 156, 99, 103, 96, 96, 72, 201, 79, 201, + 76, 79, 204, 203, 213, 158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135, + 162, 156, 148, 194, 188, 152, 228, 252, 220, 130, 156, 212, 146, 212, + 24, 231, 196, 188, 228, 204, 252, 188, 212, 226, 152, 144, 162, 210, + 226, 226, 212, 28, 93, 67, 75, 115, 75, 93, 119, 160, 144, 130, 91, + 126, 145, 66, 72, 70, 170, 66, 120, 106, 106, 118, 106, 94, 138, 174, + 161, 89, 82, 102, 137, 174, 137, 137, 142, 161, 119, 70, 149, 94, 90, + 78, 98, 114, 203, 175, 243, 32, 163, 193, 128, 25, 100, 7, 16, 23, 0, + 9, 22, 32, 237, 178, 134, 129, 129, 21, 72, 11, 128, 196, 243, 176, + 217, 29, 156, 153, 151, 158, 147, 90, 12, 54, 95, 193, 216, 84, 193, + 200, 192, 200, 36, 198, 45, 181, 168, 40, 53, 57, 91, 193, 37, 177, 60, + 79, 71, 193, 55, 177, 44, 181, 40, 19, 200, 13, 78, 76, 42, 74, 85, 80, + 83, 240, 75, 45, 7, 10, 38, 103, 100, 2, 221, 167, 139, 238, 66, 5, 13, + 144, 17, 154, 96, 167, 173, 228, 215, 98, 68, 119, 218, 74, 6, 76, 167, + 49, 60, 153, 202, 200, 160, 199, 128, 0, 0, 161, 99, 76, 142, + ] + .to_vec(); + let mut message = Message::new_with_data(data); + message.set_pointer(8); + + let file_search = SearchResult::new_from_message(&mut message).unwrap(); + assert_eq!(file_search.token, 882125677); + assert_eq!(file_search.files.len(), 2); + let file = &file_search.files[0]; + assert_eq!( + file.name, + "@@rldqn\\complete\\Canciones\\Trussel-1979-Gone For The Weekend-16bit-44,1Khz.flac" + ); + assert_eq!(file.username, "dodigan"); + assert_eq!(file.size, 47184516); +} + +#[test] +fn build_file_search_response_roundtrips_through_the_decoder() { + let files = [ + FileEntry { + name: "music\\album\\song.mp3", + size: 47_184_516, + attribs: &[(1, 320), (4, 44100), (5, 16)], + }, + FileEntry { + name: "b.flac", + size: 456, + attribs: &[], + }, + ]; + let message = build_file_search_response("e2e_sharer", 42, &files, 1, 0, 0); + + // Decode via the exact production decoder used for real peer responses: + // the dispatcher positions the pointer at 8 (past length + code). + let mut decoded = Message::new_with_data(message.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + + assert_eq!(result.username, "e2e_sharer"); + assert_eq!(result.token, 42); + assert_eq!(result.files.len(), 2); + assert_eq!(result.files[0].name, "music\\album\\song.mp3"); + assert_eq!(result.files[0].size, 47_184_516); + assert_eq!(result.files[0].attribs.get(&1), Some(&320)); + assert_eq!(result.files[0].attribs.get(&4), Some(&44100)); + assert_eq!(result.files[1].name, "b.flac"); + assert_eq!(result.files[1].size, 456); + assert!(result.files[1].attribs.is_empty()); + assert_eq!(result.slots, 1); +} + +#[test] +fn the_trailer_carries_slots_speed_and_queue_length() { + let message = build_file_search_response("me", 1, &[], 0, 900, 7); + let mut body = Message::new_with_data( + crate::utils::zlib::inflate(&message.get_data()[4..]).unwrap(), + ); + body.read_string(); + body.read_int32(); + assert_eq!(body.read_int32(), 0, "no files"); + assert_eq!( + (body.read_int8(), body.read_int32(), body.read_int32()), + (0, 900, 7) + ); +} diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs new file mode 100644 index 0000000..48a4bf9 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -0,0 +1,112 @@ +//! `FolderContentsRequest` (peer code 36) and its reply (code 37): one folder +//! of a share in the listing form code 5 uses, which is what "download +//! folder" in other clients asks for. + +use crate::message::peer::SharedDirectory; +#[cfg(test)] +use crate::message::peer::SharedFileEntry; +use crate::message::peer::shared_file_list::{ + decompress_body, read_directories, write_directories, +}; +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; +use crate::utils::zlib::deflate; +use std::sync::mpsc::Sender; + +pub struct FolderContentsRequest; +impl MessageHandler for FolderContentsRequest { + fn get_code(&self) -> u32 { + 36 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let folder = message.read_string(); + let _ = + sender.send(PeerMessage::FolderContentsRequested { token, folder }); + } +} + +/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked +/// for. The token is the one we quoted, so a client with two folders in +/// flight can tell the answers apart. +pub struct FolderContentsResponseHandler; +impl MessageHandler for FolderContentsResponseHandler { + fn get_code(&self) -> u32 { + 37 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let Some((token, folder, directories)) = parse_folder_contents(message) + else { + return; + }; + let _ = sender.send(PeerMessage::FolderContentsReceived { + token, + folder, + directories, + }); + } +} + +/// Build a `FolderContentsRequest` (peer code 36). +#[must_use] +pub fn build_folder_contents_request(token: u32, folder: &str) -> Message { + Message::new() + .write_int32(36) + .write_int32(token) + .write_string(folder) + .clone() +} + +/// Build a `FolderContentsResponse` (peer code 37). +#[must_use] +pub fn build_folder_contents( + token: u32, + folder: &str, + dirs: &[SharedDirectory], +) -> Message { + let mut payload = Message::new(); + payload.write_int32(token).write_string(folder); + write_directories(&mut payload, dirs); + Message::new() + .write_int32(37) + .write_raw_bytes(deflate(&payload.get_data())) + .clone() +} + +/// Parse a `FolderContentsResponse` positioned at its compressed blob, or +/// `None` when it does not inflate. +#[must_use] +pub fn parse_folder_contents( + message: &mut Message, +) -> Option<(u32, String, Vec)> { + 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))) +} + +#[test] +fn a_folder_listing_round_trips() { + let dirs = vec![SharedDirectory { + name: "music\\album".to_string(), + files: vec![ + SharedFileEntry { + name: "one.flac".to_string(), + size: 40, + attributes: vec![(0, 992)], + }, + SharedFileEntry { + name: "two.flac".to_string(), + size: 50, + attributes: Vec::new(), + }, + ], + }]; + let built = build_folder_contents(7, "music\\album", &dirs); + let mut message = Message::new_with_data(built.get_data()); + message.set_pointer(4); + assert_eq!( + parse_folder_contents(&mut message), + Some((7, "music\\album".to_string(), dirs)) + ); +} diff --git a/soulseek-rs-lib/src/message/peer/get_share_file_list.rs b/soulseek-rs-lib/src/message/peer/get_share_file_list.rs new file mode 100644 index 0000000..cd8c5de --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/get_share_file_list.rs @@ -0,0 +1,17 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +/// A peer asking to browse our shared files (peer code 4). The client (which +/// owns the shares) builds the real SharedFileListResponse in reply. +pub struct GetShareFileList; +impl MessageHandler for GetShareFileList { + fn get_code(&self) -> u32 { + 4 + } + fn handle(&self, _message: &mut Message, sender: Sender) { + let _ = sender.send(PeerMessage::ShareListRequested); + } +} diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs new file mode 100644 index 0000000..6ea8a40 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -0,0 +1,40 @@ +mod file_search_response; +mod folder_contents; +mod get_share_file_list; +mod peer_init; +mod place_in_queue_request; +mod place_in_queue_response; +mod queue_upload; +mod shared_file_list; +mod transfer_request; +mod transfer_response; +mod upload_denied; +mod upload_failed; +mod user_info; + +// Re-export handlers +pub use file_search_response::{ + FileEntry, FileSearchResponse, build_file_search_response, +}; +pub use 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; +pub use place_in_queue_request::PlaceInQueueRequest; +pub use place_in_queue_response::PlaceInQueueResponse; +pub use queue_upload::QueueUploadHandler; +pub use shared_file_list::{ + SharedDirectory, SharedFileEntry, SharedFileListResponseHandler, + build_shared_file_list, parse_shared_file_list, +}; +pub use transfer_request::TransferRequest; +pub use transfer_response::TransferResponse; +pub use upload_denied::UploadDeniedHandler; +pub use upload_failed::UploadFailedHandler; +pub use user_info::{ + PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info, + build_user_info_request, +}; diff --git a/soulseek-rs-lib/src/message/peer/peer_init.rs b/soulseek-rs-lib/src/message/peer/peer_init.rs new file mode 100644 index 0000000..3c7dbf8 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/peer_init.rs @@ -0,0 +1,28 @@ +use std::sync::mpsc::Sender; + +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, + trace, +}; + +pub struct PeerInit; +impl MessageHandler for PeerInit { + fn get_code(&self) -> u32 { + 1 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + message.set_pointer(4); + let _message_code = message.read_int8(); + let username = message.read_string(); + let connection_type = message.read_string(); + let token = message.read_int32(); + trace!( + "PeerInit: username: {}, connection_type: {}, token: {}", + username, connection_type, token + ); + + let _ = sender.send(PeerMessage::SetUsername(username)); + } +} diff --git a/soulseek-rs-lib/src/message/peer/place_in_queue_request.rs b/soulseek-rs-lib/src/message/peer/place_in_queue_request.rs new file mode 100644 index 0000000..2bbf775 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/place_in_queue_request.rs @@ -0,0 +1,45 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +/// `PlaceInQueueRequest` (peer code 51): a peer asking where the file they +/// queued sits in our upload queue. +/// +/// Worth answering rather than dropping: a downloader that gets no position +/// cannot tell a long queue from a client that has forgotten about it. +pub struct PlaceInQueueRequest; + +impl MessageHandler for PlaceInQueueRequest { + fn get_code(&self) -> u32 { + 51 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let filename = message.read_string(); + let _ = sender.send(PeerMessage::IncomingPlaceInQueueRequest(filename)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_asked_about_filename_is_forwarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("@@share\\track.mp3"); + }); + + PlaceInQueueRequest.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::IncomingPlaceInQueueRequest(filename)) => { + assert_eq!(filename, "@@share\\track.mp3"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs b/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs new file mode 100644 index 0000000..447a77b --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs @@ -0,0 +1,21 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +pub struct PlaceInQueueResponse; + +impl MessageHandler for PlaceInQueueResponse { + fn get_code(&self) -> u32 { + 44 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let filename = message.read_string(); + let place = message.read_int32(); + + let _ = + sender.send(PeerMessage::PlaceInQueueResponse { filename, place }); + } +} diff --git a/soulseek-rs-lib/src/message/peer/queue_upload.rs b/soulseek-rs-lib/src/message/peer/queue_upload.rs new file mode 100644 index 0000000..a4c2143 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/queue_upload.rs @@ -0,0 +1,19 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +/// A peer asking to download one of our shared files (peer code 43). +pub struct QueueUploadHandler; + +impl MessageHandler for QueueUploadHandler { + fn get_code(&self) -> u32 { + 43 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let filename = message.read_string(); + let _ = sender.send(PeerMessage::IncomingQueueUpload(filename)); + } +} diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs new file mode 100644 index 0000000..60084e5 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -0,0 +1,221 @@ +//! `SharedFileListResponse` (peer code 5): a peer's full shared-file listing, +//! sent in reply to `GetShareFileList` (code 4). The payload is zlib-compressed +//! and groups files by their virtual directory. + +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; +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)` +/// audio attributes the owner advertises for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedFileEntry { + pub name: String, + pub size: u64, + pub attributes: Vec<(u32, u32)>, +} + +impl SharedFileEntry { + /// The value of attribute `code` (0 bitrate, 1 duration, 2 VBR, 4 sample + /// rate, 5 bit depth), if the owner advertised it. + #[must_use] + pub fn attribute(&self, code: u32) -> Option { + self.attributes + .iter() + .find(|&&(c, _)| c == code) + .map(|&(_, v)| v) + } +} + +/// One shared directory and the files directly in it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedDirectory { + pub name: String, + pub files: Vec, +} + +/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them. +pub struct SharedFileListResponseHandler; +impl MessageHandler for SharedFileListResponseHandler { + fn get_code(&self) -> u32 { + 5 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let directories = parse_shared_file_list(message); + let _ = sender.send(PeerMessage::ShareListReceived(directories)); + } +} + +/// Build a `SharedFileListResponse` (peer code 5) from the directory listing. +#[must_use] +pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { + let mut payload = Message::new(); + write_directories(&mut payload, dirs); + payload.write_int32(0); // unknown + payload.write_int32(0); // number of private directories + + let compressed = deflate(&payload.get_data()); + Message::new() + .write_int32(5) + .write_raw_bytes(compressed) + .clone() +} + +/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must +/// be positioned at the compressed blob (the dispatcher sets pointer 8). +/// +/// Returns an empty listing if the payload is malformed. +#[must_use] +pub fn parse_shared_file_list(message: &mut Message) -> Vec { + // 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)) +} + +/// Write a directory listing in the form both code 5 and code 37 carry. +pub fn write_directories(payload: &mut Message, dirs: &[SharedDirectory]) { + payload.write_int32(dirs.len() as u32); + for dir in dirs { + payload + .write_string(&dir.name) + .write_int32(dir.files.len() as u32); + for file in &dir.files { + payload + .write_int8(1) + .write_string(&file.name) + .write_int64(file.size) + .write_string("") // extension + .write_int32(file.attributes.len() as u32); + for &(code, value) in &file.attributes { + payload.write_int32(code).write_int32(value); + } + } + } +} + +/// Read back what [`write_directories`] wrote, stopping early when a hostile +/// count outruns the payload so a bogus length cannot spin into a huge +/// allocation loop. +pub fn read_directories(body: &mut Message) -> Vec { + let dir_count = body.read_int32(); + let mut dirs = Vec::new(); + for _ in 0..dir_count { + if body.get_pointer() >= body.get_size() { + break; + } + let name = body.read_string(); + let file_count = body.read_int32(); + let mut files = Vec::new(); + for _ in 0..file_count { + if body.get_pointer() >= body.get_size() { + break; + } + body.read_int8(); // code + let name = body.read_string(); + let size = body.read_int64(); + body.read_string(); // extension + let attr_count = body.read_int32(); + let mut attributes = Vec::new(); + for _ in 0..attr_count { + // Each attribute is two int32s (8 bytes); read_int32 does not + // advance past the end, so bound the loop explicitly. + if body.get_pointer() + 8 > body.get_size() { + break; + } + attributes.push((body.read_int32(), body.read_int32())); + } + files.push(SharedFileEntry { + name, + size, + attributes, + }); + } + dirs.push(SharedDirectory { name, files }); + } + dirs +} + +/// Inflate a compressed peer payload positioned at the blob, refusing one +/// that expands past `max_out` — a peer we have never met chose these bytes. +pub fn decompress_body( + message: &mut Message, + max_out: usize, +) -> Option { + let pointer = message.get_pointer(); + let size = message.get_size(); + inflate_limited(&message.get_slice(pointer, size), max_out) + .ok() + .map(Message::new_with_data) +} + +#[test] +fn hostile_dir_count_does_not_hang() { + // A compressed body claiming ~4 billion directories with no data must + // parse to empty promptly rather than looping into an OOM. + let compressed = crate::utils::zlib::deflate(&u32::MAX.to_le_bytes()); + let mut message = crate::message::framed(|m| { + m.write_raw_bytes(compressed); + }); + assert!(parse_shared_file_list(&mut message).is_empty()); +} + +#[test] +fn a_listing_carries_each_files_attributes() { + let dirs = vec![SharedDirectory { + name: "music\\album".to_string(), + files: vec![ + SharedFileEntry { + name: "one.mp3".to_string(), + size: 40, + attributes: vec![(0, 320), (1, 187)], + }, + SharedFileEntry { + name: "two.flac".to_string(), + size: 50, + attributes: Vec::new(), + }, + ], + }]; + let mut message = crate::message::framed(|m| { + m.write_raw_bytes( + build_shared_file_list(&dirs).get_data()[4..].to_vec(), + ); + }); + assert_eq!(parse_shared_file_list(&mut message), dirs); +} + +#[test] +fn shared_file_list_roundtrips() { + let dirs = vec![ + SharedDirectory { + name: "music\\album".to_string(), + files: vec![ + SharedFileEntry { + name: "song one.flac".to_string(), + size: 123, + attributes: vec![(1, 300), (4, 44_100), (5, 16)], + }, + SharedFileEntry { + name: "song two.flac".to_string(), + size: 456, + attributes: Vec::new(), + }, + ], + }, + SharedDirectory { + name: "music".to_string(), + files: vec![SharedFileEntry { + name: "top.mp3".to_string(), + size: 789, + attributes: Vec::new(), + }], + }, + ]; + let message = build_shared_file_list(&dirs); + + // Decode via the same offset the dispatcher would use. + let mut decoded = Message::new_with_data(message.get_buffer()); + decoded.set_pointer(8); + assert_eq!(parse_shared_file_list(&mut decoded), dirs); +} diff --git a/soulseek-rs-lib/src/message/peer/transfer_request.rs b/soulseek-rs-lib/src/message/peer/transfer_request.rs new file mode 100644 index 0000000..e0ed13d --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/transfer_request.rs @@ -0,0 +1,18 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, + types::Transfer, +}; +use std::sync::mpsc::Sender; + +pub struct TransferRequest; +impl MessageHandler for TransferRequest { + fn get_code(&self) -> u32 { + 40 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let transfer = Transfer::new_from_message(message); + + let _ = sender.send(PeerMessage::TransferRequest(transfer)); + } +} diff --git a/soulseek-rs-lib/src/message/peer/transfer_response.rs b/soulseek-rs-lib/src/message/peer/transfer_response.rs new file mode 100644 index 0000000..ad13685 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/transfer_response.rs @@ -0,0 +1,25 @@ +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +pub struct TransferResponse; + +impl MessageHandler for TransferResponse { + fn get_code(&self) -> u32 { + 41 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let allowed = message.read_int8(); + let reason = (allowed == 0).then(|| message.read_string()); + + let _ = sender.send(PeerMessage::TransferResponse { + token, + allowed: allowed == 1, + reason, + }); + } +} diff --git a/soulseek-rs-lib/src/message/peer/upload_denied.rs b/soulseek-rs-lib/src/message/peer/upload_denied.rs new file mode 100644 index 0000000..761a9e2 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/upload_denied.rs @@ -0,0 +1,56 @@ +use crate::info; +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +pub struct UploadDeniedHandler; +impl MessageHandler for UploadDeniedHandler { + fn get_code(&self) -> u32 { + 50 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let filename = message.read_string(); + let reason = message.read_string(); + info!("Upload denied for {}: {}", filename, reason); + if reason == "Queued" { + return; + } + let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_queued_reason_is_not_a_failure() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("@@share\\gone.mp3").write_string("Queued"); + }); + + UploadDeniedHandler.handle(&mut message, tx); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn the_denied_filename_is_forwarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("@@share\\gone.mp3") + .write_string("File not shared."); + }); + + UploadDeniedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UploadFailed(_, filename)) => { + assert_eq!(filename, "@@share\\gone.mp3"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/peer/upload_failed.rs b/soulseek-rs-lib/src/message/peer/upload_failed.rs new file mode 100644 index 0000000..b53f9f2 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/upload_failed.rs @@ -0,0 +1,40 @@ +use crate::info; +use crate::{ + message::{Message, MessageHandler}, + peer::PeerMessage, +}; +use std::sync::mpsc::Sender; + +pub struct UploadFailedHandler; +impl MessageHandler for UploadFailedHandler { + fn get_code(&self) -> u32 { + 46 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let filename = message.read_string(); + info!("Upload failed for {}", filename); + let _ = sender.send(PeerMessage::UploadFailed(String::new(), filename)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_failed_filename_is_forwarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("@@share\\gone.mp3"); + }); + + UploadFailedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UploadFailed(_, filename)) => { + assert_eq!(filename, "@@share\\gone.mp3"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/peer/user_info.rs b/soulseek-rs-lib/src/message/peer/user_info.rs new file mode 100644 index 0000000..ee246fd --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/user_info.rs @@ -0,0 +1,181 @@ +//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says +//! about itself when another user opens its profile. + +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; +use std::sync::mpsc::Sender; + +pub struct UserInfoRequest; +impl MessageHandler for UserInfoRequest { + fn get_code(&self) -> u32 { + 15 + } + fn handle(&self, _message: &mut Message, sender: Sender) { + let _ = sender.send(PeerMessage::UserInfoRequested); + } +} + +/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says +/// about itself. +#[must_use] +pub fn build_user_info_request() -> Message { + Message::new().write_int32(15).clone() +} + +/// What a peer says about itself, from its `UserInfoResponse` (code 16). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerInfo { + pub description: String, + /// Whether a profile picture came with it, and how many bytes it was. The + /// image itself is not kept: no caller has a use for it yet, and a peer + /// can make it arbitrarily large. + pub picture_bytes: Option, + /// Uploads the peer says it has done in total. + pub total_uploads: u32, + /// How many transfers are waiting in its queue. + pub queue_size: u32, + /// Whether it has a free upload slot right now. + pub slots_free: bool, + /// Whether it would let us queue anything at all. Absent from older + /// clients, which stop the message one field early. + pub upload_allowed: Option, +} + +/// `UserInfoResponse` (peer code 16): a peer's answer about itself. +pub struct UserInfoResponseHandler; +impl MessageHandler for UserInfoResponseHandler { + fn get_code(&self) -> u32 { + 16 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let description = message.read_string(); + let has_picture = message.read_bool(); + let picture_bytes = has_picture.then(|| { + let length = message.read_int32() as usize; + let available = + message.get_size().saturating_sub(message.get_pointer()); + let length = length.min(available); + let at = message.get_pointer() + length; + message.set_pointer(at); + length + }); + let total_uploads = message.read_int32(); + let queue_size = message.read_int32(); + let slots_free = message.read_bool(); + // Museek+ writes `slotsavail` as an integer, leaving three bytes of + // rubbish where the next field starts; Nicotine+ guards the read the + // same way, by only taking a field there is room for. + let upload_allowed = + (message.get_size().saturating_sub(message.get_pointer()) >= 4) + .then(|| message.read_int32()); + + let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo { + description, + picture_bytes, + total_uploads, + queue_size, + slots_free, + upload_allowed, + })); + } +} + +/// Build a `UserInfoResponse` (peer code 16) with no description or picture. +#[must_use] +pub fn build_user_info( + upload_slots: u32, + queue_size: u32, + slots_free: bool, +) -> Message { + Message::new() + .write_int32(16) + .write_string("") + .write_bool(false) + .write_int32(upload_slots) + .write_int32(queue_size) + .write_bool(slots_free) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(build); + UserInfoResponseHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UserInfoReceived(info)) => info, + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_peers_answer_carries_its_slots_and_queue() { + let info = parse(|m| { + m.write_string("hello, I share flacs"); + m.write_bool(false); + m.write_int32(42); // total uploads + m.write_int32(3); // queue size + m.write_bool(true); // a slot is free + m.write_int32(1); // uploads allowed + }); + + assert_eq!(info.description, "hello, I share flacs"); + assert_eq!(info.picture_bytes, None); + assert_eq!(info.total_uploads, 42); + assert_eq!(info.queue_size, 3); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, Some(1)); + } + + #[test] + fn a_picture_is_skipped_over_rather_than_kept() { + let info = parse(|m| { + m.write_string("with a picture"); + m.write_bool(true); + m.write_int32(4); + m.write_raw_bytes(vec![1, 2, 3, 4]); + m.write_int32(7); + m.write_int32(0); + m.write_bool(false); + m.write_int32(0); + }); + + assert_eq!(info.picture_bytes, Some(4)); + assert_eq!(info.total_uploads, 7, "the fields after it still line up"); + } + + #[test] + fn a_message_that_stops_early_still_parses() { + // Older clients omit the trailing field, and Museek+ leaves rubbish + // where it would be; neither may cost us the fields before it. + let info = parse(|m| { + m.write_string("older client"); + m.write_bool(false); + m.write_int32(5); + m.write_int32(1); + m.write_bool(true); + }); + + assert_eq!(info.total_uploads, 5); + assert_eq!(info.queue_size, 1); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, None); + } + + #[test] + fn a_picture_longer_than_the_message_does_not_run_off_the_end() { + let info = parse(|m| { + m.write_string("hostile"); + m.write_bool(true); + m.write_int32(u32::MAX); + m.write_raw_bytes(vec![9, 9]); + }); + + assert_eq!(info.picture_bytes, Some(2)); + assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero"); + } +} diff --git a/soulseek-rs-lib/src/message/server/admin_message.rs b/soulseek-rs-lib/src/message/server/admin_message.rs new file mode 100644 index 0000000..e043e47 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/admin_message.rs @@ -0,0 +1,30 @@ +//! `AdminMessage` (server code 66): an announcement from the server to +//! everyone, surfaced as a private message from the user "server". + +use crate::actor::server_actor::{ServerMessage, UserMessage}; +use crate::message::{Message, MessageHandler}; +use std::sync::mpsc::Sender; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct AdminMessageHandler; + +impl MessageHandler for AdminMessageHandler { + fn get_code(&self) -> u32 { + 66 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs() as u32); + let announcement = UserMessage::new( + 0, + now, + "server".to_string(), + message.read_string(), + false, + ); + let _ = + sender.send(ServerMessage::PrivateMessageReceived(announcement)); + } +} diff --git a/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs new file mode 100644 index 0000000..184e1f2 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs @@ -0,0 +1,49 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us. +/// +/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it +/// is the only field the server relays — the username is not repeated, so the +/// token is what a client matches the pending attempt on. +/// +/// Without this the download sits in "connecting" until a timeout; with it the +/// wait ends the moment the peer gives up. +pub struct CantConnectToPeerHandler; + +impl MessageHandler for CantConnectToPeerHandler { + fn get_code(&self) -> u32 { + 1001 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let _ = sender.send(ServerMessage::CantConnectToPeer { token }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_token_of_the_failed_attempt_is_reported() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(4242); + }); + + CantConnectToPeerHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantConnectToPeer { token }) => { + assert_eq!(token, 4242); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/connect_to_peer.rs b/soulseek-rs-lib/src/message/server/connect_to_peer.rs new file mode 100644 index 0000000..1ffd706 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/connect_to_peer.rs @@ -0,0 +1,17 @@ +use crate::actor::server_actor::ServerMessage; +use crate::message::{Message, MessageHandler}; +use crate::peer::Peer; +use std::sync::mpsc::Sender; +pub struct ConnectToPeerHandler; + +impl MessageHandler for ConnectToPeerHandler { + fn get_code(&self) -> u32 { + 18 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let Some(peer) = Peer::new_from_message(message) else { + return; + }; + let _ = sender.send(ServerMessage::ConnectToPeer(peer)); + } +} diff --git a/soulseek-rs-lib/src/message/server/distributed.rs b/soulseek-rs-lib/src/message/server/distributed.rs new file mode 100644 index 0000000..8dacc78 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/distributed.rs @@ -0,0 +1,162 @@ +//! What the server says about the distributed network: parent candidates +//! (code 102), a reset (130), and a search embedded for a branch root (93). + +use crate::actor::server_actor::ServerMessage; +use crate::message::distributed::{Distributed, parse_at}; +use crate::message::{Message, MessageHandler}; +use std::net::Ipv4Addr; +use std::sync::mpsc::Sender; + +pub struct PossibleParentsHandler; +impl MessageHandler for PossibleParentsHandler { + fn get_code(&self) -> u32 { + 102 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let candidates = (0..count.min(10)) + .map_while(|_| entry(message)) + .filter_map(|(username, host, port)| { + Some((username, host, u16::try_from(port).ok()?)) + }) + .collect(); + let _ = sender.send(ServerMessage::PossibleParents(candidates)); + } +} + +/// One whole `(username, host, port)` entry, or `None` where the message +/// runs out: a truncated entry is not a parent to dial. +fn entry(message: &mut Message) -> Option<(String, String, u32)> { + let username = message.read_string(); + if username.is_empty() + || message.get_size().saturating_sub(message.get_pointer()) < 8 + { + return None; + } + let host = Ipv4Addr::from(message.read_int32()).to_string(); + Some((username, host, message.read_int32())) +} + +pub struct ResetDistributedHandler; +impl MessageHandler for ResetDistributedHandler { + fn get_code(&self) -> u32 { + 130 + } + fn handle(&self, _message: &mut Message, sender: Sender) { + let _ = sender.send(ServerMessage::ResetDistributed); + } +} + +/// A search the server hands us directly because we are a branch root; it is +/// answered like any other. +pub struct EmbeddedMessageHandler; +impl MessageHandler for EmbeddedMessageHandler { + fn get_code(&self) -> u32 { + 93 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let code = message.read_int8(); + if let Some(Distributed::Search { + username, + token, + query, + }) = parse_at(message, code) + { + let _ = sender.send(ServerMessage::FileSearchRequest { + username, + token, + query, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc::channel; + + #[test] + fn possible_parents_carry_each_candidates_address() { + let (sender, received) = channel(); + let mut message = crate::message::framed(|m| { + m.write_int32(2) + .write_string("alice") + .write_int32(u32::from_le_bytes([1, 0, 0, 127])) + .write_int32(2234) + .write_string("bob") + .write_int32(u32::from_le_bytes([8, 8, 8, 8])) + .write_int32(2235); + }); + PossibleParentsHandler.handle(&mut message, sender); + let ServerMessage::PossibleParents(candidates) = + received.try_recv().unwrap() + else { + panic!("expected PossibleParents"); + }; + assert_eq!( + candidates, + vec![ + ("alice".to_string(), "127.0.0.1".to_string(), 2234), + ("bob".to_string(), "8.8.8.8".to_string(), 2235), + ] + ); + } + + // The count says two, the bytes hold one and a half: the whole one is + // a candidate, the stub is not. A port that is not a port is skipped too. + #[test] + fn a_truncated_or_absurd_entry_is_not_a_candidate() { + let (sender, received) = channel(); + let mut message = crate::message::framed(|m| { + m.write_int32(3) + .write_string("alice") + .write_int32(u32::from_le_bytes([1, 0, 0, 127])) + .write_int32(70_000) + .write_string("bob") + .write_int32(u32::from_le_bytes([8, 8, 8, 8])) + .write_int32(2235) + .write_string("carol"); + }); + PossibleParentsHandler.handle(&mut message, sender); + let ServerMessage::PossibleParents(candidates) = + received.try_recv().unwrap() + else { + panic!("expected PossibleParents"); + }; + assert_eq!( + candidates, + vec![("bob".to_string(), "8.8.8.8".to_string(), 2235)] + ); + } + + #[test] + fn a_reset_and_an_embedded_search_become_client_events() { + let (sender, received) = channel(); + let mut reset = crate::message::framed(|_| {}); + ResetDistributedHandler.handle(&mut reset, sender.clone()); + assert!(matches!( + received.try_recv().unwrap(), + ServerMessage::ResetDistributed + )); + + let inner = crate::message::distributed::build_search("seeker", 9, "q") + .get_data(); + let mut embedded = crate::message::framed(|m| { + m.write_raw_bytes(inner); + }); + EmbeddedMessageHandler.handle(&mut embedded, sender); + let ServerMessage::FileSearchRequest { + username, + token, + query, + } = received.try_recv().unwrap() + else { + panic!("expected FileSearchRequest"); + }; + assert_eq!( + (username.as_str(), token, query.as_str()), + ("seeker", 9, "q") + ); + } +} diff --git a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs new file mode 100644 index 0000000..2233529 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs @@ -0,0 +1,69 @@ +use crate::debug; +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct ExcludedSearchPhrasesHandler; + +impl MessageHandler for ExcludedSearchPhrasesHandler { + fn get_code(&self) -> u32 { + 160 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item_count = message.read_int32(); + + let mut exluded_phrases: Vec = Vec::new(); + for _ in 0..item_count { + // Guard against a hostile item_count outrunning the payload. + if message.get_pointer() + 4 > message.get_size() { + break; + } + let phrase = message.read_string(); + 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)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn hostile_item_count_does_not_hang() { + // item_count=u32::MAX with no phrases: the guard must make this return + // promptly instead of looping ~4 billion times. + let (tx, _rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(u32::MAX); + }); + ExcludedSearchPhrasesHandler.handle(&mut message, tx); + } + + #[test] + fn the_phrases_are_reported_not_only_logged() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("banned"); + m.write_string("blocked"); + }); + + ExcludedSearchPhrasesHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => { + assert_eq!(phrases, ["banned", "blocked"]); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/file_search.rs b/soulseek-rs-lib/src/message/server/file_search.rs new file mode 100644 index 0000000..c8def58 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/file_search.rs @@ -0,0 +1,27 @@ +use crate::trace; +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, message::Message, + message::handlers::MessageHandler, +}; + +pub struct FileSearchHandler; + +impl MessageHandler for FileSearchHandler { + fn get_code(&self) -> u32 { + 26 + } + fn handle(&self, message: &mut Message, sender: Sender) { + // The server distributes another user's search to us: [user][token][query]. + let username = message.read_string(); + let token = message.read_int32(); + let query = message.read_string(); + trace!("[server] search from {}: {} ({})", username, query, token); + let _ = sender.send(ServerMessage::FileSearchRequest { + username, + token, + query, + }); + } +} diff --git a/soulseek-rs-lib/src/message/server/get_peer_address.rs b/soulseek-rs-lib/src/message/server/get_peer_address.rs new file mode 100644 index 0000000..576d426 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/get_peer_address.rs @@ -0,0 +1,35 @@ +use crate::actor::server_actor::ServerMessage; +use crate::message::{Message, MessageHandler}; +use std::sync::mpsc::Sender; + +pub struct GetPeerAddressHandler; + +impl MessageHandler for GetPeerAddressHandler { + fn get_code(&self) -> u32 { + 3 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + + // Read IP address as 4 bytes + let mut ip: Vec = vec![]; + for _ in 0..4 { + ip.push(message.read_int8()); + } + let host = format!("{}.{}.{}.{}", ip[3], ip[2], ip[1], ip[0]); + + let port = message.read_int32(); + let obfuscation_type = message.read_int32(); + let obfuscated_port = message.read_int32() as u16; + crate::debug!("GetPeerAddressHandler: {username:?}"); + + let _ = sender.send(ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }); + } +} diff --git a/soulseek-rs-lib/src/message/server/global_room.rs b/soulseek-rs-lib/src/message/server/global_room.rs new file mode 100644 index 0000000..7e8f704 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/global_room.rs @@ -0,0 +1,58 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `GlobalRoomMessage` (code 152): a message said in some public room, +/// delivered because we subscribed to the global feed (code 150). +pub struct GlobalRoomMessageHandler; + +impl MessageHandler for GlobalRoomMessageHandler { + fn get_code(&self) -> u32 { + 152 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let text = message.read_string(); + let _ = sender.send(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message: text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_global_message_names_the_room_it_was_said_in() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("anyone here?"); + }); + + GlobalRoomMessageHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), message.as_str()), + ("jazz", "alice", "anyone here?") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/interests.rs b/soulseek-rs-lib/src/message/server/interests.rs new file mode 100644 index 0000000..6d623a6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/interests.rs @@ -0,0 +1,346 @@ +//! Interests and the queries built on them: recommendations (codes 54, 56, +//! 111), similar users (110, 112) and another user's interests (57). +//! +//! Ratings are signed on the wire: the server sends a descending list of +//! things it recommends and an ascending list of things it recommends +//! against, and the latter carry negative ratings. + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::{Recommendation, SimilarUser, UserInterests}, +}; + +/// Read a `[count][item][rating]...` vector, stopping if the payload runs out +/// before the count does. +fn read_recommendations(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + #[allow(clippy::cast_possible_wrap)] + let rating = message.read_int32() as i32; + items.push(Recommendation { item, rating }); + } + items +} + +/// Read a `[count][string]...` vector, stopping at the end of the payload. +fn read_strings(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + items.push(item); + } + items +} + +/// `GetRecommendations` (code 54): recommendations drawn from our own +/// interests. +pub struct RecommendationsHandler; + +impl MessageHandler for RecommendationsHandler { + fn get_code(&self) -> u32 { + 54 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `GlobalRecommendations` (code 56): the same shape, server-wide. +pub struct GlobalRecommendationsHandler; + +impl MessageHandler for GlobalRecommendationsHandler { + fn get_code(&self) -> u32 { + 56 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `ItemRecommendations` (code 111): what else people who like one item like. +pub struct ItemRecommendationsHandler; + +impl MessageHandler for ItemRecommendationsHandler { + fn get_code(&self) -> u32 { + 111 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let recommendations = read_recommendations(message); + let _ = sender.send(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }); + } +} + +/// `SimilarUsers` (code 110): users whose interests overlap ours. +pub struct SimilarUsersHandler; + +impl MessageHandler for SimilarUsersHandler { + fn get_code(&self) -> u32 { + 110 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + let weight = message.read_int32(); + users.push(SimilarUser { username, weight }); + } + let _ = sender.send(ServerMessage::SimilarUsersReceived { users }); + } +} + +/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only +/// names here, so every weight reads as zero. +pub struct ItemSimilarUsersHandler; + +impl MessageHandler for ItemSimilarUsersHandler { + fn get_code(&self) -> u32 { + 112 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let usernames = read_strings(message); + let _ = sender + .send(ServerMessage::ItemSimilarUsersReceived { item, usernames }); + } +} + +/// `UserInterests` (code 57): what one user likes and hates. +pub struct UserInterestsHandler; + +impl MessageHandler for UserInterestsHandler { + fn get_code(&self) -> u32 { + 57 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let likes = read_strings(message); + let hates = read_strings(message); + let _ = + sender.send(ServerMessage::UserInterestsReceived(UserInterests { + username, + likes, + hates, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn recommendations_split_into_recommended_and_not() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("jazz"); + m.write_int32(3); + m.write_int32(1); + m.write_string("polka"); + #[allow(clippy::cast_sign_loss)] + m.write_int32(-2i32 as u32); + }); + + RecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!( + recommended, + vec![Recommendation { + item: "jazz".into(), + rating: 3 + }] + ); + assert_eq!( + unrecommended, + vec![Recommendation { + item: "polka".into(), + rating: -2 + }] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn global_recommendations_parse_the_same_shape() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("dub"); + m.write_int32(9); + m.write_int32(0); + }); + + GlobalRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!(recommended.len(), 1); + assert_eq!(recommended[0].item, "dub"); + assert!(unrecommended.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn similar_users_carry_their_weight() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("alice"); + m.write_int32(4); + m.write_string("bob"); + m.write_int32(1); + }); + + SimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::SimilarUsersReceived { users }) => { + assert_eq!( + users, + vec![ + SimilarUser { + username: "alice".into(), + weight: 4 + }, + SimilarUser { + username: "bob".into(), + weight: 1 + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_similar_users_report_the_item_they_answer_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("alice"); + }); + + ItemSimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => { + assert_eq!(item, "jazz"); + assert_eq!(usernames, vec!["alice".to_string()]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_recommendations_report_their_item() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("blues"); + m.write_int32(2); + }); + + ItemRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }) => { + assert_eq!(item, "jazz"); + assert_eq!(recommendations[0].item, "blues"); + assert_eq!(recommendations[0].rating, 2); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_users_likes_and_hates_arrive_as_two_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice"); + m.write_int32(2); + m.write_string("jazz"); + m.write_string("dub"); + m.write_int32(1); + m.write_string("polka"); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert_eq!(interests.username, "alice"); + assert_eq!(interests.likes, ["jazz", "dub"]); + assert_eq!(interests.hates, ["polka"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_user_with_no_interests_parses_to_empty_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("ghost"); + m.write_int32(0); + m.write_int32(0); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert!(interests.likes.is_empty()); + assert!(interests.hates.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/join_room.rs b/soulseek-rs-lib/src/message/server/join_room.rs new file mode 100644 index 0000000..b3462c7 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/join_room.rs @@ -0,0 +1,309 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::{RoomUserStats, UserStatus}, +}; +use std::sync::mpsc::Sender; + +pub struct JoinRoomHandler; + +impl MessageHandler for JoinRoomHandler { + fn get_code(&self) -> u32 { + 14 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + // JoinRoom (code 14): room name, the member usernames, then the + // parallel per-member stat vectors. + let room = message.read_string(); + let users = read_strings(message); + let stats = parse_member_stats(message, &users); + let _ = sender.send(ServerMessage::RoomJoined { + room: room.clone(), + users, + }); + if !stats.is_empty() { + let _ = sender.send(ServerMessage::RoomMemberStats { room, stats }); + } + } +} + +/// Read a length-prefixed vector of strings, stopping early if the count +/// outruns the payload: a bogus (possibly hostile) count must not spin us +/// into an OOM allocation loop. A 4-byte floor is the smallest a +/// length-prefixed string can be. +fn read_strings(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut values = Vec::new(); + for _ in 0..count { + if message.get_pointer() + 4 > message.get_size() { + break; + } + values.push(message.read_string()); + } + values +} + +/// Read a length-prefixed vector of 32-bit values, bounded the same way. +fn read_int32s(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut values = Vec::new(); + for _ in 0..count { + if message.get_pointer() + 4 > message.get_size() { + break; + } + values.push(message.read_int32()); + } + values +} + +/// Parse the per-member statistics that follow the membership list of a +/// `JoinRoom` (code 14) reply: statuses, then a record of share statistics per +/// member, then whether each member's upload slots are full, then country +/// codes. +/// +/// Each vector is independently length-prefixed and a server may send fewer +/// entries than there are members (or, on an older server, omit the country +/// vector entirely), so every field is looked up positionally and defaults +/// when absent rather than shifting the whole roster. +#[must_use] +fn parse_member_stats( + message: &mut Message, + users: &[String], +) -> Vec { + let statuses = read_int32s(message); + + let stat_count = message.read_int32(); + let mut shares = Vec::new(); + for _ in 0..stat_count { + // A share record is 20 bytes: speed, an obsolete 64-bit upload + // number, files and folders. + if message.get_pointer() + 20 > message.get_size() { + break; + } + let average_speed = message.read_int32(); + let _upload_number = message.read_int64(); // obsolete + let shared_files = message.read_int32(); + let shared_folders = message.read_int32(); + shares.push((average_speed, shared_files, shared_folders)); + } + + let slots_full = read_int32s(message); + let countries = read_strings(message); + + users + .iter() + .enumerate() + .map(|(i, username)| { + let (average_speed, shared_files, shared_folders) = + shares.get(i).copied().unwrap_or((0, 0, 0)); + RoomUserStats { + username: username.clone(), + status: UserStatus::from_code( + statuses.get(i).copied().unwrap_or(0), + ), + average_speed, + shared_files, + shared_folders, + slots_full: slots_full.get(i).copied().unwrap_or(0) != 0, + country: countries.get(i).cloned().filter(|c| !c.is_empty()), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn hostile_user_count_does_not_hang() { + // room="" then user_count=u32::MAX with no usernames: must return + // promptly instead of looping ~4 billion times. + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("").write_int32(u32::MAX); + }); + + JoinRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomJoined { users, .. }) => { + assert!(users.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + /// A full JoinRoom payload: room, members, then the four parallel stat + /// vectors the server sends for them. + fn join_payload() -> Message { + framed(|m| { + m.write_string("nicotine") + .write_int32(2) + .write_string("alice") + .write_string("bob") + // statuses + .write_int32(2) + .write_int32(2) + .write_int32(1) + // share records + .write_int32(2) + .write_int32(1024) + .write_int64(0) + .write_int32(4321) + .write_int32(77) + .write_int32(50) + .write_int64(0) + .write_int32(10) + .write_int32(2) + // slots full + .write_int32(2) + .write_int32(1) + .write_int32(0) + // countries + .write_int32(2) + .write_string("NL") + .write_string(""); + }) + } + + #[test] + fn member_stats_are_parsed_alongside_the_roster() { + let (tx, rx) = std::sync::mpsc::channel(); + JoinRoomHandler.handle(&mut join_payload(), tx); + + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::RoomJoined { .. }) + )); + match rx.try_recv() { + Ok(ServerMessage::RoomMemberStats { room, stats }) => { + assert_eq!(room, "nicotine"); + assert_eq!( + stats, + vec![ + RoomUserStats { + username: "alice".to_string(), + status: UserStatus::Online, + average_speed: 1024, + shared_files: 4321, + shared_folders: 77, + slots_full: true, + country: Some("NL".to_string()), + }, + RoomUserStats { + username: "bob".to_string(), + status: UserStatus::Away, + average_speed: 50, + shared_files: 10, + shared_folders: 2, + slots_full: false, + country: None, + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_server_that_omits_the_stat_vectors_still_yields_a_roster() { + // Nothing after the membership list: every stat reads as a default + // rather than shifting the roster or dropping members. + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz").write_int32(1).write_string("alice"); + }); + + JoinRoomHandler.handle(&mut message, tx); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::RoomJoined { .. }) + )); + match rx.try_recv() { + Ok(ServerMessage::RoomMemberStats { stats, .. }) => { + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].username, "alice"); + assert_eq!(stats[0].average_speed, 0); + assert_eq!(stats[0].country, None); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn short_stat_vectors_do_not_shift_the_roster() { + // Stats for the first member only: the second must still be reported, + // with defaults, rather than inheriting the first member's figures. + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz") + .write_int32(2) + .write_string("alice") + .write_string("bob") + .write_int32(1) + .write_int32(2) + .write_int32(1) + .write_int32(1024) + .write_int64(0) + .write_int32(9) + .write_int32(1) + .write_int32(0) + .write_int32(0); + }); + + JoinRoomHandler.handle(&mut message, tx); + let _ = rx.try_recv(); + match rx.try_recv() { + Ok(ServerMessage::RoomMemberStats { stats, .. }) => { + assert_eq!(stats.len(), 2); + assert_eq!(stats[0].shared_files, 9); + assert_eq!(stats[1].username, "bob"); + assert_eq!(stats[1].shared_files, 0); + assert_eq!(stats[1].status, UserStatus::Offline); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn hostile_stat_counts_do_not_hang_or_overallocate() { + // Vectors claiming ~4 billion entries in a tiny frame must return + // promptly, bounded by the payload. + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz") + .write_int32(1) + .write_string("alice") + .write_int32(u32::MAX); + }); + + JoinRoomHandler.handle(&mut message, tx); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::RoomJoined { .. }) + )); + } + + #[test] + fn forwards_room_and_member_list() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("nicotine") + .write_int32(2) + .write_string("alice") + .write_string("bob"); + }); + + JoinRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomJoined { room, users }) => { + assert_eq!(room, "nicotine"); + assert_eq!(users, vec!["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/leave_room.rs b/soulseek-rs-lib/src/message/server/leave_room.rs new file mode 100644 index 0000000..583987e --- /dev/null +++ b/soulseek-rs-lib/src/message/server/leave_room.rs @@ -0,0 +1,38 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct LeaveRoomHandler; + +impl MessageHandler for LeaveRoomHandler { + fn get_code(&self) -> u32 { + 15 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::RoomLeft { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn forwards_left_room() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + }); + + LeaveRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomLeft { room }) => assert_eq!(room, "jazz"), + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/login.rs b/soulseek-rs-lib/src/message/server/login.rs new file mode 100644 index 0000000..80dde9b --- /dev/null +++ b/soulseek-rs-lib/src/message/server/login.rs @@ -0,0 +1,29 @@ +use crate::{ + actor::server_actor::ServerMessage, debug, info, message::Message, +}; +use std::sync::mpsc::Sender; + +use crate::message::MessageHandler; + +pub struct LoginHandler; + +impl MessageHandler for LoginHandler { + fn get_code(&self) -> u32 { + 1 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let response = message.read_int8(); + + if response != 1 { + let _ = sender.send(ServerMessage::LoginStatus(false)); + return; + } + + info!("Login successful"); + let greeting = message.read_string(); + debug!("Server greeting: {:?}", greeting); + + let _ = sender.send(ServerMessage::LoginStatus(true)); + } +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs new file mode 100644 index 0000000..4ea1ebe --- /dev/null +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -0,0 +1,950 @@ +use crate::{ + message::Message, + peer::ConnectionType, + types::{ClientVersion, Transfer}, + utils::md5::md5, +}; + +pub struct MessageFactory; +impl MessageFactory { + #[must_use] + pub fn build_get_peer_address(username: &str) -> Message { + let mut message = Message::new(); + + message.write_int32(3); + message.write_string(username); + message + } + #[must_use] + pub fn build_login_message( + username: &str, + password: &str, + version: ClientVersion, + ) -> Message { + let hash = md5([username, password].join("").as_str()); + + let mut message = Message::new(); + + message + .write_int32(1) + .write_string(username) + .write_string(password) + .write_int32(version.major) + .write_string(&hash) + .write_int32(version.minor) + .clone() + } + + #[must_use] + pub fn build_shared_folders_message( + folder_count: u32, + file_count: u32, + ) -> Message { + Message::new() + .write_int32(35) + .write_int32(folder_count) + .write_int32(file_count) + .clone() + } + #[must_use] + pub fn build_file_search_message(token: u32, query: &str) -> Message { + Message::new() + .write_int32(26) + .write_int32(token) + .write_string(query) + .clone() + } + /// Build a private message (server code 22) to send to another user. + #[must_use] + pub fn build_message_user(username: &str, message: &str) -> Message { + Message::new() + .write_int32(22) + .write_string(username) + .write_string(message) + .clone() + } + + /// Acknowledge a received private message (server code 23) so the server + /// stops re-delivering it. + #[must_use] + pub fn build_message_acked(id: u32) -> Message { + Message::new().write_int32(23).write_int32(id).clone() + } + + /// Ask the server (code 18) to broker a connection to a firewalled peer: + /// the server tells that peer to connect back to us, quoting `token`. + #[must_use] + pub fn build_connect_to_peer( + token: u32, + username: &str, + connection_type: ConnectionType, + ) -> Message { + Message::new() + .write_int32(18) + .write_int32(token) + .write_string(username) + .write_string(&connection_type.to_string()) + .clone() + } + + /// Ask the server (code 92) how much of our own privilege time is left. + #[must_use] + pub fn build_check_privileges() -> Message { + Message::new().write_int32(92).clone() + } + + /// Tell a peer where their queued file sits (peer code 44). Place counts + /// from 1. + #[must_use] + pub fn build_place_in_queue_response( + filename: &str, + place: u32, + ) -> Message { + Message::new() + .write_int32(44) + .write_string(filename) + .write_int32(place) + .clone() + } + + /// A wishlist search (server code 103). Same shape as a plain `FileSearch`, + /// but the server rate-limits it to the interval it announced in code 104 + /// and does not count it against the normal search allowance. + #[must_use] + pub fn build_wishlist_search(token: u32, query: &str) -> Message { + Message::new() + .write_int32(103) + .write_int32(token) + .write_string(query) + .clone() + } + + #[must_use] + pub fn build_set_status_message(status_code: u32) -> Message { + Message::new() + .write_int32(28) + .write_int32(status_code) + .clone() + } + #[must_use] + /// Whether we lack a parent in the distributed network (code 71); the + /// server offers PossibleParents while we say so. + pub fn build_have_no_parent(no_parent: bool) -> Message { + Message::new().write_int32(71).write_bool(no_parent).clone() + } + + /// The root of the branch we hang from (code 127): ourselves while we + /// have no parent. + #[must_use] + pub fn build_branch_root(username: &str) -> Message { + Message::new() + .write_int32(127) + .write_string(username) + .clone() + } + + /// How deep in the tree we sit (code 126): 0 without a parent. + #[must_use] + pub fn build_branch_level(level: u32) -> Message { + Message::new().write_int32(126).write_int32(level).clone() + } + + /// Whether we take children in the distributed network (code 100). + #[must_use] + pub fn build_accept_children(accept: bool) -> Message { + Message::new().write_int32(100).write_bool(accept).clone() + } + #[must_use] + pub fn build_set_wait_port_message(port: u16) -> Message { + Message::new() + .write_int32(2) + .write_int32(port.into()) + .clone() + } + /// Ask the server (code 7) for a user's online status. + #[must_use] + pub fn build_get_user_status(username: &str) -> Message { + Message::new().write_int32(7).write_string(username).clone() + } + + /// Tell the server (code 121) what a finished upload averaged, in bytes + /// per second; it folds that into the speed other users see for us. + #[must_use] + pub fn build_send_upload_speed(bytes_per_second: u32) -> Message { + Message::new() + .write_int32(121) + .write_int32(bytes_per_second) + .clone() + } + + /// Ask the server (code 36) for a user's share statistics. + #[must_use] + pub fn build_get_user_stats(username: &str) -> Message { + Message::new() + .write_int32(36) + .write_string(username) + .clone() + } + + /// Watch `username` (server code 5): the server replies with their + /// current status and share statistics, then pushes every later status + /// change as a `GetUserStatus` (code 7) until we unwatch them. + #[must_use] + pub fn build_watch_user(username: &str) -> Message { + Message::new().write_int32(5).write_string(username).clone() + } + + /// Stop watching `username` (server code 6). The server sends no reply. + #[must_use] + pub fn build_unwatch_user(username: &str) -> Message { + Message::new().write_int32(6).write_string(username).clone() + } + + /// Ask the server (code 64) for the list of public chat rooms. + #[must_use] + pub fn build_room_list_request() -> Message { + Message::new().write_int32(64).clone() + } + + /// Join a chat room (server code 14). `private` requests a private room. + #[must_use] + pub fn build_join_room(room: &str, private: bool) -> Message { + Message::new() + .write_int32(14) + .write_string(room) + .write_int32(u32::from(private)) + .clone() + } + + /// Leave a chat room (server code 15). + #[must_use] + pub fn build_leave_room(room: &str) -> Message { + Message::new().write_int32(15).write_string(room).clone() + } + + /// Say `message` in chat room `room` (server code 13). + #[must_use] + pub fn build_say_chatroom(room: &str, message: &str) -> Message { + Message::new() + .write_int32(13) + .write_string(room) + .write_string(message) + .clone() + } + + /// Ask a peer for their shared-file listing (peer code 4, no body). + #[must_use] + pub fn build_get_share_file_list() -> Message { + Message::new().write_int32(4).clone() + } + + #[must_use] + pub fn build_queue_upload_message(filename: &str) -> Message { + Message::new() + .write_int32(43) + .write_string(filename) + .clone() + } + + #[must_use] + pub fn build_transfer_request_message( + filename: &str, + token: u32, + ) -> Message { + Message::new() + .write_int32(40) // code + .write_int32(0) // direction + .write_int32(token) + .write_string(filename) + .clone() + } + /// A TransferRequest (peer code 40) initiating an *upload*: we offer a file + /// to a peer who queued it, quoting our transfer token and its size. + #[must_use] + pub fn build_upload_transfer_request( + filename: &str, + token: u32, + size: u64, + ) -> Message { + Message::new() + .write_int32(40) + .write_int32(1) // direction: upload + .write_int32(token) + .write_string(filename) + .write_int64(size) + .clone() + } + + #[must_use] + pub fn build_transfer_response_message(transfer: Transfer) -> Message { + Message::new() + .write_int32(41) + .write_int32(transfer.token) + .write_bool(true) + .clone() + } + #[must_use] + pub fn build_transfer_denial_message(token: u32, reason: &str) -> Message { + Message::new() + .write_int32(41) + .write_int32(token) + .write_bool(false) + .write_string(reason) + .clone() + } + + #[must_use] + pub fn build_pierce_firewall_message(token: u32) -> Message { + Message::new() + .write_int8(0) // PierceFirewall message code + .write_int32(token) + .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, + connection_type: ConnectionType, + token: u32, + ) -> Message { + Message::new() + .write_int8(1) + .write_string(own_username) + .write_string(&connection_type.to_string()) + .write_int32(token) + .clone() + } +} + +#[test] +fn test_build_get_user_status() { + let message = MessageFactory::build_get_user_status("bob"); + let expect: Vec = [7, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_get_user_stats() { + let message = MessageFactory::build_get_user_stats("bob"); + let expect: Vec = [36, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_login_message() { + let message = MessageFactory::build_login_message( + "insane_in_the_brain2", + "13375137", + ClientVersion::default(), + ); + + let expect: Vec = [ + 1, 0, 0, 0, 20, 0, 0, 0, 105, 110, 115, 97, 110, 101, 95, 105, 110, 95, + 116, 104, 101, 95, 98, 114, 97, 105, 110, 50, 8, 0, 0, 0, 49, 51, 51, + 55, 53, 49, 51, 55, 176, 0, 0, 0, 32, 0, 0, 0, 50, 101, 100, 102, 53, + 49, 100, 48, 51, 55, 57, 52, 51, 55, 56, 102, 56, 98, 98, 54, 51, 49, + 48, 100, 52, 54, 48, 99, 50, 50, 98, 49, 1, 0, 0, 0, + ] + .to_vec(); + + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_upload_transfer_request() { + use crate::types::Transfer; + let message = + MessageFactory::build_upload_transfer_request("song.mp3", 555, 4096); + // Decode via the production Transfer parser (dispatcher starts at offset 8). + let mut decoded = Message::new_with_data(message.get_buffer()); + decoded.set_pointer(8); + let transfer = Transfer::new_from_message(&mut decoded); + assert_eq!(transfer.direction, 1); // upload + assert_eq!(transfer.token, 555); + assert_eq!(transfer.filename, "song.mp3"); + assert_eq!(transfer.size, 4096); +} + +#[test] +fn a_transfer_denial_carries_the_token_and_the_reason() { + let message = + MessageFactory::build_transfer_denial_message(555, "Cancelled"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 41); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 555); + assert!(!decoded.read_bool()); + assert_eq!(decoded.read_string(), "Cancelled"); +} + +#[test] +fn test_build_peer_init_message() { + use crate::peer::ConnectionType; + let message = + MessageFactory::build_peer_init_message("bob", ConnectionType::P, 7); + // [1][len=3]"bob"[len=1]"P"[token=7] — no length prefix in get_data() + let expect: Vec = [ + 1, // PeerInit code (int8) + 3, 0, 0, 0, 98, 111, 98, // username "bob" + 1, 0, 0, 0, 80, // connection type "P" + 7, 0, 0, 0, // token + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_message_user() { + let message = MessageFactory::build_message_user("bob", "hi"); + let expect: Vec = [ + 22, 0, 0, 0, // code + 3, 0, 0, 0, 98, 111, 98, // username "bob" + 2, 0, 0, 0, 104, 105, // message "hi" + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_message_acked() { + let message = MessageFactory::build_message_acked(7); + let expect: Vec = [23, 0, 0, 0, 7, 0, 0, 0].to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_join_room() { + let message = MessageFactory::build_join_room("nicotine", false); + let expect: Vec = [ + 14, 0, 0, 0, // code + 8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine" + 0, 0, 0, 0, // private = 0 + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_leave_room() { + let message = MessageFactory::build_leave_room("nicotine"); + let expect: Vec = [ + 15, 0, 0, 0, // code + 8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine" + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_say_chatroom() { + let message = MessageFactory::build_say_chatroom("room", "hi"); + let expect: Vec = [ + 13, 0, 0, 0, // code + 4, 0, 0, 0, 114, 111, 111, 109, // "room" + 2, 0, 0, 0, 104, 105, // "hi" + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_room_list_request() { + let message = MessageFactory::build_room_list_request(); + assert_eq!(vec![64, 0, 0, 0], message.get_data()); +} + +#[test] +fn test_build_check_privileges() { + assert_eq!( + vec![92, 0, 0, 0], + MessageFactory::build_check_privileges().get_data() + ); +} + +#[test] +fn test_build_place_in_queue_response() { + let message = MessageFactory::build_place_in_queue_response("song.mp3", 3); + let expect: Vec = [ + 44, 0, 0, 0, // code + 8, 0, 0, 0, 115, 111, 110, 103, 46, 109, 112, 51, // "song.mp3" + 3, 0, 0, 0, // place + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_wishlist_search() { + let message = MessageFactory::build_wishlist_search(12, "trance wax"); + let expect: Vec = [ + 103, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32, + 119, 97, 120, + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_file_search_message() { + let message = MessageFactory::build_file_search_message(12, "trance wax"); + let expect: Vec = [ + 26, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32, + 119, 97, 120, + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_watch_user() { + let message = MessageFactory::build_watch_user("bob"); + let expect: Vec = [5, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_unwatch_user() { + let message = MessageFactory::build_unwatch_user("bob"); + let expect: Vec = [6, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn an_upload_speed_report_carries_the_rate() { + let message = MessageFactory::build_send_upload_speed(900); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 121); + 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); +} diff --git a/soulseek-rs-lib/src/message/server/message_user.rs b/soulseek-rs-lib/src/message/server/message_user.rs new file mode 100644 index 0000000..9817e2f --- /dev/null +++ b/soulseek-rs-lib/src/message/server/message_user.rs @@ -0,0 +1,100 @@ +use crate::actor::server_actor::{ServerMessage, UserMessage}; +use crate::info; +use crate::message::server::MessageFactory; +use crate::message::{Message, MessageHandler}; + +use std::sync::mpsc::Sender; + +pub struct MessageUser; + +impl MessageHandler for MessageUser { + fn get_code(&self) -> u32 { + 22 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let id = message.read_int32(); + let timestamp = message.read_int32(); + let username = message.read_string(); + let message_content = message.read_string(); + let new_message = message.read_bool(); + let user_message = UserMessage::new( + id, + timestamp, + username, + message_content, + new_message, + ); + + info!("[MessageUser] User message received:{:?}", user_message); + + // Acknowledge every message, not only the ones flagged new. The flag + // marks a message delivered as it was sent; one the server stored + // while we were offline arrives with it false, and those are exactly + // the messages the server keeps re-sending at every login until they + // are acknowledged. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_message_acked(id), + )); + + // Surface the message to the client so it can be read via the API. + let _ = + sender.send(ServerMessage::PrivateMessageReceived(user_message)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn deliver(new_message: bool) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(7); + m.write_int32(1_700_000_000); + m.write_string("alice"); + m.write_string("hello"); + m.write_int8(u8::from(new_message)); + }); + MessageUser.handle(&mut message, tx); + rx.try_iter().collect() + } + + fn acked_id(messages: &[ServerMessage]) -> Option { + messages.iter().find_map(|message| match message { + ServerMessage::SendMessage(m) => { + let mut decoded = Message::new_with_data(m.get_buffer()); + (decoded.get_message_code() == 23).then(|| { + decoded.set_pointer(8); + decoded.read_int32() + }) + } + _ => None, + }) + } + + #[test] + fn a_freshly_delivered_message_is_acknowledged() { + let sent = deliver(true); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn a_message_stored_while_we_were_offline_is_acknowledged_too() { + // Stored messages arrive with new_message false; without an ack the + // server re-delivers them at every login, forever. + let sent = deliver(false); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn the_message_itself_is_surfaced_to_the_client() { + let sent = deliver(false); + assert!(sent.iter().any(|message| matches!( + message, + ServerMessage::PrivateMessageReceived(m) + if m.message() == "hello" && m.username() == "alice" + ))); + } +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs new file mode 100644 index 0000000..41a4f77 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -0,0 +1,66 @@ +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; +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; +mod user_left_room; +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, +}; +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; +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; +pub use user_left_room::UserLeftRoomHandler; +pub use watch_user::WatchUserHandler; +pub use wish_list_interval::WishListIntervalHandler; diff --git a/soulseek-rs-lib/src/message/server/parent_min_speed.rs b/soulseek-rs-lib/src/message/server/parent_min_speed.rs new file mode 100644 index 0000000..e783421 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/parent_min_speed.rs @@ -0,0 +1,45 @@ +use crate::debug; +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +pub struct ParentMinSpeedHandler; + +impl MessageHandler for ParentMinSpeedHandler { + fn get_code(&self) -> u32 { + 83 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let number = message.read_int32(); + debug!("Parent min speed: {}", number); + // Kept, not just logged: with the ratio below it decides how many + // children this client may carry. + let _ = sender.send(ServerMessage::ParentMinSpeed(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_minimum_speed_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1024); + }); + + ParentMinSpeedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentMinSpeed(speed)) => { + assert_eq!(speed, 1024); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs new file mode 100644 index 0000000..e171927 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs @@ -0,0 +1,44 @@ +use crate::debug; +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +pub struct ParentSpeedRatioHandler; + +// The server sends us a speed ratio determining the number of children we can have in the distributed network. The maximum number of children is our upload speed divided by the speed ratio. +impl MessageHandler for ParentSpeedRatioHandler { + fn get_code(&self) -> u32 { + 84 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let number = message.read_int32(); + debug!("Parent speed ratio: {}", number); + let _ = sender.send(ServerMessage::ParentSpeedRatio(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_ratio_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(50); + }); + + ParentSpeedRatioHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentSpeedRatio(ratio)) => { + assert_eq!(ratio, 50); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs new file mode 100644 index 0000000..7936014 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -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) { + let room = message.read_string(); + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + users.push(username); + } + (room, users) +} + +/// `RoomMembers` (code 133): who may enter a private room. +pub struct RoomMembersHandler; + +impl MessageHandler for RoomMembersHandler { + fn get_code(&self) -> u32 { + 133 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users }); + } +} + +/// `RoomOperators` (code 148): who runs a private room. +pub struct RoomOperatorsHandler; + +impl MessageHandler for RoomOperatorsHandler { + fn get_code(&self) -> u32 { + 148 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = + sender.send(ServerMessage::PrivateRoomOperators { room, users }); + } +} + +/// One user added to or removed from a private room's roster, by the code it +/// arrived under: members added (134) or removed (135), operators added (143) +/// or removed (144). +pub struct RoomRosterChangeHandler(pub u32); + +impl MessageHandler for RoomRosterChangeHandler { + fn get_code(&self) -> u32 { + self.0 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: 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 for OwnRoomStandingHandler { + fn get_code(&self) -> u32 { + self.0 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + 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 for CantCreateRoomHandler { + fn get_code(&self) -> u32 { + 1003 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::CantCreateRoom { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_member_roster_lists_the_rooms_members() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("bob"); + }); + + RoomMembersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomMembers { room, users }) => { + assert_eq!(room, "club"); + assert_eq!(users, ["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_roster_count_beyond_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(u32::MAX); + m.write_string("alice"); + }); + + RoomOperatorsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomOperators { users, .. }) => { + assert_eq!(users, ["alice"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn each_roster_change_reports_which_roster_and_which_way() { + for (handler, members, added) in [ + (RoomRosterChangeHandler(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:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/privileged_users.rs b/soulseek-rs-lib/src/message/server/privileged_users.rs new file mode 100644 index 0000000..7f78ccc --- /dev/null +++ b/soulseek-rs-lib/src/message/server/privileged_users.rs @@ -0,0 +1,115 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `PrivilegedUsers` (code 69): everyone the server counts as having donated, +/// sent once at login. +/// +/// The names matter, not the count: they decide who overtakes whom in our +/// upload queue, which is the "recognition of privileges" an alternative client +/// is expected to implement. +pub struct PrivilegedUsersHandler; + +impl MessageHandler for PrivilegedUsersHandler { + fn get_code(&self) -> u32 { + 69 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + // Guard against a hostile count outrunning the payload. + if message.get_pointer() + 4 > message.get_size() { + break; + } + users.push(message.read_string()); + } + let _ = sender.send(ServerMessage::PrivilegedUsers(users)); + } +} + +/// `CheckPrivileges` (code 92): how many seconds of our own privileges are +/// left. Zero means we have none. +pub struct CheckPrivilegesHandler; + +impl MessageHandler for CheckPrivilegesHandler { + fn get_code(&self) -> u32 { + 92 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let seconds = message.read_int32(); + let _ = sender.send(ServerMessage::OwnPrivileges(seconds)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_user_list_is_kept_not_just_counted() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2) + .write_string("donor_a") + .write_string("donor_b"); + }); + + PrivilegedUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivilegedUsers(users)) => { + assert_eq!(users, ["donor_a", "donor_b"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn an_empty_list_is_still_an_answer() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(0); + }); + + PrivilegedUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivilegedUsers(users)) => { + assert!(users.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_hostile_count_does_not_hang() { + // count=u32::MAX with no names: the guard must return promptly rather + // than loop four billion times. + let (tx, _rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(u32::MAX); + }); + PrivilegedUsersHandler.handle(&mut message, tx); + } + + #[test] + fn our_own_remaining_privilege_time_is_reported() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(86_400); + }); + + CheckPrivilegesHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::OwnPrivileges(seconds)) => { + assert_eq!(seconds, 86_400); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/relogged.rs b/soulseek-rs-lib/src/message/server/relogged.rs new file mode 100644 index 0000000..1d15361 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/relogged.rs @@ -0,0 +1,18 @@ +use crate::actor::server_actor::ServerMessage; +use crate::message::{Message, MessageHandler}; +use std::sync::mpsc::Sender; + +/// `Relogged` (code 41): the server is about to cut this connection because +/// the same username logged in somewhere else. +pub struct ReloggedHandler; + +impl MessageHandler for ReloggedHandler { + fn get_code(&self) -> u32 { + 41 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let _ = message; + let _ = sender.send(ServerMessage::Relogged); + } +} diff --git a/soulseek-rs-lib/src/message/server/room_list.rs b/soulseek-rs-lib/src/message/server/room_list.rs new file mode 100644 index 0000000..710a7ab --- /dev/null +++ b/soulseek-rs-lib/src/message/server/room_list.rs @@ -0,0 +1,123 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::RoomInfo, +}; +use std::sync::mpsc::Sender; + +pub struct RoomListHandler; + +impl MessageHandler for RoomListHandler { + fn get_code(&self) -> u32 { + 64 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let rooms = parse_room_list(message); + let _ = sender.send(ServerMessage::RoomListReceived(rooms)); + } +} + +/// Parse the public rooms out of a `RoomList` (code 64) message: a vector of +/// room names followed by a vector of user counts. The remaining private-room +/// sections are ignored. `message` must be positioned at the payload (the +/// dispatcher sets pointer 8). +#[must_use] +pub fn parse_room_list(message: &mut Message) -> Vec { + let name_count = message.read_int32(); + let mut names = Vec::new(); + for _ in 0..name_count { + // Stop once the payload can't hold another field: a bogus (possibly + // hostile) count must not spin us into an OOM allocation loop. A + // 4-byte floor also avoids read_int32 stalling on a 1-3 byte tail. + if message.get_pointer() + 4 > message.get_size() { + break; + } + names.push(message.read_string()); + } + let count_count = message.read_int32(); + let mut counts = Vec::new(); + for _ in 0..count_count { + if message.get_pointer() + 4 > message.get_size() { + break; + } + counts.push(message.read_int32()); + } + names + .into_iter() + .zip(counts) + .map(|(name, user_count)| RoomInfo { name, user_count }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn parses_names_zipped_with_counts() { + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("nicotine"); + m.write_string("jazz"); + m.write_int32(2); + m.write_int32(42); + m.write_int32(7); + }); + let rooms = parse_room_list(&mut message); + assert_eq!( + rooms, + vec![ + RoomInfo { + name: "nicotine".to_string(), + user_count: 42 + }, + RoomInfo { + name: "jazz".to_string(), + user_count: 7 + }, + ] + ); + } + + #[test] + fn empty_room_list_parses_to_empty() { + let mut message = framed(|m| { + m.write_int32(0); + m.write_int32(0); + }); + assert!(parse_room_list(&mut message).is_empty()); + } + + #[test] + fn hostile_counts_do_not_hang_or_overallocate() { + // A tiny frame claiming ~4 billion names/counts must return promptly + // (bounded by the payload) rather than looping into an OOM. + let mut message = framed(|m| { + m.write_int32(u32::MAX); + }); + let rooms = parse_room_list(&mut message); + assert!(rooms.is_empty()); + } + + #[test] + fn handler_forwards_parsed_rooms() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("room"); + m.write_int32(1); + m.write_int32(5); + }); + RoomListHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomListReceived(rooms)) => { + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].name, "room"); + assert_eq!(rooms[0].user_count, 5); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/room_tickers.rs b/soulseek-rs-lib/src/message/server/room_tickers.rs new file mode 100644 index 0000000..d9ab1c9 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/room_tickers.rs @@ -0,0 +1,174 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::RoomTicker, +}; + +/// `RoomTickers` (code 113): the whole ticker board of a room, sent when we +/// join it. +pub struct RoomTickersHandler; + +impl MessageHandler for RoomTickersHandler { + fn get_code(&self) -> u32 { + 113 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let count = message.read_int32(); + // A truncated or hostile count must not preallocate: grow as entries + // actually parse, and stop at the first one that runs off the end. + let mut tickers = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + let ticker = message.read_string(); + if username.is_empty() { + break; + } + tickers.push(RoomTicker { username, ticker }); + } + let _ = sender.send(ServerMessage::RoomTickers { room, tickers }); + } +} + +/// `RoomTickerAdded` (code 114): one user set or replaced their ticker. +pub struct RoomTickerAddedHandler; + +impl MessageHandler for RoomTickerAddedHandler { + fn get_code(&self) -> u32 { + 114 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let ticker = message.read_string(); + let _ = sender.send(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }); + } +} + +/// `RoomTickerRemoved` (code 115): one user cleared their ticker. +pub struct RoomTickerRemovedHandler; + +impl MessageHandler for RoomTickerRemovedHandler { + fn get_code(&self) -> u32 { + 115 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = + sender.send(ServerMessage::RoomTickerRemoved { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_ticker_board_carries_every_entry_in_order() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("hello there"); + m.write_string("bob"); + m.write_string("back later"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { room, tickers }) => { + assert_eq!(room, "jazz"); + assert_eq!( + tickers, + vec![ + RoomTicker { + username: "alice".into(), + ticker: "hello there".into() + }, + RoomTicker { + username: "bob".into(), + ticker: "back later".into() + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_count_larger_than_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1_000_000); + m.write_string("alice"); + m.write_string("hi"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { tickers, .. }) => { + assert_eq!(tickers.len(), 1); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn an_added_ticker_reports_its_room_user_and_text() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("new ticker"); + }); + + RoomTickerAddedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), ticker.as_str()), + ("jazz", "alice", "new ticker") + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_removed_ticker_reports_its_room_and_user() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + }); + + RoomTickerRemovedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerRemoved { room, username }) => { + assert_eq!( + (room.as_str(), username.as_str()), + ("jazz", "alice") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/say_chatroom.rs b/soulseek-rs-lib/src/message/server/say_chatroom.rs new file mode 100644 index 0000000..87e30b7 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/say_chatroom.rs @@ -0,0 +1,54 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct SayChatroomHandler; + +impl MessageHandler for SayChatroomHandler { + fn get_code(&self) -> u32 { + 13 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let message_text = message.read_string(); + let _ = sender.send(ServerMessage::RoomMessageReceived { + room, + username, + message: message_text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn forwards_room_message() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz") + .write_string("alice") + .write_string("hello everyone"); + }); + + SayChatroomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "alice"); + assert_eq!(message, "hello everyone"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/user_info.rs b/soulseek-rs-lib/src/message/server/user_info.rs new file mode 100644 index 0000000..0a0cb7d --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_info.rs @@ -0,0 +1,112 @@ +//! `GetUserStatus` (code 7) and `GetUserStats` (code 36): what the server +//! knows about another user. +//! +//! The two answers arrive as separate messages, so a caller that wants a full +//! picture asks for both and merges them — see `Client::request_user_info`. + +use crate::actor::server_actor::ServerMessage; +use crate::message::{Message, MessageHandler}; +use std::sync::mpsc::Sender; + +/// Receives `GetUserStatus` (code 7): `username`, `status`, `privileged`. +pub struct GetUserStatusHandler; + +impl MessageHandler for GetUserStatusHandler { + fn get_code(&self) -> u32 { + 7 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let status = message.read_int32(); + let privileged = message.read_bool(); + let _ = sender.send(ServerMessage::UserStatusReceived { + username, + status, + privileged, + }); + } +} + +/// Receives `GetUserStats` (code 36): `username`, `average upload speed`, two +/// obsolete fields, then the shared file and folder counts. +pub struct GetUserStatsHandler; + +impl MessageHandler for GetUserStatsHandler { + fn get_code(&self) -> u32 { + 36 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let average_speed = message.read_int32(); + let _upload_number = message.read_int32(); // obsolete, always 0 + let _unknown = message.read_int32(); // obsolete, always 0 + let shared_files = message.read_int32(); + let shared_folders = message.read_int32(); + let _ = sender.send(ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn user_status_is_parsed_with_its_privileged_flag() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice").write_int32(2).write_int8(1); + }); + + GetUserStatusHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserStatusReceived { + username, + status, + privileged, + }) => { + assert_eq!(username, "alice"); + assert_eq!(status, 2); + assert!(privileged); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn user_stats_skip_the_two_obsolete_fields() { + let (tx, rx) = std::sync::mpsc::channel(); + // username, speed, obsolete, obsolete, files, folders + let mut message = framed(|m| { + m.write_string("bob") + .write_int32(1024) + .write_int32(0) + .write_int32(0) + .write_int32(4321) + .write_int32(77); + }); + + GetUserStatsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }) => { + assert_eq!(username, "bob"); + assert_eq!(average_speed, 1024); + assert_eq!(shared_files, 4321, "must skip the obsolete pair"); + assert_eq!(shared_folders, 77); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/user_joined_room.rs b/soulseek-rs-lib/src/message/server/user_joined_room.rs new file mode 100644 index 0000000..2682b53 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_joined_room.rs @@ -0,0 +1,44 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct UserJoinedRoomHandler; + +impl MessageHandler for UserJoinedRoomHandler { + fn get_code(&self) -> u32 { + 16 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + // UserJoinedRoom (code 16): room, username, then that user's stats, + // which we don't need. Reading the first two fields is enough. + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::RoomUserJoined { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn forwards_user_joined() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz").write_string("carol"); + }); + + UserJoinedRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomUserJoined { room, username }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "carol"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/user_left_room.rs b/soulseek-rs-lib/src/message/server/user_left_room.rs new file mode 100644 index 0000000..9438163 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_left_room.rs @@ -0,0 +1,42 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct UserLeftRoomHandler; + +impl MessageHandler for UserLeftRoomHandler { + fn get_code(&self) -> u32 { + 17 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::RoomUserLeft { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn forwards_user_left() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz").write_string("carol"); + }); + + UserLeftRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomUserLeft { room, username }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "carol"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/watch_user.rs b/soulseek-rs-lib/src/message/server/watch_user.rs new file mode 100644 index 0000000..8fd478f --- /dev/null +++ b/soulseek-rs-lib/src/message/server/watch_user.rs @@ -0,0 +1,146 @@ +//! `WatchUser` (code 5): subscribe to a user's status and share statistics. +//! +//! Watching is how a client keeps a buddy list current. After a `WatchUser` +//! request the server answers on the same code with the user's current +//! presence and stats, and from then on pushes every later change as an +//! ordinary `GetUserStatus` (code 7). `UnwatchUser` (code 6) cancels the +//! subscription and has no reply. +//! +//! Note on codes: the protocol has no `AddUserList`/`WatchedUserAdded` +//! /`WatchedUserRemoved` messages — 141/142/143 are `EnableRoomInvitations`, +//! `ChangePassword` and `AddRoomOperator`. Watching is 5/6, per the reference +//! protocol documentation. + +use crate::actor::server_actor::ServerMessage; +use crate::message::{Message, MessageHandler}; +use std::sync::mpsc::Sender; + +/// Receives the `WatchUser` reply (code 5): `username`, `exists`, and — when +/// the user exists — status plus the same share statistics `GetUserStats` +/// carries. +pub struct WatchUserHandler; + +impl MessageHandler for WatchUserHandler { + fn get_code(&self) -> u32 { + 5 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let exists = message.read_bool(); + let watched = if exists { + let status = message.read_int32(); + let average_speed = message.read_int32(); + // Two obsolete 32-bit fields, exactly as GetUserStats (36) + // carries them -- not one 64-bit value. The byte count is the + // same either way, but reading them as the server writes them + // keeps this handler and its sibling honest. + let _upload_number = message.read_int32(); // obsolete, always 0 + let _unknown = message.read_int32(); // obsolete, always 0 + let shared_files = message.read_int32(); + let shared_folders = message.read_int32(); + ServerMessage::WatchedUserReceived { + username, + exists, + status: Some(status), + average_speed: Some(average_speed), + shared_files: Some(shared_files), + shared_folders: Some(shared_folders), + } + } else { + // An unknown username: the server stops after the flag, so there + // is nothing further to read and nothing to record. + ServerMessage::WatchedUserReceived { + username, + exists, + status: None, + average_speed: None, + shared_files: None, + shared_folders: None, + } + }; + let _ = sender.send(watched); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn existing_user_carries_status_and_stats() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice") + .write_int8(1) // exists + .write_int32(2) // status: online + .write_int32(1024) // average speed + .write_int32(0) // obsolete upload number + .write_int32(0) // obsolete unknown + .write_int32(4321) // shared files + .write_int32(77); // shared folders + }); + + WatchUserHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + }) => { + assert_eq!(username, "alice"); + assert!(exists); + assert_eq!(status, Some(2)); + assert_eq!(average_speed, Some(1024)); + assert_eq!(shared_files, Some(4321)); + assert_eq!(shared_folders, Some(77)); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn unknown_user_reports_absence_without_stats() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("ghost").write_int8(0); + }); + + WatchUserHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::WatchedUserReceived { + username, + exists, + status, + shared_files, + .. + }) => { + assert_eq!(username, "ghost"); + assert!(!exists); + assert_eq!(status, None); + assert_eq!(shared_files, None); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn truncated_payload_does_not_panic() { + // exists = 1 but the stats were cut off: the bounds-checked readers + // must yield zeros rather than panicking on a hostile frame. + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice").write_int8(1).write_int32(2); + }); + + WatchUserHandler.handle(&mut message, tx); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::WatchedUserReceived { .. }) + )); + } +} diff --git a/soulseek-rs-lib/src/message/server/wish_list_interval.rs b/soulseek-rs-lib/src/message/server/wish_list_interval.rs new file mode 100644 index 0000000..ce8f141 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/wish_list_interval.rs @@ -0,0 +1,46 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `WishlistInterval` (code 104): how often the server will accept a wishlist +/// search from us. Usually 720 seconds, or 120 for a privileged account. +/// +/// The value is the server's rate limit, not a suggestion, so it is kept rather +/// than logged: the re-search timer runs off it. +pub struct WishListIntervalHandler; + +impl MessageHandler for WishListIntervalHandler { + fn get_code(&self) -> u32 { + 104 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let seconds = message.read_int32(); + let _ = sender.send(ServerMessage::WishlistInterval(seconds)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_announced_interval_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(720); + }); + + WishListIntervalHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::WishlistInterval(seconds)) => { + assert_eq!(seconds, 720); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs new file mode 100644 index 0000000..fd2e231 --- /dev/null +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -0,0 +1,1076 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::net::TcpStream; +use std::net::ToSocketAddrs; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::client::ClientContext; +use crate::message::server::MessageFactory; +use crate::trace; +use crate::types::{Download, DownloadStatus}; +use crate::utils::path::{PART_SUFFIX, expand_tilde}; + +/// Read in 64 KiB slices: an eighth of the syscalls of the 8 KiB reads before, +/// and what a fast peer fills per read. slskd reads 256 KiB, Nicotine+ ~50 KiB. +const READ_BUFFER_SIZE: usize = 64 * 1024; +const CANCEL_POLL: Duration = Duration::from_secs(1); +const STALL_DEADLINE: Duration = Duration::from_secs(30); +/// Bytes between progress reports. Counting bytes rather than reads keeps +/// the report volume flat however big a read is, and gives a slow peer's +/// rate a window wide enough to mean something. +const PROGRESS_INTERVAL_BYTES: usize = 128 * 1024; + +#[derive(Debug)] +pub enum DownloadError { + ConnectionFailed(io::Error), + InvalidAddress(String), + HandshakeFailed(io::Error), + StreamReadError(io::Error), + StreamWriteError(io::Error), + TokenNotFound(u32), + DownloadInfoMissing(u32), + FileWriteError(io::Error), + PathResolutionError(String), + LockPoisoned, + Cancelled, + IncompleteDownload { received: u64, expected: u64 }, +} + +impl std::fmt::Display for DownloadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConnectionFailed(e) => write!(f, "Connection failed: {e}"), + Self::InvalidAddress(addr) => { + write!(f, "Invalid address: {addr}") + } + Self::HandshakeFailed(e) => write!(f, "Handshake failed: {e}"), + Self::StreamReadError(e) => write!(f, "Stream read error: {e}"), + Self::StreamWriteError(e) => write!(f, "Stream write error: {e}"), + Self::TokenNotFound(token) => { + write!(f, "Token not found: {token}") + } + Self::DownloadInfoMissing(token) => { + write!(f, "Download info missing for token: {token}") + } + Self::FileWriteError(e) => write!(f, "File write error: {e}"), + Self::PathResolutionError(msg) => { + write!(f, "Path resolution error: {msg}") + } + Self::LockPoisoned => write!(f, "Lock poisoned"), + Self::Cancelled => write!(f, "Download cancelled"), + Self::IncompleteDownload { received, expected } => write!( + f, + "Incomplete download: received {received} of {expected} bytes" + ), + } + } +} + +impl std::error::Error for DownloadError {} + +fn extract_filename_from_path(full_path: &str) -> &str { + full_path + .split(['/', '\\']) + .next_back() + .unwrap_or(full_path) +} + +fn part_path(final_path: &str) -> String { + format!("{final_path}{PART_SUFFIX}") +} + +pub fn part_path_of(download: &Download) -> Option { + resolve_download_path(download) + .ok() + .map(|path| part_path(&path)) +} + +/// Where a download's bytes end up: its configured directory (falling back +/// to the parent if it names a file) joined with the remote basename. +/// +/// A directory that does not exist yet stays the target — `PartFile::open` +/// creates it. Treating it as "not a directory" and dodging to the parent +/// made a daemon whose folder was never created download into `~/Downloads` +/// while reporting `~/Downloads/Soulseek`. +fn resolve_download_path(download: &Download) -> Result { + let mut expanded_path = expand_tilde(&download.download_directory); + + if expanded_path.is_file() { + expanded_path = expanded_path + .parent() + .ok_or_else(|| { + DownloadError::PathResolutionError(format!( + "Cannot resolve parent directory for: {}", + expanded_path.display() + )) + })? + .to_path_buf(); + } + + let final_path = + expanded_path.join(extract_filename_from_path(&download.filename)); + + final_path + .to_str() + .ok_or_else(|| { + DownloadError::PathResolutionError(format!( + "Path contains invalid UTF-8: {}", + final_path.display() + )) + }) + .map(String::from) +} + +/// The `.part` file a transfer streams into, so an interrupted transfer leaves +/// behind what it managed to fetch and the next attempt can pick up from there. +struct PartFile { + file: File, + final_path: String, + written: u64, + expected: u64, +} + +impl PartFile { + fn open(download: &Download) -> Result { + let final_path = resolve_download_path(download)?; + let path = PathBuf::from(part_path(&final_path)); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(DownloadError::FileWriteError)?; + } + + // 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 }; + + let file = if written > 0 { + OpenOptions::new().append(true).open(&path) + } else { + File::create(&path) + } + .map_err(DownloadError::FileWriteError)?; + + Ok(Self { + file, + final_path, + written, + expected: download.size, + }) + } + + /// 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; + 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)?; + self.written += data.len() as u64; + Ok(()) + } + + const fn is_complete(&self) -> bool { + self.written >= self.expected + } + + fn discard(self) { + drop(self.file); + let _ = fs::remove_file(part_path(&self.final_path)); + } + + /// A short transfer is a failure, and the `.part` stays put so the next + /// attempt can resume it. + fn finish(self) -> Result { + if !self.is_complete() { + return Err(DownloadError::IncompleteDownload { + received: self.written, + expected: self.expected, + }); + } + drop(self.file); + fs::rename(part_path(&self.final_path), &self.final_path) + .map_err(DownloadError::FileWriteError)?; + Ok(self.final_path) + } +} + +pub struct DownloadPeer { + username: String, + host: String, + port: u32, + #[allow(dead_code)] + own_username: String, + token: u32, + no_pierce: bool, +} + +impl DownloadPeer { + #[must_use] + pub const fn new( + username: String, + host: String, + port: u32, + token: u32, + no_pierce: bool, + own_username: String, + ) -> Self { + Self { + username, + host, + port, + own_username, + token, + no_pierce, + } + } + + fn establish_connection(&self) -> Result { + let socket_address = format!("{}:{}", self.host, self.port) + .to_socket_addrs() + .map_err(DownloadError::ConnectionFailed)? + .next() + .ok_or_else(|| { + DownloadError::InvalidAddress(format!( + "{}:{}", + self.host, self.port + )) + })?; + + let stream = TcpStream::connect_timeout( + &socket_address, + Duration::from_secs(20), + ) + .map_err(DownloadError::ConnectionFailed)?; + + Self::apply_transfer_deadlines(&stream)?; + + Ok(stream) + } + + /// Bound every stall on a transfer socket. This must cover streams the + /// listener accepted as well as ones we dialled: an accepted F connection + /// used to arrive here with no read timeout at all, so a peer that sent a + /// token and went silent parked the transfer thread — holding the socket + /// and the open `.part` file — for the rest of the session. + fn apply_transfer_deadlines( + stream: &TcpStream, + ) -> Result<(), DownloadError> { + stream + .set_read_timeout(Some(STALL_DEADLINE)) + .map_err(DownloadError::ConnectionFailed)?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .map_err(DownloadError::ConnectionFailed)?; + stream + .set_nodelay(true) + .map_err(DownloadError::ConnectionFailed)?; + Ok(()) + } + + fn perform_handshake( + &self, + stream: &mut TcpStream, + ) -> Result<(), DownloadError> { + trace!( + "[download_peer:{}] performing handshake no_pierce: {}", + self.username, self.no_pierce + ); + + if !self.no_pierce { + let message = + MessageFactory::build_pierce_firewall_message(self.token); + stream + .write_all(&message.get_buffer()) + .map_err(DownloadError::HandshakeFailed)?; + trace!( + "[download_peer:{}] sending pierce firewall message token: {}: {:?}", + self.username, + self.token, + &message.get_buffer() + ); + stream.flush().map_err(DownloadError::HandshakeFailed)?; + } + Ok(()) + } + + /// Soulseek's START_DOWNLOAD is an 8-byte little-endian offset, so anything + /// already in the `.part` file is skipped by asking the peer to start past it. + fn start_transfer( + download: Download, + stream: &mut TcpStream, + client_context: &Arc>, + ) -> Result<(Download, PartFile), DownloadError> { + Self::wait_while_paused(client_context, &download)?; + let part = PartFile::open(&download)?; + stream + .write_all(&part.written.to_le_bytes()) + .map_err(DownloadError::StreamWriteError)?; + Self::report_progress(client_context, &download, part.written, 0.0); + Ok((download, part)) + } + + fn read_download_stream( + &self, + stream: &mut TcpStream, + client_context: &Arc>, + download: Option, + matched_download: &mut Option, + ) -> Result<(Download, String), DownloadError> { + let mut read_buffer = vec![0u8; READ_BUFFER_SIZE]; + let mut bytes_since_last_update = 0usize; + let mut last_update_time = Instant::now(); + + trace!( + "[download_peer:{}] Starting to read data from peer", + self.username + ); + + 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 speed_limit = client_context + .read() + .map_err(|_| DownloadError::LockPoisoned)? + .download_speed_limit + .clone(); + let mut last_data = Instant::now(); + + loop { + 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; + } + + let allowance = speed_limit.take(READ_BUFFER_SIZE); + let read = stream.read(&mut read_buffer[..allowance]); + speed_limit.give_back(allowance - read.as_ref().map_or(0, |&n| n)); + match read { + Ok(0) => { + trace!( + "[download_peer:{}] connection closed by peer", + self.username + ); + break; + } + Ok(bytes_read) => { + last_data = Instant::now(); + part.write(&read_buffer[..bytes_read])?; + bytes_since_last_update += bytes_read; + + if bytes_since_last_update >= PROGRESS_INTERVAL_BYTES { + let elapsed = last_update_time.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { + bytes_since_last_update as f64 / elapsed + } else { + 0.0 + }; + Self::report_progress( + client_context, + &download, + part.written, + speed, + ); + bytes_since_last_update = 0; + last_update_time = Instant::now(); + } + + if part.is_complete() { + break; + } + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) && last_data.elapsed() < STALL_DEADLINE => {} + Err(e) => { + return Err(DownloadError::StreamReadError(e)); + } + } + } + + trace!( + "[download_peer:{}] finished reading data from peer", + self.username + ); + + let final_path = + Self::finish_transfer(client_context, &download, part)?; + Ok((download, final_path)) + } + + fn report_progress( + client_context: &Arc>, + download: &Download, + bytes_downloaded: u64, + speed_bytes_per_sec: f64, + ) { + Self::send_download_status( + client_context, + download, + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes: download.size, + speed_bytes_per_sec, + }, + ); + } + + pub(crate) fn send_download_status( + client_context: &Arc>, + download: &Download, + status: DownloadStatus, + ) { + let applied = client_context.write().is_ok_and(|mut context| { + context + .downloads + .update_status(download.token, status.clone()) + }); + if applied { + let _ = download.sender.send(status); + } + } + + /// Promote a complete partial and publish `Completed` as one operation with + /// respect to cancellation. Holding the store write lock across the rename + /// gives both possible races deterministic outcomes: cancellation marks the + /// row first and discards the partial, or completion lands first and a later + /// cancellation is refused. + fn finish_transfer( + client_context: &Arc>, + download: &Download, + part: PartFile, + ) -> Result { + loop { + let mut context = client_context + .write() + .map_err(|_| DownloadError::LockPoisoned)?; + let status = context + .get_download_by_token(download.token) + .map(|download| download.status.clone()) + .ok_or(DownloadError::TokenNotFound(download.token))?; + + if matches!(status, DownloadStatus::Cancelled) { + drop(context); + part.discard(); + return Err(DownloadError::Cancelled); + } + if matches!(status, DownloadStatus::Paused { .. }) { + drop(context); + if let Err(error) = + Self::wait_while_paused(client_context, download) + { + if matches!(error, DownloadError::Cancelled) { + part.discard(); + } + return Err(error); + } + continue; + } + + let final_path = part.finish()?; + if !context + .downloads + .update_status(download.token, DownloadStatus::Completed) + { + return Err(DownloadError::TokenNotFound(download.token)); + } + drop(context); + let _ = download.sender.send(DownloadStatus::Completed); + return Ok(final_path); + } + } + + fn wait_while_paused( + client_context: &Arc>, + download: &Download, + ) -> Result { + let mut waited = false; + loop { + let status = client_context + .read() + .map_err(|_| DownloadError::LockPoisoned)? + .get_download_by_token(download.token) + .map(|download| download.status.clone()) + .ok_or(DownloadError::TokenNotFound(download.token))?; + + if matches!(status, DownloadStatus::Cancelled) { + return Err(DownloadError::Cancelled); + } + if !matches!(status, DownloadStatus::Paused { .. }) { + return Ok(waited); + } + + waited = true; + thread::sleep(Duration::from_millis(200)); + } + } + + pub fn download_file( + self, + client_context: Arc>, + download: Option, + stream: Option, + ) -> Result<(Download, String), DownloadError> { + 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()?, + }; + + trace!("[download_peer:{}] connected", 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, + &mut matched_download, + )?; + + trace!( + "[download_peer:{}] download completed successfully: {} bytes, saved to: {}", + self.username, download.size, 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 + } +} + +#[cfg(test)] +mod tests { + use super::{ + DownloadError, DownloadPeer, PartFile, extract_filename_from_path, + }; + use crate::client::ClientContext; + use crate::types::{Download, DownloadMetadata, DownloadStatus}; + use std::sync::{Arc, RwLock, mpsc}; + + fn scratch_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir() + .join(format!("soulseek-part-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn download_into(dir: &std::path::Path, size: u64) -> Download { + Download { + username: "peer".to_string(), + filename: "file.bin".to_string(), + token: 1, + size, + download_directory: dir.display().to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + } + } + + #[test] + fn a_discarded_part_file_is_removed_from_disk() { + let dir = scratch_dir("discard"); + let mut part = PartFile::open(&download_into(&dir, 8)).unwrap(); + part.write(b"data").unwrap(); + let path = dir.join("file.bin.part"); + assert!(path.exists()); + + part.discard(); + + assert!(!path.exists()); + assert!(!dir.join("file.bin").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_pause_is_reported_so_the_stall_budget_restarts() { + let dir = scratch_dir("paused"); + let mut download = download_into(&dir, 8); + download.status = DownloadStatus::Paused { + bytes_downloaded: 0, + total_bytes: 8, + }; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + let resumer = { + let context = context.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + assert!( + context + .write() + .unwrap() + .downloads + .resume_by_file("peer", "file.bin") + ); + }) + }; + + assert!(matches!( + DownloadPeer::wait_while_paused(&context, &download), + Ok(true) + )); + assert!(matches!( + DownloadPeer::wait_while_paused(&context, &download), + Ok(false) + )); + let _ = resumer.join(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_cancelled_download_stops_the_transfer_loop() { + let dir = scratch_dir("cancelled"); + let mut download = download_into(&dir, 8); + download.status = DownloadStatus::Cancelled; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + assert!(matches!( + DownloadPeer::wait_while_paused(&context, &download), + Err(DownloadError::Cancelled) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_status_after_cancel_is_not_relayed_on_the_channel() { + let dir = scratch_dir("relay"); + let (tx, rx) = mpsc::channel(); + let mut download = download_into(&dir, 8); + download.sender = tx; + download.status = DownloadStatus::Cancelled; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + DownloadPeer::send_download_status( + &context, + &download, + DownloadStatus::InProgress { + bytes_downloaded: 4, + total_bytes: 8, + speed_bytes_per_sec: 1.0, + }, + ); + + assert!(rx.try_recv().is_err(), "a cancelled row hears nothing more"); + 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"); + let mut download = download_into(&dir, 8); + download.status = DownloadStatus::Cancelled; + 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 mut stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + let _peer = listener.accept().unwrap(); + + let outcome = + DownloadPeer::start_transfer(download, &mut stream, &context); + + assert!(matches!(outcome, Err(DownloadError::Cancelled))); + assert!(!dir.join("file.bin.part").exists(), "no .part is opened"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_directory_that_does_not_exist_yet_is_created_not_dodged() { + let dir = scratch_dir("fresh").join("Soulseek"); + + let mut part = PartFile::open(&download_into(&dir, 4)).unwrap(); + part.write(b"data").unwrap(); + let final_path = part.finish().unwrap(); + + assert_eq!( + std::path::Path::new(&final_path).parent(), + Some(dir.as_path()), + "the bytes belong in the configured directory, not its parent" + ); + assert_eq!(std::fs::read(&final_path).unwrap(), b"data"); + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); + } + + #[test] + fn a_partial_file_sets_the_resume_offset() { + let dir = scratch_dir("resume"); + 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"); + + part.write(b"456789").unwrap(); + let final_path = part.finish().unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"0123456789"); + assert!( + !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("file.bin.part"), vec![9u8; 40]).unwrap(); + + let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); + assert_eq!(part.written, 0); + + part.write(&(0..10).collect::>()).unwrap(); + let final_path = part.finish().unwrap(); + assert_eq!( + std::fs::read(final_path).unwrap(), + (0..10).collect::>() + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_truncated_transfer_fails_and_keeps_the_partial_file() { + let dir = scratch_dir("truncated"); + let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); + part.write(b"01234").unwrap(); + + assert!(matches!( + part.finish(), + Err(DownloadError::IncompleteDownload { + received: 5, + expected: 10 + }) + )); + assert_eq!( + std::fs::read(dir.join("file.bin.part")).unwrap(), + b"01234", + "the partial stays on disk for the next attempt" + ); + assert!(!dir.join("file.bin").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn overshoot_past_the_expected_size_is_dropped() { + let dir = scratch_dir("overshoot"); + let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); + part.write(&(0..12).collect::>()).unwrap(); + + assert!(part.is_complete()); + assert_eq!( + std::fs::read(part.finish().unwrap()).unwrap(), + (0..10).collect::>() + ); + let _ = std::fs::remove_dir_all(dir); + } + + // Streams the listener accepted arrive with no deadlines; without these a + // peer that sends a token and goes silent parks the transfer thread — + // socket and open .part file included — for the rest of the session. + #[test] + fn supplied_streams_get_transfer_deadlines() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + + DownloadPeer::apply_transfer_deadlines(&stream).unwrap(); + + let secs = std::time::Duration::from_secs; + assert_eq!(stream.read_timeout().unwrap(), Some(secs(30))); + assert_eq!(stream.write_timeout().unwrap(), Some(secs(5))); + } + + #[test] + fn test_establish_connection_invalid_address() { + let download_peer = DownloadPeer::new( + "user".to_string(), + "invalid-host".to_string(), + 9999, + 123, + false, + "own_user".to_string(), + ); + let result = download_peer.establish_connection(); + assert!(result.is_err()); + } + + #[test] + fn test_extract_filename_from_path() { + assert_eq!(extract_filename_from_path("/path/to/file.mp3"), "file.mp3"); + assert_eq!( + extract_filename_from_path("C:\\path\\to\\file.mp3"), + "file.mp3" + ); + assert_eq!( + extract_filename_from_path( + "@@bhfrv\\Soulseek Downloads\\complete\\Beatport Top Deep House (2021)\\michel test file.mp3" + ), + "michel test file.mp3" + ); + assert_eq!(extract_filename_from_path("file.mp3"), "file.mp3"); + } +} diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs new file mode 100644 index 0000000..2b5f0cf --- /dev/null +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -0,0 +1,720 @@ +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; +use std::time::{Duration, Instant}; + +use crate::client::{Client, ClientContext, ClientOperation}; + +use crate::message::{Message, MessageReader}; +use crate::peer::{ConnectionType, DownloadError, DownloadPeer, Peer}; +use crate::types::Download; +use crate::utils::lock::RwLockExt; +use crate::utils::semaphore::{Permit, Semaphore}; +use crate::{debug, error, info, trace}; + +/// How long to wait before accepting again after a failure. +/// +/// The failure worth pausing for is running out of file descriptors, which a +/// busy search can reach: it does not clear by the next instruction, and +/// retrying flat out spins a core and floods the log. +const ACCEPT_BACKOFF: Duration = Duration::from_millis(100); + +const PEER_INIT_MESSAGE_CODE: u8 = 1; + +/// How long an accepted peer gets to send its peer-init handshake. +const PEER_INIT_TIMEOUT: Duration = Duration::from_secs(10); + +const TOKEN_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(10); + +const MAX_HANDSHAKES: usize = 128; + +const MAX_PEER_INIT_BYTES: usize = 4096; + +#[derive(Clone)] +struct ConnectionContext { + client_sender: Sender, + client_context: Arc>, + own_username: String, +} + +struct PeerInitData { + username: String, + connection_type: ConnectionType, + token: u32, +} + +fn read_peer_init_message( + stream: &mut TcpStream, + reader: &mut MessageReader, + deadline: Instant, +) -> io::Result { + // An untrusted peer gets a bounded handshake. Without this a peer that + // connects and stays silent parks this read forever, pinning a thread that + // owes us a peer init. + let message = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "peer init deadline passed", + )); + } + stream.set_read_timeout(Some(remaining))?; + reader.read_from_socket(stream)?; + if let Ok(Some(msg)) = reader.extract_message() { + break msg; + } + if reader.buffer_len() > MAX_PEER_INIT_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "peer init larger than any handshake", + )); + } + }; + + // Whatever this connection turns into reads on its own terms from here. + stream.set_read_timeout(None)?; + Ok(message) +} + +fn parse_peer_init_message(mut message: Message) -> Option { + message.set_pointer(4); + let message_code = message.read_int8(); + + if message_code != PEER_INIT_MESSAGE_CODE { + return None; + } + + let username = message.read_string(); + // An untrusted peer can send any connection-type string; an unknown value + // must be rejected, not panic the listener accept loop. + let connection_type = message.read_string().parse().ok()?; + Some(PeerInitData { + username, + connection_type, + token: message.read_int32(), + }) +} + +fn wait_for_transfer_token( + stream: &mut TcpStream, + reader: &mut MessageReader, + deadline: Instant, +) -> io::Result<()> { + while reader.buffer_len() < 4 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "transfer token deadline passed", + )); + } + stream.set_read_timeout(Some(remaining))?; + reader.read_from_socket(stream)?; + } + stream.set_read_timeout(None)?; + Ok(()) +} + +fn take_transfer_token(reader: &mut MessageReader) -> Option { + let buffer = reader.get_buffer(); + Some(u32::from_le_bytes(buffer.get(0..4)?.try_into().ok()?)) +} + +fn wait_for_download_registration( + client_context: &Arc>, + token: u32, + username: &str, + deadline: Instant, +) -> Option { + loop { + 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; + } + }; + if download.is_some() { + return download; + } + if Instant::now() >= deadline { + if let Ok(context) = client_context.read_safe() { + trace!( + "[listener:{}] no claimable download for token {}, known tokens: {:?}", + username, + token, + context.get_download_tokens() + ); + } + return None; + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn handle_peer_connection( + peer: Peer, + stream: TcpStream, + reader: MessageReader, + context: &ConnectionContext, +) -> bool { + // The peer actor multiplexes socket reads with its mailbox on a single + // thread: `tick()` reads the socket, but outgoing messages (e.g. a queued + // QueueUpload for a download) are delivered through the mailbox between + // ticks. A blocking socket would park `tick()` inside `read` whenever the + // peer is idle, starving the mailbox and stalling downloads. Match the + // outbound path and drive this connection non-blocking. + if let Err(e) = stream.set_nonblocking(true) { + error!("[listener] failed to set peer stream non-blocking: {}", e); + return false; + } + stream.set_nodelay(true).ok(); + + Client::register_peer_or_fail( + &context.client_context, + peer, + Some(stream), + Some(reader), + ) +} + +fn handle_file_connection( + peer: Peer, + mut stream: TcpStream, + mut reader: MessageReader, + token: u32, + context: &ConnectionContext, + deadline: Instant, +) { + trace!( + "[client] DownloadFromPeer token: {} peer: {:?}", + token, peer + ); + + if let Err(e) = wait_for_transfer_token(&mut stream, &mut reader, deadline) + { + debug!( + "[listener:{}:{}] no transfer token arrived: {e}", + peer.host, peer.port + ); + return; + } + + let download = + take_transfer_token(&mut reader).and_then(|transfer_token| { + wait_for_download_registration( + &context.client_context, + transfer_token, + &peer.username, + Instant::now() + TOKEN_REGISTRATION_TIMEOUT, + ) + }); + let download_peer = DownloadPeer::new( + peer.username.clone(), + peer.host.clone(), + peer.port, + token, + true, + context.own_username.clone(), + ); + + match download_peer.download_file( + context.client_context.clone(), + download, + Some(stream), + ) { + Ok((download, filename)) => { + info!( + "Successfully downloaded {} bytes to {}", + download.size, filename + ); + } + Err(DownloadError::Cancelled) => {} + Err(e) => { + error!( + "Failed to download file from {}:{} (token: {}) - Error: {}", + peer.host, peer.port, token, e + ); + } + } +} + +/// Handle a `PierceFirewall` (peer init code 0): a peer we asked the server to +/// broker (because a direct connection failed) connecting back to us. It quotes +/// the correlation token we registered; we register the stream as its P control +/// connection and tell the client the connection is live. +fn handle_pierce_firewall( + mut message: Message, + stream: TcpStream, + reader: MessageReader, + context: &ConnectionContext, + peer_ip: &str, + peer_port: u16, +) { + message.set_pointer(5); // skip length prefix (4) + int8 code (1) + let token = message.read_int32(); + + let username = match context.client_context.write_safe() { + Ok(mut ctx) => { + if let Some(upload) = ctx.take_brokered_upload(token) { + drop(ctx); + crate::client::Client::serve_pierced( + &context.client_context, + upload, + stream, + ); + return; + } + ctx.take_pending_connect(token) + } + Err(e) => { + error!("[listener] pierce firewall lock: {}", e); + return; + } + }; + let Some(username) = username else { + debug!( + "[listener:{peer_ip}:{peer_port}] PierceFirewall token {token} is not pending; ignoring" + ); + return; + }; + + let peer = Peer::new( + username.clone(), + ConnectionType::P, + peer_ip.to_string(), + peer_port.into(), + None, + 0, + 0, + 0, + ); + if handle_peer_connection(peer, stream, reader, context) { + // Inbound peers don't self-announce, so nudge the client to flush any + // downloads queued for this now-connected peer. + let _ = context + .client_sender + .send(ClientOperation::PeerConnected(username)); + } +} + +fn handle_incoming_connection( + mut stream: TcpStream, + context: ConnectionContext, + permit: Permit, +) { + let Ok(peer_addr) = stream.peer_addr() else { + error!("[listener] failed to get peer address"); + return; + }; + + let peer_ip = peer_addr.ip().to_string(); + let peer_port = peer_addr.port(); + let mut reader = MessageReader::new(); + + // A peer that dials and then goes away is routine, not an error. + let deadline = Instant::now() + PEER_INIT_TIMEOUT; + let message = + match read_peer_init_message(&mut stream, &mut reader, deadline) { + Ok(message) => message, + Err(e) => { + debug!( + "[listener:{peer_ip}:{peer_port}] no peer init message: {e}" + ); + return; + } + }; + drop(permit); + + // A firewalled peer brokered through the server connects back with a + // PierceFirewall (code 0) instead of a PeerInit (code 1). + if message.get_init_code() == 0 { + handle_pierce_firewall( + message, stream, reader, &context, &peer_ip, peer_port, + ); + return; + } + + let Some(init_data) = parse_peer_init_message(message) else { + error!( + "[listener:{peer_ip}:{peer_port}] Invalid or unknown peer init message" + ); + return; + }; + + debug!( + "[listener:{peer_ip}:{peer_port}] peerInit (0) username: {} connection_type: {} token: {}", + init_data.username, init_data.connection_type, init_data.token + ); + + // Register under the plain username, the same key outbound connections + // use. A suffixed key ("user:direct") put the same peer in the registry + // twice — two sockets, two threads — and made every "already connected?" + // check miss, so an inbound searcher still got a second, outbound + // connection dialled at them. + let peer = Peer::new( + init_data.username.clone(), + init_data.connection_type.clone(), + peer_ip.clone(), + peer_port.into(), + None, + 0, + 0, + 0, + ); + + match init_data.connection_type { + ConnectionType::P => { + if handle_peer_connection(peer, stream, reader, &context) { + // Inbound peers don't self-announce, so nudge the client to + // flush anything queued for this now-connected peer — same as + // the PierceFirewall path. + let _ = context + .client_sender + .send(ClientOperation::PeerConnected(init_data.username)); + } + } + + ConnectionType::F => handle_file_connection( + peer, + stream, + reader, + init_data.token, + &context, + 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}] distributed child {} offered", + init_data.username + ); + let _ = + context.client_sender.send(ClientOperation::ChildConnected { + username: init_data.username, + stream, + }); + } + } +} + +pub struct Listen {} + +impl Listen { + /// Take the peer-listening socket, falling back to a port the operating + /// system picks when the configured one is already held. + /// + /// Several clients on one machine share one configured port, and losing + /// the race for it must not cost a session its listener: an unreachable + /// client is one that never receives search responses or transfers. The + /// caller advertises [`TcpListener::local_addr`], so peers are always told + /// the port that was really bound. + pub fn bind(port: u16) -> io::Result { + match TcpListener::bind(("0.0.0.0", port)) { + Err(e) if e.kind() == io::ErrorKind::AddrInUse && port != 0 => { + info!( + "[listener] port {port} is taken, falling back to an \ + ephemeral port" + ); + TcpListener::bind(("0.0.0.0", 0)) + } + other => other, + } + } + + pub fn serve( + listener: &TcpListener, + client_sender: Sender, + client_context: Arc>, + own_username: String, + stopped: Arc, + ) { + info!("[listener] listening on {:?}", listener.local_addr()); + + let context = ConnectionContext { + client_sender, + client_context, + own_username, + }; + 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: {}", + stream.unwrap_err() + ); + // Running out of file descriptors does not clear by the next + // instruction, and retrying flat out turns one exhausted + // moment into a spinning core and thousands of identical log + // lines. Pause long enough for something to be released. + std::thread::sleep(ACCEPT_BACKOFF); + continue; + }; + + let permit = handshakes.acquire(); + let context = context.clone(); + // One thread per connection: the peer-init handshake blocks, and a + // peer that is slow to send one must not stop us accepting anybody + // else — a wedged accept loop makes us unreachable to every peer. + thread::spawn(move || { + handle_incoming_connection(stream, context, permit); + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{DownloadMetadata, DownloadStatus}; + use std::io::Write; + use std::net::TcpListener; + use std::sync::mpsc; + + fn registered_download(token: u32) -> Download { + Download { + username: "peer".to_string(), + filename: "song.mp3".to_string(), + token, + size: 100, + download_directory: std::env::temp_dir().display().to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + } + } + + #[test] + fn a_cancelled_download_is_not_claimable() { + let client_context = Arc::new(RwLock::new(ClientContext::new())); + let mut download = registered_download(7); + download.status = DownloadStatus::Cancelled; + client_context.write().unwrap().add_download(download); + + let claimed = wait_for_download_registration( + &client_context, + 7, + "peer", + Instant::now() + Duration::from_millis(200), + ); + + assert!(claimed.is_none(), "a cancelled transfer must not restart"); + } + + #[test] + fn a_download_registered_during_the_wait_is_found() { + let client_context = Arc::new(RwLock::new(ClientContext::new())); + let registering = { + let client_context = client_context.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + client_context + .write() + .unwrap() + .add_download(registered_download(7)); + }) + }; + + let download = wait_for_download_registration( + &client_context, + 7, + "peer", + Instant::now() + Duration::from_secs(5), + ); + + assert!( + download.is_some(), + "a token registered during the wait must be found" + ); + let _ = registering.join(); + } + + #[test] + fn an_unregistered_token_gives_up_at_the_deadline() { + let client_context = Arc::new(RwLock::new(ClientContext::new())); + + let started = Instant::now(); + let download = wait_for_download_registration( + &client_context, + 7, + "peer", + started + Duration::from_millis(200), + ); + + assert!(download.is_none(), "an unknown token must not be invented"); + assert!( + started.elapsed() < Duration::from_secs(2), + "the deadline must bound the wait" + ); + } + + #[test] + fn a_download_for_another_peer_is_not_claimed() { + let client_context = Arc::new(RwLock::new(ClientContext::new())); + client_context + .write() + .unwrap() + .add_download(registered_download(7)); + + let download = wait_for_download_registration( + &client_context, + 7, + "impostor", + Instant::now() + Duration::from_millis(200), + ); + + assert!( + download.is_none(), + "a token must only match the peer it was registered for" + ); + } + + #[test] + fn an_already_claimed_download_is_not_reclaimed() { + let client_context = Arc::new(RwLock::new(ClientContext::new())); + let mut claimed = registered_download(7); + claimed.status = DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }; + client_context.write().unwrap().add_download(claimed); + + let download = wait_for_download_registration( + &client_context, + 7, + "peer", + Instant::now() + Duration::from_millis(200), + ); + + assert!( + download.is_none(), + "a transfer already in progress must not be claimed again" + ); + } + + #[test] + fn a_dribbling_handshake_hits_the_total_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let writer = std::thread::spawn(move || { + let mut socket = TcpStream::connect(addr).unwrap(); + let _ = socket.write_all(&1000u32.to_le_bytes()); + for _ in 0..20 { + if socket.write_all(&[0u8]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + }); + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = MessageReader::new(); + + let started = Instant::now(); + let result = read_peer_init_message( + &mut stream, + &mut reader, + started + Duration::from_millis(300), + ); + + assert!(result.is_err(), "a dribbled handshake must not succeed"); + assert!( + started.elapsed() < Duration::from_secs(5), + "the deadline must bound the whole handshake, not each read" + ); + let _ = writer.join(); + } + + #[test] + fn the_transfer_token_is_waited_for_across_segments() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let writer = std::thread::spawn(move || { + let mut socket = TcpStream::connect(addr).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + socket.write_all(&42u32.to_le_bytes()).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + }); + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = MessageReader::new(); + + let result = wait_for_transfer_token( + &mut stream, + &mut reader, + Instant::now() + Duration::from_secs(5), + ); + + assert!( + result.is_ok(), + "token in a later segment must be waited for" + ); + assert!(reader.buffer_len() >= 4, "token bytes must be buffered"); + let _ = writer.join(); + } + + #[test] + fn waiting_for_a_transfer_token_stops_at_the_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let writer = std::thread::spawn(move || { + let socket = TcpStream::connect(addr).unwrap(); + std::thread::sleep(Duration::from_millis(500)); + drop(socket); + }); + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = MessageReader::new(); + + let started = Instant::now(); + let result = wait_for_transfer_token( + &mut stream, + &mut reader, + started + Duration::from_millis(200), + ); + + assert!(result.is_err(), "a silent peer must not park the thread"); + assert!( + started.elapsed() < Duration::from_secs(2), + "the deadline must bound the wait" + ); + let _ = writer.join(); + } + + #[test] + fn an_oversized_handshake_is_rejected() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let writer = std::thread::spawn(move || { + let mut socket = TcpStream::connect(addr).unwrap(); + let junk = vec![0xFFu8; MAX_PEER_INIT_BYTES * 2]; + let _ = socket.write_all(&junk); + std::thread::sleep(Duration::from_millis(500)); + }); + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = MessageReader::new(); + + let result = read_peer_init_message( + &mut stream, + &mut reader, + Instant::now() + Duration::from_secs(5), + ); + + assert!( + result.is_err(), + "an oversized handshake must be rejected, got {result:?}" + ); + let _ = writer.join(); + } +} diff --git a/soulseek-rs-lib/src/peer/mod.rs b/soulseek-rs-lib/src/peer/mod.rs new file mode 100644 index 0000000..15494ba --- /dev/null +++ b/soulseek-rs-lib/src/peer/mod.rs @@ -0,0 +1,211 @@ +mod download_peer; +pub mod listen; +pub mod upload_peer; + +// Export actor types +pub use crate::actor::peer_actor::PeerMessage; + +pub use download_peer::part_path_of; +pub use download_peer::{DownloadError, DownloadPeer}; + +use crate::message::Message; +use core::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone)] +pub enum ConnectionType { + P, + F, + D, +} + +#[derive(Debug, Clone)] +pub struct ParseConnectionTypeError; + +impl fmt::Display for ParseConnectionTypeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "invalid connection type") + } +} + +impl std::error::Error for ParseConnectionTypeError {} + +impl FromStr for ConnectionType { + type Err = ParseConnectionTypeError; + + fn from_str(s: &str) -> Result { + match s { + "P" => Ok(Self::P), + "F" => Ok(Self::F), + "D" => Ok(Self::D), + _ => Err(ParseConnectionTypeError), + } + } +} + +impl fmt::Display for ConnectionType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + Self::P => "P", + Self::F => "F", + Self::D => "D", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Clone)] +pub struct Peer { + pub username: String, + pub connection_type: ConnectionType, + pub host: String, + pub port: u32, + pub token: Option, + pub privileged: Option, + pub unknown: Option, + pub obfuscated_port: Option, + /// True when the server asked us to make this connection (`ConnectToPeer`, + /// code 18). Then `token` is the far peer's correlation token, and a dial + /// that fails owes them a `CantConnectToPeer` quoting it. + pub brokered: bool, +} +impl Peer { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub const fn new( + username: String, + connection_type: ConnectionType, + host: String, + port: u32, + token: Option, + privileged: u8, + unknown: u8, + obfuscated_port: u16, + ) -> Self { + Self { + username, + connection_type, + host, + port, + token, + privileged: Some(privileged), + unknown: Some(unknown), + obfuscated_port: Some(obfuscated_port), + brokered: false, + } + } + pub fn new_from_message(message: &mut Message) -> Option { + let username = message.read_string(); + // The connection type is an untrusted string; an unknown value must not + // panic the actor that parses it. + let connection_type = message.read_string().parse().ok()?; + + let ip = [ + message.read_int8(), + message.read_int8(), + message.read_int8(), + message.read_int8(), + ]; + let host = format!("{}.{}.{}.{}", ip[3], ip[2], ip[1], ip[0]); + + let (port, token, privileged, unknown, obfuscated_port) = ( + message.read_int32(), + message.read_int32(), + message.read_int8(), + message.read_int8(), + message.read_int8(), + ); + + Some(Self { + username, + connection_type, + host, + port, + token: Some(token), + privileged: Some(privileged), + unknown: Some(unknown), + obfuscated_port: Some(u16::from(obfuscated_port)), + brokered: true, + }) + } +} +#[test] +fn new_accepts_a_full_range_obfuscated_port() { + // A real obfuscated port is a u16; it must be stored without truncation. + // (The GetPeerAddressResponse path previously narrowed it to u8 and + // panicked for any value > 255, crashing the client's ops thread.) + let peer = Peer::new( + "u".to_string(), + ConnectionType::P, + "1.2.3.4".to_string(), + 1, + None, + 0, + 0, + 51770, + ); + assert_eq!(peer.obfuscated_port, Some(51770)); +} + +#[test] +fn new_from_message_returns_none_on_invalid_connection_type() { + // username "ab", connection_type "X" (not P/F/D) from an untrusted server. + let mut data: Vec = vec![0, 0, 0, 0, 0, 0, 0, 0]; + data.extend([2, 0, 0, 0, 97, 98]); // username = "ab" + data.extend([1, 0, 0, 0, 88]); // connection_type = "X" + data.extend([1, 2, 3, 4]); // ip + data.extend([0, 0, 0, 0]); // port + data.extend([0, 0, 0, 0]); // token + data.extend([0, 0, 0]); // privileged, unknown, obfuscated_port + let mut message = Message::new_with_data(data); + message.set_pointer(8); + + assert!(Peer::new_from_message(&mut message).is_none()); +} + +#[test] +fn test_new_from_message() { + let data: Vec = [ + 36, 0, 0, 0, 18, 0, 0, 0, 2, 0, 0, 0, 100, 112, 1, 0, 0, 0, 80, 27, + 231, 37, 45, 186, 8, 0, 0, 178, 78, 25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ] + .to_vec(); + let mut message = Message::new_with_data(data); + message.set_pointer(8); + + let peer = Peer::new_from_message(&mut message).unwrap(); + + assert_eq!(peer.username, "dp"); + assert!(matches!(peer.connection_type, ConnectionType::P)); + assert_eq!(peer.host, "45.37.231.27"); + assert_eq!(peer.port, 2234); + assert_eq!(peer.token, Some(1658546)); + assert_eq!(peer.privileged, Some(0)); + assert_eq!(peer.unknown, Some(0)); + assert_eq!(peer.obfuscated_port, Some(0)); +} + +#[test] +fn test_new_from_message2() { + let data: Vec = [ + 42, 0, 0, 0, 18, 0, 0, 0, 8, 0, 0, 0, 103, 114, 97, 110, 100, 112, 97, + 103, 1, 0, 0, 0, 80, 137, 128, 193, 68, 187, 8, 0, 0, 58, 16, 0, 0, 0, + 1, 0, 0, 0, 188, 8, 0, 0, + ] + .to_vec(); + let mut message = Message::new_with_data(data); + message.set_pointer(8); + + println!("code: {}", message.get_message_code()); + + let peer = Peer::new_from_message(&mut message).unwrap(); + + assert_eq!(peer.username, "grandpag"); + assert!(matches!(peer.connection_type, ConnectionType::P)); + assert_eq!(peer.host, "68.193.128.137"); + assert_eq!(peer.port, 2235); + assert_eq!(peer.token, Some(4154)); + assert_eq!(peer.privileged, Some(0)); + assert_eq!(peer.unknown, Some(1)); + assert_eq!(peer.obfuscated_port, Some(0)); +} diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs new file mode 100644 index 0000000..640e7a0 --- /dev/null +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -0,0 +1,379 @@ +//! Serving a shared file to a peer over an F (file transfer) connection. + +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; + +use crate::message::server::MessageFactory; +use crate::peer::ConnectionType; +use crate::trace; + +/// Connect to the downloader's file listener and stream `path`'s bytes. +/// +/// We announce ourselves with a `PeerInit(F)` immediately followed by the raw +/// transfer token (so it lands in the downloader's read buffer, where the +/// download is matched by token), then the downloader sends an 8-byte +/// START_DOWNLOAD offset before we stream the file. +/// +/// `bytes_sent` is updated as the transfer progresses, and setting `cancel` +/// aborts the stream with an [`io::ErrorKind::Interrupted`] error. Returns the +/// bytes streamed by this call, which a resumed transfer's prefix is not. +/// +/// # Errors +/// Returns any I/O error opening the file or talking to the peer. +#[cfg(test)] +pub fn serve_file( + host: &str, + port: u32, + own_username: &str, + token: u32, + path: &Path, + bytes_sent: &AtomicU64, + cancel: &AtomicBool, +) -> io::Result { + let stream = dial(host, port)?; + serve_direct(stream, own_username, token, path, bytes_sent, cancel) +} + +/// Connect to `host:port` with the transfer deadlines applied. +/// +/// # Errors +/// Returns the connect error, or `InvalidInput` for an unresolvable address. +pub fn dial(host: &str, port: u32) -> io::Result { + let socket = format!("{host}:{port}") + .to_socket_addrs()? + .next() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no address") + })?; + with_deadlines(TcpStream::connect_timeout( + &socket, + Duration::from_secs(20), + )?) +} + +fn with_deadlines(stream: TcpStream) -> io::Result { + // A downloader that connects and then goes silent must cost seconds, not + // the rest of the session: without deadlines a wedged `read_exact` (or a + // zero-window `write_all`) holds this thread, the socket, the open file, + // and — because the upload stays InProgress — an upload slot, forever. + // Thirty seconds of total silence is a dead transfer, not a slow one: even + // a slow reader drains some buffer within that. The write deadline is + // deliberately looser than the download side's 5s: bulk data flows this + // way, and a congested but living peer may hold the send buffer full far + // longer than a peer draining our tiny control frames ever would. + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + stream.set_write_timeout(Some(Duration::from_secs(30)))?; + stream.set_nodelay(true).ok(); + Ok(stream) +} + +/// Stream `path` over a connection we dialled: announce with `PeerInit(F)` +/// and the token, then hand over to [`stream_file`]. +/// +/// # Errors +/// Returns any I/O error opening the file or talking to the peer. +pub fn serve_direct( + mut stream: TcpStream, + own_username: &str, + token: u32, + path: &Path, + bytes_sent: &AtomicU64, + cancel: &AtomicBool, +) -> io::Result { + let file = File::open(path)?; + // PeerInit(F) + the 4-byte token in a single write so they coalesce. + let mut init = MessageFactory::build_peer_init_message( + own_username, + ConnectionType::F, + token, + ) + .get_buffer(); + init.extend_from_slice(&token.to_le_bytes()); + stream.write_all(&init)?; + stream.flush()?; + stream_file(stream, file, path, bytes_sent, cancel) +} + +/// Stream `path` over a connection the downloader opened to us after the +/// server brokered it: it has already sent `PierceFirewall`, so only the +/// 4-byte transfer token precedes the offset exchange. +/// +/// # Errors +/// Returns any I/O error opening the file or talking to the peer. +pub fn serve_pierced( + stream: TcpStream, + token: u32, + path: &Path, + bytes_sent: &AtomicU64, + cancel: &AtomicBool, +) -> io::Result { + let file = File::open(path)?; + let mut stream = with_deadlines(stream)?; + stream.write_all(&token.to_le_bytes())?; + stream.flush()?; + stream_file(stream, file, path, bytes_sent, cancel) +} + +fn stream_file( + mut stream: TcpStream, + mut file: File, + path: &Path, + bytes_sent: &AtomicU64, + cancel: &AtomicBool, +) -> io::Result { + // The downloader replies with an 8-byte START_DOWNLOAD offset first. A + // non-zero offset means it is resuming and already holds that prefix, so + // seek past it and count it as sent to keep progress absolute. + let mut offset = [0u8; 8]; + stream.read_exact(&mut offset)?; + let offset = u64::from_le_bytes(offset); + 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::ErrorKind::Interrupted.into()); + } + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + stream.write_all(&buffer[..read])?; + bytes_sent.fetch_add(read as u64, Ordering::Relaxed); + streamed += read as u64; + } + stream.flush()?; + + // 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(), + stream.peer_addr() + ); + Ok(streamed) +} + +#[cfg(test)] +mod tests { + use super::serve_file; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + 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. + /// Returns the connected stream and the token the uploader announced. + fn accept_and_send_offset( + listener: &TcpListener, + offset: u64, + ) -> (TcpStream, u32) { + let (mut stream, _) = listener.accept().unwrap(); + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).unwrap(); + let mut payload = vec![0u8; u32::from_le_bytes(len_buf) as usize]; + stream.read_exact(&mut payload).unwrap(); + let mut token = [0u8; 4]; + stream.read_exact(&mut token).unwrap(); + + stream.write_all(&offset.to_le_bytes()).unwrap(); + stream.flush().unwrap(); + (stream, u32::from_le_bytes(token)) + } + + fn scratch_file(name: &str, content: &[u8]) -> std::path::PathBuf { + let dir = std::env::temp_dir() + .join(format!("soulseek-upload-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("probe.bin"); + std::fs::write(&path, content).unwrap(); + path + } + + fn spawn_uploader( + listener: &TcpListener, + path: std::path::PathBuf, + token: u32, + cancel: Arc, + ) -> ( + Arc, + std::thread::JoinHandle>, + ) { + let port = u32::from(listener.local_addr().unwrap().port()); + let bytes_sent = Arc::new(AtomicU64::new(0)); + let sent_counter = bytes_sent.clone(); + let uploader = std::thread::spawn(move || { + serve_file( + "127.0.0.1", + port, + "me", + token, + &path, + &sent_counter, + &cancel, + ) + }); + (bytes_sent, uploader) + } + + #[test] + fn serve_file_streams_the_file_over_an_f_connection() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("stream", &content); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + 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![0; content.len()]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content); + + 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_accepts_valid_resume_offsets() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + for offset in [1000, content.len()] { + let path = + scratch_file(&format!("valid-offset-{offset}"), &content); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 779, Arc::default()); + + let (mut stream, _) = + accept_and_send_offset(&listener, offset as u64); + let mut received = vec![0; content.len() - offset]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content[offset..]); + drop(stream); + + assert_eq!( + uploader.join().unwrap().unwrap(), + (content.len() - offset) as u64 + ); + assert_eq!( + bytes_sent.load(Ordering::Relaxed), + content.len() as u64 + ); + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn serve_file_rejects_an_offset_past_end_of_file() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("invalid-offset", &content); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let 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 = (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 || { + let result = serve_file( + "127.0.0.1", + port, + "me", + 783, + &path, + &AtomicU64::new(0), + &AtomicBool::new(false), + ); + let _ = result_tx.send(result); + }); + + 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); + + // 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(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn serve_file_stops_when_cancelled() { + let path = scratch_file("cancel", &vec![7u8; 1024 * 1024]); + let dir = path.parent().unwrap().to_path_buf(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + // 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 (_, uploader) = spawn_uploader(&listener, path, 778, cancel); + + let (mut stream, _) = accept_and_send_offset(&listener, 0); + + let mut received = Vec::new(); + let _ = stream.read_to_end(&mut received); + assert!(received.is_empty(), "no file bytes after cancellation"); + + let err = uploader.join().unwrap().unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::Interrupted); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/soulseek-rs-lib/src/shares/audio.rs b/soulseek-rs-lib/src/shares/audio.rs new file mode 100644 index 0000000..6de9282 --- /dev/null +++ b/soulseek-rs-lib/src/shares/audio.rs @@ -0,0 +1,335 @@ +//! The audio attributes a share advertises for a file, read from the file's +//! own headers: Soulseek codes 0 bitrate (kbps), 1 duration (seconds), 2 VBR, +//! 4 sample rate (Hz) and 5 bit depth. Searchers filter and sort on these, so +//! a file without them ranks last in every other client. + +use std::path::Path; + +use std::io::{Read, Seek, SeekFrom}; + +const BITRATE: u32 = 0; +const DURATION: u32 = 1; +const VBR: u32 = 2; +const SAMPLE_RATE: u32 = 4; +const BIT_DEPTH: u32 = 5; + +/// How much of a file's head one read takes. A frame header with its Xing +/// block needs under a hundred bytes; the rest is margin so a typical ID3v2 +/// tag of a few kilobytes fits in the first read too. +const HEAD: usize = 8 * 1024; + +/// The attributes for `path`, or none when the format is not one we read or +/// the file does not parse. Only the file's head is read. +#[must_use] +pub fn probe(path: &Path) -> Vec<(u32, u32)> { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("mp3") => mp3(path), + Some("flac") => flac(path), + _ => None, + } + .unwrap_or_default() +} + +fn flac(path: &Path) -> Option> { + let mut head = [0u8; 42]; + std::fs::File::open(path).ok()?.read_exact(&mut head).ok()?; + if &head[0..4] != b"fLaC" || head[4] & 0x7F != 0 { + return None; + } + let packed = u64::from_be_bytes(head[18..26].try_into().ok()?); + let sample_rate = (packed >> 44) as u32; + let bits = ((packed >> 36) & 0x1F) as u32 + 1; + let samples = packed & 0xF_FFFF_FFFF; + if sample_rate == 0 { + return None; + } + let mut attributes = Vec::new(); + if samples > 0 { + let seconds = samples / u64::from(sample_rate); + attributes.push((DURATION, u32::try_from(seconds).unwrap_or(u32::MAX))); + } + attributes.push((SAMPLE_RATE, sample_rate)); + attributes.push((BIT_DEPTH, bits)); + Some(attributes) +} + +/// Layer III bitrates in kbps by the header's four-bit index, for MPEG-1 and +/// for MPEG-2 and 2.5, which halve the table. +const MPEG1_BITRATES: [u32; 15] = [ + 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, +]; +const MPEG2_BITRATES: [u32; 15] = + [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]; +/// Sample rates by the header's two-bit index, one row per version: MPEG-2.5, +/// MPEG-2, MPEG-1. +const SAMPLE_RATES: [[u32; 3]; 3] = [ + [11_025, 12_000, 8_000], + [22_050, 24_000, 16_000], + [44_100, 48_000, 32_000], +]; + +/// One Layer III frame header, decoded. +struct Frame { + bitrate_kbps: u32, + sample_rate: u32, + samples: u32, + /// Offset of the Xing/Info block relative to the header. + xing_at: usize, +} + +fn frame(h: &[u8]) -> Option { + if h.len() < 4 || h[0] != 0xFF || h[1] & 0xE0 != 0xE0 { + return None; + } + let version = (h[1] >> 3) & 3; // 0 = 2.5, 2 = 2, 3 = 1 + let layer = (h[1] >> 1) & 3; // 1 = Layer III + let bitrate_index = usize::from(h[2] >> 4); + let rate_index = usize::from((h[2] >> 2) & 3); + if version == 1 + || layer != 1 + || bitrate_index == 0 + || bitrate_index == 15 + || rate_index == 3 + { + return None; + } + let mpeg1 = version == 3; + let mono = (h[3] >> 6) & 3 == 3; + // Protection bit clear means a two-byte CRC precedes the side info. + let crc = usize::from(h[1] & 1 == 0) * 2; + let rates = SAMPLE_RATES[if version == 0 { + 0 + } else if mpeg1 { + 2 + } else { + 1 + }]; + Some(Frame { + bitrate_kbps: if mpeg1 { + MPEG1_BITRATES + } else { + MPEG2_BITRATES + }[bitrate_index], + sample_rate: rates[rate_index], + samples: if mpeg1 { 1152 } else { 576 }, + // Side info is 32 bytes for MPEG-1 stereo, 17 for MPEG-1 mono and + // MPEG-2 stereo, 9 for MPEG-2 mono. + xing_at: 4 + + crc + + match (mpeg1, mono) { + (true, false) => 32, + (true, true) | (false, false) => 17, + (false, true) => 9, + }, + }) +} + +fn mp3(path: &Path) -> Option> { + let mut file = std::fs::File::open(path).ok()?; + let total = file.metadata().ok()?.len(); + let mut head = [0u8; HEAD]; + let mut len = file.read(&mut head).ok()?; + // An ID3v2 tag in front: its size is four syncsafe bytes, plus a footer. + // `base` is where `head` sits in the file once a big tag forces a second + // read, so the audio length below counts from the first frame. + let mut base = 0usize; + let mut audio_at = 0usize; + if len >= 10 && &head[0..3] == b"ID3" { + let size = head[6..10] + .iter() + .fold(0usize, |acc, &b| (acc << 7) | usize::from(b & 0x7F)); + audio_at = 10 + size + if head[5] & 0x10 != 0 { 10 } else { 0 }; + if audio_at + 4 > len { + file.seek(SeekFrom::Start(audio_at as u64)).ok()?; + len = file.read(&mut head).ok()?; + base = audio_at; + audio_at = 0; + } + } + let head = &head[..len]; + let start = (audio_at..len.saturating_sub(4)) + .find(|&i| frame(&head[i..]).is_some())?; + let frame = frame(&head[start..])?; + let audio_bytes = total.saturating_sub((base + start) as u64); + let at_frame_rate = |bytes: u64| { + (bytes * 8 / (u64::from(frame.bitrate_kbps) * 1000)) as u32 + }; + + let xing = start + frame.xing_at; + let tag = head.get(xing..xing + 4); + let vbr = u32::from(tag == Some(b"Xing")); + if tag != Some(b"Xing") && tag != Some(b"Info") { + return Some(vec![ + (BITRATE, frame.bitrate_kbps), + (DURATION, at_frame_rate(audio_bytes)), + (VBR, 0), + ]); + } + let flags = + u32::from_be_bytes(head.get(xing + 4..xing + 8)?.try_into().ok()?); + let mut at = xing + 8; + let mut frames = None; + if flags & 1 != 0 { + frames = + Some(u32::from_be_bytes(head.get(at..at + 4)?.try_into().ok()?)); + at += 4; + } + let bytes = if flags & 2 != 0 { + u64::from(u32::from_be_bytes(head.get(at..at + 4)?.try_into().ok()?)) + } else { + audio_bytes + }; + // Without a frame count the block adds nothing the first frame did not say. + let Some(frames) = frames else { + return Some(vec![ + (BITRATE, frame.bitrate_kbps), + (DURATION, at_frame_rate(bytes)), + (VBR, vbr), + ]); + }; + let seconds = f64::from(frames) * f64::from(frame.samples) + / f64::from(frame.sample_rate); + let kbps = if seconds > 0.0 { + (bytes as f64 * 8.0 / seconds / 1000.0) as u32 + } else { + frame.bitrate_kbps + }; + Some(vec![ + (BITRATE, kbps), + (DURATION, seconds as u32), + (VBR, vbr), + ]) +} + +#[cfg(test)] +pub(in crate::shares) mod tests { + use super::*; + + /// One MPEG-1 Layer III frame header: 128 kbps, 44.1 kHz, stereo. + const CBR_128: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00]; + const CBR_128_FRAME_LEN: usize = 417; + + pub(in crate::shares) fn cbr_mp3(frames: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..frames { + out.extend_from_slice(&CBR_128); + out.resize(out.len() + CBR_128_FRAME_LEN - 4, 0); + } + out + } + + fn write(name: &str, bytes: &[u8]) -> std::path::PathBuf { + let dir = std::env::temp_dir() + .join(format!("soulseek-audio-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path + } + + #[test] + fn a_constant_bitrate_mp3_reports_bitrate_duration_and_no_vbr() { + // 100 frames of 1152 samples at 44.1 kHz is 2.61 s. + let path = write("cbr.mp3", &cbr_mp3(100)); + assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 0)]); + } + + /// An ID3v2 tag of `size` bytes (syncsafe in the header) with no footer. + fn id3v2(size: usize) -> Vec { + let mut tag = b"ID3\x04\x00\x00".to_vec(); + tag.push(((size >> 21) & 0x7F) as u8); + tag.push(((size >> 14) & 0x7F) as u8); + tag.push(((size >> 7) & 0x7F) as u8); + tag.push((size & 0x7F) as u8); + tag.resize(10 + size, 0); + tag + } + + #[test] + fn an_id3v2_tag_in_front_of_the_frames_is_skipped() { + // 50 frames is 1.31 s; counting the 8000-byte tag as audio, or the + // tag twice, would put the duration at 0. + let mut bytes = id3v2(8000); + bytes.extend_from_slice(&cbr_mp3(50)); + let path = write("tagged.mp3", &bytes); + assert_eq!(probe(&path), [(0, 128), (1, 1), (2, 0)]); + } + + #[test] + fn a_tag_larger_than_the_first_read_is_skipped_too() { + // 20 KiB of tag pushes the frames past the first read; 100 frames is + // 2.61 s, and counting the tag as audio would say 3. + let mut bytes = id3v2(20_480); + bytes.extend_from_slice(&cbr_mp3(100)); + let path = write("big-tag.mp3", &bytes); + assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 0)]); + } + + #[test] + fn a_crc_protected_frame_still_finds_its_xing_header() { + // Protection bit clear: a two-byte CRC sits before the side info. + let mut bytes = cbr_mp3(1); + bytes[1] = 0xFA; + let xing_at = 4 + 2 + 32; + bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing"); + bytes[xing_at + 4..xing_at + 8].copy_from_slice(&3u32.to_be_bytes()); + bytes[xing_at + 8..xing_at + 12].copy_from_slice(&200u32.to_be_bytes()); + bytes[xing_at + 12..xing_at + 16] + .copy_from_slice(&104_250u32.to_be_bytes()); + let path = write("crc.mp3", &bytes); + assert_eq!(probe(&path), [(0, 159), (1, 5), (2, 1)]); + } + + #[test] + fn a_xing_header_without_a_frame_count_falls_back_to_the_frame_header() { + let mut bytes = cbr_mp3(100); + let xing_at = 4 + 32; + bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing"); + bytes[xing_at + 4..xing_at + 8].copy_from_slice(&2u32.to_be_bytes()); + bytes[xing_at + 8..xing_at + 12] + .copy_from_slice(&41_700u32.to_be_bytes()); + let path = write("no-frames.mp3", &bytes); + assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 1)]); + } + + #[test] + fn a_variable_bitrate_mp3_uses_its_xing_header() { + // A Xing header in the first frame's data: 200 frames, 104_250 bytes + // in the stream, so 5.22 s at an average of 160 kbps. + let mut bytes = cbr_mp3(1); + let xing_at = 4 + 32; // MPEG-1 stereo side info + bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing"); + bytes[xing_at + 4..xing_at + 8].copy_from_slice(&3u32.to_be_bytes()); + bytes[xing_at + 8..xing_at + 12].copy_from_slice(&200u32.to_be_bytes()); + bytes[xing_at + 12..xing_at + 16] + .copy_from_slice(&104_250u32.to_be_bytes()); + let path = write("vbr.mp3", &bytes); + assert_eq!(probe(&path), [(0, 159), (1, 5), (2, 1)]); + } + + #[test] + fn a_flac_reports_duration_sample_rate_and_bit_depth() { + // STREAMINFO: 44.1 kHz, 2 channels, 16 bits, 441_000 samples (10 s). + let mut bytes = b"fLaC".to_vec(); + bytes.extend_from_slice(&[0x80, 0x00, 0x00, 0x22]); + bytes.extend_from_slice(&[0x10, 0x00, 0x10, 0x00]); // block sizes + bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // frame sizes + let packed: u64 = + (44_100u64 << 44) | (1u64 << 41) | (15u64 << 36) | 0x0006_BAA8; + bytes.extend_from_slice(&packed.to_be_bytes()); + bytes.extend_from_slice(&[0u8; 16]); // md5 + let path = write("lossless.flac", &bytes); + assert_eq!(probe(&path), [(1, 10), (4, 44_100), (5, 16)]); + } + + #[test] + fn anything_else_or_a_truncated_file_has_no_attributes() { + assert!(probe(&write("notes.txt", b"hello")).is_empty()); + assert!(probe(&write("short.mp3", b"ID3")).is_empty()); + assert!(probe(&write("short.flac", b"fLaC\x80\x00")).is_empty()); + assert!(probe(Path::new("/nonexistent/x.mp3")).is_empty()); + } +} diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs new file mode 100644 index 0000000..807fbe3 --- /dev/null +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -0,0 +1,418 @@ +//! An in-memory index of the files we share with other peers. +//! +//! Scanning a directory produces a read-only [`Shares`] snapshot keyed by the +//! peer-facing *virtual path* (the shared directory's own name followed by the +//! backslash-separated relative path, matching the Soulseek wire convention). + +mod audio; + +use crate::message::peer::{SharedDirectory, SharedFileEntry}; +use std::collections::{HashMap, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; + +/// One shared file: its peer-facing virtual path and where it lives on disk. +/// +/// Built by the scan, never by a host, so fields can be added without a +/// breaking release. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct SharedFile { + /// Backslash-separated path exposed to peers, e.g. `music\album\song.mp3`. + pub virtual_path: String, + /// `virtual_path` lowercased once at scan time, so answering a search does + /// not lowercase every shared path again. + search_key: String, + /// The real filesystem path used to serve the bytes. + pub real_path: PathBuf, + pub size: u64, + /// `(code, value)` audio attributes read from the file's headers: bitrate, + /// duration, VBR, sample rate, bit depth, as far as the format gives them. + pub attributes: Vec<(u32, u32)>, +} + +/// A read-only snapshot of the shared files, cheap to clone behind an `Arc`. +#[derive(Debug, Default)] +pub struct Shares { + files: Vec, + by_virtual: HashMap, + folder_count: usize, +} + +impl Shares { + /// An empty share set (nothing shared). + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Scan `root` recursively into a share index. Symlinks and unreadable + /// entries are skipped so a single bad entry never aborts the scan. + /// + /// # Errors + /// Returns an error only if `root` itself cannot be read. + pub fn scan(root: &Path) -> io::Result { + // Fail fast if the root is unreadable; deeper failures are skipped. + let _ = std::fs::read_dir(root)?; + + Ok(Self::scan_many(&[root.to_path_buf()])) + } + + /// Scan several roots into one merged index. Unreadable roots are + /// skipped (with a log line) instead of failing the whole scan, and + /// roots that share a directory name get a ` (2)`, ` (3)`… suffix so + /// their virtual paths cannot collide. + #[must_use] + pub fn scan_many(roots: &[PathBuf]) -> Self { + let mut files = Vec::new(); + let mut folder_count = 0; + let mut name_uses: HashMap = HashMap::new(); + + for root in roots { + if std::fs::read_dir(root).is_err() { + crate::warn!( + "Skipping unreadable shared directory {}", + root.display() + ); + continue; + } + let base = root_display_name(root); + let uses = name_uses.entry(base.clone()).or_insert(0); + *uses += 1; + let name = if *uses == 1 { + base + } else { + format!("{base} ({uses})") + }; + scan_root(root, &name, &mut files, &mut folder_count); + } + + Self::from_files(files, folder_count) + } + + fn from_files(files: Vec, folder_count: usize) -> Self { + let by_virtual = files + .iter() + .enumerate() + .map(|(i, f)| (f.virtual_path.clone(), i)) + .collect(); + Self { + files, + by_virtual, + folder_count, + } + } + + /// Files whose virtual path contains *every* whitespace-separated term of + /// `query` (case-insensitive). An empty query matches nothing. + #[must_use] + pub fn search(&self, query: &str) -> Vec<&SharedFile> { + let terms: Vec = query + .to_lowercase() + .split_whitespace() + .map(String::from) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + self.files + .iter() + .filter(|f| terms.iter().all(|t| f.search_key.contains(t.as_str()))) + .collect() + } + + /// Look up a shared file by its exact virtual path. + #[must_use] + pub fn get(&self, virtual_path: &str) -> Option<&SharedFile> { + self.by_virtual.get(virtual_path).map(|&i| &self.files[i]) + } + + /// All shared files, in scan order. + #[must_use] + pub fn files(&self) -> &[SharedFile] { + &self.files + } + + /// Files grouped by their virtual directory (everything before the final + /// backslash), in the form a browse or folder listing carries. + #[must_use] + pub fn directories(&self) -> Vec { + let mut by_dir: std::collections::BTreeMap< + String, + Vec, + > = std::collections::BTreeMap::new(); + for file in &self.files { + let (dir, base) = file + .virtual_path + .rsplit_once('\\') + .unwrap_or(("", file.virtual_path.as_str())); + 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() + .map(|(name, files)| SharedDirectory { name, files }) + .collect() + } + + #[must_use] + pub const fn file_count(&self) -> u32 { + self.files.len() as u32 + } + + #[must_use] + pub const fn folder_count(&self) -> u32 { + self.folder_count as u32 + } +} + +fn root_display_name(root: &Path) -> String { + root.file_name().map_or_else( + || "shared".to_string(), + |n| n.to_string_lossy().into_owned(), + ) +} + +/// Recursively walk `root`, appending its files (under the virtual root name +/// `root_name`) to `files` and counting folders that contain files. +fn scan_root( + root: &Path, + root_name: &str, + files: &mut Vec, + folder_count: &mut usize, +) { + let mut folders_with_files: HashSet = HashSet::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { + continue; + }; + // `entry.metadata()` does not traverse the symlink itself. + if meta.file_type().is_symlink() { + continue; + } + let path = entry.path(); + if meta.is_dir() { + stack.push(path); + } else if meta.is_file() { + // The download directory is usually shared too; an in-progress + // `.part` file is not something to offer other peers. + if path + .to_string_lossy() + .ends_with(crate::utils::path::PART_SUFFIX) + { + continue; + } + let virtual_path = virtual_path_for(root_name, root, &path); + files.push(SharedFile { + search_key: virtual_path.to_lowercase(), + virtual_path, + attributes: audio::probe(&path), + real_path: path, + size: meta.len(), + }); + folders_with_files.insert(dir.clone()); + } + } + } + *folder_count += folders_with_files.len(); +} + +/// Build the peer-facing virtual path for `path` under `root`: the root's own +/// name followed by the backslash-separated components relative to it. +fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String { + let rel = path.strip_prefix(root).unwrap_or(path); + let mut parts = vec![root_name.to_string()]; + for component in rel.components() { + parts.push(component.as_os_str().to_string_lossy().into_owned()); + } + parts.join("\\") +} + +#[cfg(test)] +mod tests { + use super::Shares; + + fn temp_tree() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir() + .join(format!("soulseek-shares-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("album")).unwrap(); + std::fs::write(root.join("top.mp3"), b"aaaa").unwrap(); + std::fs::write(root.join("album").join("song one.flac"), b"bbbbbb") + .unwrap(); + std::fs::write(root.join("album").join("song two.flac"), b"cc") + .unwrap(); + root + } + + #[test] + fn a_scanned_mp3_carries_its_bitrate() { + let root = std::env::temp_dir() + .join(format!("soulseek-shares-audio-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // 100 frames at 128 kbps is 2.6 seconds. + std::fs::write( + root.join("track.mp3"), + super::audio::tests::cbr_mp3(100), + ) + .unwrap(); + std::fs::write(root.join("cover.jpg"), b"jpeg").unwrap(); + + let shares = Shares::scan(&root).unwrap(); + let track = shares + .get( + "soulseek-shares-audio-{pid}\\track.mp3" + .replace("{pid}", &std::process::id().to_string()) + .as_str(), + ) + .unwrap(); + assert!(track.attributes.contains(&(0, 128))); + assert!(track.attributes.contains(&(1, 2))); + let cover = shares + .files() + .iter() + .find(|f| f.virtual_path.ends_with("cover.jpg")) + .unwrap(); + assert!(cover.attributes.is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn scan_counts_files_and_folders() { + let root = temp_tree(); + let shares = Shares::scan(&root).unwrap(); + assert_eq!(shares.file_count(), 3); + // Two folders contain files: the root and `album`. + assert_eq!(shares.folder_count(), 2); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn in_progress_downloads_are_not_shared() { + let root = temp_tree(); + std::fs::write(root.join("half.mp3.part"), b"dd").unwrap(); + + let shares = Shares::scan(&root).unwrap(); + assert_eq!(shares.file_count(), 3, ".part files stay out of the index"); + let base = root.file_name().unwrap().to_string_lossy().into_owned(); + assert!(shares.get(&format!("{base}\\half.mp3.part")).is_none()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn nested_files_use_backslash_virtual_paths() { + let root = temp_tree(); + let base = root.file_name().unwrap().to_string_lossy().into_owned(); + let shares = Shares::scan(&root).unwrap(); + + let vpath = format!("{base}\\album\\song one.flac"); + let file = shares.get(&vpath).expect("nested file indexed"); + assert_eq!(file.size, 6); + assert!(shares.get("does\\not\\exist").is_none()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_mixed_case_path_matches_a_lowercase_query() { + let root = std::env::temp_dir() + .join(format!("soulseek-shares-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Live Set")).unwrap(); + std::fs::write(root.join("Live Set").join("Opening TRACK.flac"), b"x") + .unwrap(); + let shares = Shares::scan(&root).unwrap(); + + assert_eq!(shares.search("live opening track").len(), 1); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn search_is_case_insensitive_and_multi_term_and() { + let root = temp_tree(); + let shares = Shares::scan(&root).unwrap(); + + // Both album files match "SONG"; "song one" narrows to one. + assert_eq!(shares.search("SONG").len(), 2); + assert_eq!(shares.search("song ONE").len(), 1); + assert!(shares.search("nonexistent").is_empty()); + assert!(shares.search("").is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn scan_many_merges_roots() { + let root_a = temp_tree(); + let root_b = temp_tree(); + let shares = Shares::scan_many(&[root_a.clone(), root_b.clone()]); + assert_eq!(shares.file_count(), 6); + assert_eq!(shares.folder_count(), 4); + // Files from both roots are reachable under their root's name. + let name_a = root_a.file_name().unwrap().to_string_lossy(); + let name_b = root_b.file_name().unwrap().to_string_lossy(); + assert!(shares.get(&format!("{name_a}\\top.mp3")).is_some()); + assert!(shares.get(&format!("{name_b}\\top.mp3")).is_some()); + let _ = std::fs::remove_dir_all(root_a); + let _ = std::fs::remove_dir_all(root_b); + } + + #[test] + fn scan_many_disambiguates_equal_root_names() { + // Two different directories both named "music". + let parent_a = temp_tree(); + let parent_b = temp_tree(); + let music_a = parent_a.join("music"); + let music_b = parent_b.join("music"); + std::fs::create_dir_all(&music_a).unwrap(); + std::fs::create_dir_all(&music_b).unwrap(); + std::fs::write(music_a.join("a.mp3"), b"a").unwrap(); + std::fs::write(music_b.join("b.mp3"), b"b").unwrap(); + + let shares = Shares::scan_many(&[music_a, music_b]); + assert!(shares.get("music\\a.mp3").is_some()); + assert!( + shares.get("music (2)\\b.mp3").is_some(), + "second root with the same name gets a numbered suffix" + ); + let _ = std::fs::remove_dir_all(parent_a); + let _ = std::fs::remove_dir_all(parent_b); + } + + #[test] + fn scan_many_skips_unreadable_roots() { + let root = temp_tree(); + let shares = Shares::scan_many(&[ + std::path::PathBuf::from("/does/not/exist"), + root.clone(), + ]); + assert_eq!(shares.file_count(), 3); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn empty_shares_have_no_files_or_folders() { + let shares = Shares::empty(); + assert_eq!(shares.file_count(), 0); + assert_eq!(shares.folder_count(), 0); + assert!(shares.search("anything").is_empty()); + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs new file mode 100644 index 0000000..8fa98e1 --- /dev/null +++ b/soulseek-rs-lib/src/types.rs @@ -0,0 +1,609 @@ +use std::{collections::HashMap, sync::mpsc::Sender}; + +use crate::{error::Result, message::Message, utils::zlib::inflate}; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct File { + pub username: String, + pub name: String, + pub size: u64, + pub attribs: HashMap, +} +/// The client version sent to the server on login. +/// +/// Major versions identify client projects on the Soulseek network and are +/// reserved per project for the project's lifetime: +/// . +/// +/// Within the soulseek-rs major, minor versions identify the individual +/// clients built on this library. Reserved minors: +/// +/// | Minor | Client | +/// |-------|-----------------------------------------------| +/// | 1 | unidentified (library default) | +/// | 2 | soulseek-rs reference client (CLI/TUI) | +/// +/// Building on this library? Keep [`ClientVersion::MAJOR`] and pick an +/// unused minor for your client by opening an issue: +/// . +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientVersion { + pub major: u32, + pub minor: u32, +} + +impl ClientVersion { + /// The major version reserved for the soulseek-rs project. Clients + /// built on this library keep this major and pick their own + /// reserved minor version (see the table on [`ClientVersion`]). + pub const MAJOR: u32 = 176; + + /// The version the soulseek-rs reference client (CLI/TUI) logs in with. + pub const REFERENCE_CLIENT: Self = Self { + major: Self::MAJOR, + minor: 2, + }; +} + +impl Default for ClientVersion { + fn default() -> Self { + Self { + major: Self::MAJOR, + minor: 1, + } + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SearchResult { + pub token: u32, + pub files: Vec, + pub slots: u8, + pub speed: u32, + pub username: String, +} + +#[derive(Debug, Clone)] +pub struct Search { + pub token: u32, + pub results: Vec, +} + +/// Peer responses a search keeps before it stops collecting. +/// +/// Peers answer a popular query for minutes after the window closes; left +/// unbounded, one live-network search grew to nearly a million files and +/// gigabytes of memory, and every consumer of the results paid for the +/// whole set. A result set is a shortlist to pick from, not an archive of +/// everyone who answered. +pub const MAX_SEARCH_RESPONSES: usize = 500; + +/// Files a search keeps across all responses, so a few peers with huge +/// matching collections cannot blow past the response cap. +pub const MAX_SEARCH_FILES: usize = 10_000; + +/// Files one reply to a peer's search carries at most. +/// +/// A broad query against a big share would otherwise hand the whole index +/// to whoever typed it, on every such search that arrives. Nicotine+ sends +/// 300 by default and slskd 500; a searcher with that many sources from one +/// peer has plenty. +pub const MAX_SEARCH_REPLY_FILES: usize = 300; + +impl Search { + /// Store one peer's response, unless this search already holds enough. + /// + /// The last accepted response may carry the total past + /// [`MAX_SEARCH_FILES`]; responses are kept whole rather than truncated. + pub fn accept(&mut self, result: SearchResult) { + // ponytail: counts files linearly per call — bounded by the response + // cap; a running total would mean a new pub field on Search. + let files: usize = + self.results.iter().map(|result| result.files.len()).sum(); + if self.results.len() < MAX_SEARCH_RESPONSES && files < MAX_SEARCH_FILES + { + self.results.push(result); + } + } +} + +impl SearchResult { + pub fn new_from_message(message: &mut Message) -> Result { + let pointer = message.get_pointer(); + let size = message.get_size(); + let data: Vec = message.get_slice(pointer, size); + let deflated = inflate(&data)?; + let mut message = Message::new_with_data(deflated); + + let username = message.read_string(); + let token = message.read_int32(); + let n_files = message.read_int32(); + let mut files: Vec = Vec::new(); + for _ in 0..n_files { + // Stop if a hostile n_files count outruns the payload, so a bogus + // length can't spin us into a huge allocation loop. + if message.get_pointer() >= message.get_size() { + break; + } + message.read_int8(); + let name = message.read_string(); + let size = message.read_int64(); + message.read_string(); + let n_attribs = message.read_int32(); + let mut attribs: HashMap = HashMap::new(); + + for _ in 0..n_attribs { + // Each attribute is two int32s (8 bytes); guard against a bogus + // count since read_int32 does not advance past the buffer end. + if message.get_pointer() + 8 > message.get_size() { + break; + } + attribs.insert(message.read_int32(), message.read_int32()); + } + files.push(File { + username: username.clone(), + name, + size, + attribs, + }); + } + let slots = message.read_int8(); + let speed = message.read_int32(); + + Ok(Self { + token, + files, + slots, + speed, + username, + }) + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Transfer { + pub direction: u32, + pub token: u32, + pub filename: String, + pub size: u64, +} +#[derive(Debug, Clone, Default)] +pub struct DownloadMetadata { + pub bitrate: Option, + pub length_seconds: Option, + pub peer_upload_speed: Option, + pub peer_free_slots: Option, +} + +#[derive(Debug, Clone)] +pub struct Download { + pub username: String, + pub filename: String, + pub token: u32, + pub size: u64, + pub download_directory: String, + pub status: DownloadStatus, + pub sender: Sender, + pub queue_position: Option, + pub metadata: DownloadMetadata, +} + +impl Download { + #[must_use] + pub const fn is_finished(&self) -> bool { + self.status.is_terminal() + } + + #[must_use] + pub const fn bytes_downloaded(&self) -> u64 { + match &self.status { + DownloadStatus::InProgress { + bytes_downloaded, .. + } + | DownloadStatus::Paused { + bytes_downloaded, .. + } => *bytes_downloaded, + DownloadStatus::Completed => self.size, + _ => 0, + } + } + + #[must_use] + pub const fn speed_bytes_per_sec(&self) -> f64 { + match &self.status { + DownloadStatus::InProgress { + speed_bytes_per_sec, + .. + } => *speed_bytes_per_sec, + _ => 0.0, + } + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum DownloadStatus { + Queued, + InProgress { + bytes_downloaded: u64, + total_bytes: u64, + speed_bytes_per_sec: f64, + }, + Paused { + bytes_downloaded: u64, + total_bytes: u64, + }, + Completed, + Cancelled, + /// Failed, optionally with a human-readable reason. + Failed(Option), + TimedOut, +} + +impl DownloadStatus { + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!( + self, + Self::Completed + | Self::Cancelled + | Self::Failed(_) + | Self::TimedOut + ) + } +} + +/// Whether a user is reachable, as the server reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UserStatus { + #[default] + Offline, + Away, + Online, +} + +impl UserStatus { + /// Map the wire value (`GetUserStatus`, code 7). Anything unrecognised is + /// treated as offline, which is the safe reading for "cannot be reached". + #[must_use] + pub const fn from_code(code: u32) -> Self { + match code { + 1 => Self::Away, + 2 => Self::Online, + _ => Self::Offline, + } + } + + /// True when the user can be reached — online or away, but not offline. + #[must_use] + pub const fn is_reachable(self) -> bool { + matches!(self, Self::Away | Self::Online) + } +} + +impl std::fmt::Display for UserStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Offline => "offline", + Self::Away => "away", + Self::Online => "online", + }) + } +} + +/// A user's presence, from `GetUserStatus`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UserPresence { + pub status: UserStatus, + /// Whether the server grants this user queue priority. + pub privileged: bool, +} + +/// A user's sharing statistics, from `GetUserStats`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct UserStats { + /// Average upload speed in bytes per second, as the server records it. + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, +} + +/// What the server knows about another user. +/// +/// The server answers presence and statistics as two separate messages, so +/// each part is `None` until its reply lands. A snapshot therefore cannot +/// report a status the server never sent — ask for +/// [`UserInfo::presence`] and handle `None` rather than reading a default. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct UserInfo { + pub username: String, + /// `None` until the status reply arrives. + pub presence: Option, + /// `None` until the statistics reply arrives. + pub stats: Option, +} + +impl UserInfo { + /// An empty snapshot for `username`, before any reply has arrived. + #[must_use] + pub const fn pending(username: String) -> Self { + Self { + username, + presence: None, + stats: None, + } + } + + /// True once both replies have landed. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.presence.is_some() && self.stats.is_some() + } +} + +/// What the server reports about one member of a room we joined, from the +/// per-member stat vectors of a `JoinRoom` (code 14) reply. +/// +/// A server may send shorter stat vectors than the membership list (older +/// servers omit country codes entirely), so absent fields read as zero and an +/// unreported country is `None` rather than an empty string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomUserStats { + pub username: String, + pub status: UserStatus, + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, + /// True when the member's upload slots are all occupied, so a download + /// from them will queue. The wire field is `slotsfull`, a boolean — not a + /// count, and not a count of *free* slots. + pub slots_full: bool, + /// Two-letter country code, when the server reports one. + pub country: Option, +} + +/// A public chat room advertised by the server (`RoomList`, code 64). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomInfo { + pub name: String, + pub user_count: u32, +} + +/// Something that happened in the chat-room subsystem, surfaced to the client +/// so a UI can react to it. Drained via `Client::take_room_events`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoomEvent { + /// The full list of public rooms (supersedes any previous snapshot). + List(Vec), + /// We successfully joined `room`; carries the current member list. + Joined { room: String, users: Vec }, + /// We left `room`. + Left { room: String }, + /// `username` said `message` in `room`. + Message { + room: String, + username: String, + message: String, + }, + /// `username` joined `room`. + UserJoined { room: String, username: String }, + /// `username` left `room`. + UserLeft { room: String, username: String }, + /// The full ticker board of `room`, sent when we join it (code 113). + Tickers { + room: String, + tickers: Vec, + }, + /// `username` set their ticker in `room` (code 114). It replaces any + /// previous ticker of theirs. + TickerAdded { + room: String, + username: String, + ticker: String, + }, + /// `username` cleared their ticker in `room` (code 115). + TickerRemoved { room: String, username: String }, + /// A message said in *any* public room, delivered because we joined the + /// global room feed (code 152). `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room (code 133). + PrivateMembers { room: String, users: Vec }, + /// Who runs a private room (code 148). + PrivateOperators { room: String, users: Vec }, + /// One user joined or left a private room's member roster (`members` + /// true, codes 134/135) or its operator roster (143/144). + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership (`members` true, codes 139/140) or operatorship + /// (145/146) of a private room was granted or revoked. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003) — the name + /// belongs to a private room we are not a member of. + CantCreate { room: String }, +} + +/// One user's ticker (the scrolling one-line message) in a room. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomTicker { + pub username: String, + pub ticker: String, +} + +/// One recommended item and how strongly the server recommends it. The rating +/// is signed: a negative rating means the item is recommended *against*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recommendation { + pub item: String, + pub rating: i32, +} + +/// A user the server considers similar to us, with the weight (the number of +/// interests shared) it scored them by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimilarUser { + pub username: String, + pub weight: u32, +} + +/// What a user likes and hates (`UserInterests`, code 57). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserInterests { + pub username: String, + pub likes: Vec, + pub hates: Vec, +} + +impl Transfer { + pub fn new_from_message(message: &mut Message) -> Self { + let direction = message.read_int32(); + let token = message.read_int32(); + let filename = message.read_string(); + let size = message.read_int64(); + + Self { + direction, + token, + filename, + size, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A FileSearchResponse whose n_files claims ~4 billion entries with no + // file data must parse to an empty result promptly, not loop into an OOM. + #[test] + fn search_result_hostile_file_count_does_not_hang() { + let mut body = Vec::new(); + body.extend_from_slice(&0u32.to_le_bytes()); // username "" (len 0) + body.extend_from_slice(&7u32.to_le_bytes()); // token + body.extend_from_slice(&u32::MAX.to_le_bytes()); // n_files (hostile) + let compressed = crate::utils::zlib::deflate(&body); + let mut message = Message::new_with_data(compressed); + let result = SearchResult::new_from_message(&mut message) + .expect("hostile count should parse, not error"); + assert_eq!(result.token, 7); + assert!(result.files.is_empty()); + } + + // A truncated TransferRequest from an untrusted peer must parse to defaults + // rather than panic (the read_* primitives are bounds-checked). + #[test] + fn transfer_new_from_truncated_message_does_not_panic() { + let mut message = Message::new_with_data(vec![1, 0, 0]); + let transfer = Transfer::new_from_message(&mut message); + assert_eq!(transfer.direction, 0); + assert_eq!(transfer.token, 0); + assert_eq!(transfer.filename, ""); + assert_eq!(transfer.size, 0); + } +} + +/// Lifecycle of a file we are serving to a peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UploadStatus { + /// Waiting for a free upload slot, at this 1-based place in the queue. + Queued(u32), + InProgress, + Completed, + Cancelled, + Failed(String), +} + +/// A snapshot of one upload for display purposes. +#[derive(Debug, Clone)] +pub struct UploadInfo { + /// The peer receiving the file. + pub username: String, + /// The peer-facing virtual path being served. + pub filename: String, + pub size: u64, + pub bytes_sent: u64, + pub status: UploadStatus, + /// Transfer rate, so an upload row can fill the same Speed column a + /// download does. Zero unless the upload is in progress. + pub speed_bytes_per_sec: f64, +} + +/// Why a server session ended before the client was done with it. +/// +/// A session that ends stops seeing the network entirely, which is not the +/// same thing as the network having nothing to show — telling the two apart is +/// the difference between "retry" and "give up". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SessionLoss { + /// Another login claimed this username. The server keeps one session per + /// name, so the older one is cut off. + Displaced = 1, + /// The connection to the server dropped. + Disconnected = 2, +} + +impl std::fmt::Display for SessionLoss { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Displaced => f.write_str( + "another login took over this username; the server allows one \ + session per account", + ), + Self::Disconnected => { + f.write_str("the connection to the server dropped") + } + } + } +} + +/// A lock-free view, shared with the server actor, of whether the session is +/// still alive. +/// +/// The first loss recorded wins: a displaced session also drops its socket a +/// moment later, and "displaced" is the reason worth reporting. +#[derive(Debug, Clone, Default)] +pub struct SessionWatch(std::sync::Arc); + +impl SessionWatch { + const LIVE: u8 = 0; + + pub fn record(&self, loss: SessionLoss) { + let _ = self.0.compare_exchange( + Self::LIVE, + loss as u8, + std::sync::atomic::Ordering::Relaxed, + std::sync::atomic::Ordering::Relaxed, + ); + } + + #[must_use] + pub fn loss(&self) -> Option { + match self.0.load(std::sync::atomic::Ordering::Relaxed) { + 1 => Some(SessionLoss::Displaced), + 2 => Some(SessionLoss::Disconnected), + _ => None, + } + } + + pub fn clear(&self) { + self.0 + .store(Self::LIVE, std::sync::atomic::Ordering::Relaxed); + } +} diff --git a/soulseek-rs-lib/src/utils/keepalive.rs b/soulseek-rs-lib/src/utils/keepalive.rs new file mode 100644 index 0000000..b7c4358 --- /dev/null +++ b/soulseek-rs-lib/src/utils/keepalive.rs @@ -0,0 +1,99 @@ +//! TCP keepalive for the server connection. +//! +//! A NAT that drops an idle mapping leaves the socket looking connected while +//! the server can no longer reach us, and nothing ever errors. Probes every +//! ten seconds keep the mapping alive and turn a dead path into a read error +//! within about half a minute, which is what Nicotine+ does. The server +//! stopped answering ServerPing (code 32) long ago, so that is no substitute. + +use std::io; +use std::net::TcpStream; + +#[cfg(unix)] +pub fn set_keepalive(stream: &TcpStream) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + let fd = stream.as_raw_fd(); + set_option(fd, libc::SOL_SOCKET, libc::SO_KEEPALIVE, 1)?; + #[cfg(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "netbsd" + ))] + { + set_option(fd, libc::IPPROTO_TCP, IDLE_SECONDS_OPTION, 10)?; + set_option(fd, libc::IPPROTO_TCP, libc::TCP_KEEPINTVL, 2)?; + set_option(fd, libc::IPPROTO_TCP, libc::TCP_KEEPCNT, 10)?; + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +const IDLE_SECONDS_OPTION: libc::c_int = libc::TCP_KEEPALIVE; +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "netbsd" +))] +const IDLE_SECONDS_OPTION: libc::c_int = libc::TCP_KEEPIDLE; + +#[cfg(unix)] +fn set_option( + fd: libc::c_int, + level: libc::c_int, + name: libc::c_int, + value: libc::c_int, +) -> io::Result<()> { + // SAFETY: `value` outlives the call and its exact size travels with it. + let rc = unsafe { + libc::setsockopt( + fd, + level, + name, + (&raw const value).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +// ponytail: Windows wants WSAIoctl(SIO_KEEPALIVE_VALS); wire it when the +// daemon ships there. +#[cfg(not(unix))] +pub fn set_keepalive(_stream: &TcpStream) -> io::Result<()> { + Ok(()) +} + +#[cfg(all(test, unix))] +pub fn keepalive_enabled(stream: &TcpStream) -> bool { + use std::os::unix::io::AsRawFd; + let mut value: libc::c_int = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `value` and `len` outlive the call and `len` bounds the write. + let rc = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_KEEPALIVE, + (&raw mut value).cast(), + &raw mut len, + ) + }; + rc == 0 && value != 0 +} + +#[cfg(all(test, unix))] +#[test] +fn keepalive_is_off_until_asked_for() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + assert!(!keepalive_enabled(&stream)); + set_keepalive(&stream).unwrap(); + assert!(keepalive_enabled(&stream)); +} diff --git a/soulseek-rs-lib/src/utils/lock.rs b/soulseek-rs-lib/src/utils/lock.rs new file mode 100644 index 0000000..17588b6 --- /dev/null +++ b/soulseek-rs-lib/src/utils/lock.rs @@ -0,0 +1,28 @@ +use std::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::error::{Result, SoulseekRs}; + +pub trait RwLockExt { + fn read_safe(&self) -> Result>; + fn write_safe(&self) -> Result>; +} + +impl RwLockExt for RwLock { + fn read_safe(&self) -> Result> { + self.read().map_err(|_| SoulseekRs::LockPoisoned) + } + + fn write_safe(&self) -> Result> { + self.write().map_err(|_| SoulseekRs::LockPoisoned) + } +} + +pub trait MutexExt { + fn lock_safe(&self) -> Result>; +} + +impl MutexExt for Mutex { + fn lock_safe(&self) -> Result> { + self.lock().map_err(|_| SoulseekRs::LockPoisoned) + } +} diff --git a/soulseek-rs-lib/src/utils/logger.rs b/soulseek-rs-lib/src/utils/logger.rs new file mode 100644 index 0000000..ea49656 --- /dev/null +++ b/soulseek-rs-lib/src/utils/logger.rs @@ -0,0 +1,281 @@ +use std::env; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::{ + Mutex, Once, + atomic::{AtomicBool, AtomicU8, Ordering}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, + Trace = 4, +} + +static INIT: Once = Once::new(); +static LOG_LEVEL: AtomicU8 = AtomicU8::new(LogLevel::Warn as u8); + +/// Change the level at runtime, from any thread. +pub fn set_log_level(level: LogLevel) { + LOG_LEVEL.store(level as u8, Ordering::Relaxed); +} + +#[must_use] +pub fn log_level() -> LogLevel { + match LOG_LEVEL.load(Ordering::Relaxed) { + 0 => LogLevel::Error, + 2 => LogLevel::Info, + 3 => LogLevel::Debug, + 4 => LogLevel::Trace, + _ => LogLevel::Warn, + } +} + +static BUFFER: Mutex> = Mutex::new(Vec::new()); +static BUFFERING: AtomicBool = AtomicBool::new(false); +static LOG_FILE: Mutex> = Mutex::new(None); + +pub fn init() { + INIT.call_once(|| { + let level = env::var("LOG_LEVEL") + .or_else(|_| env::var("RUST_LOG")) + .unwrap_or_else(|_| "WARN".to_string()) + .to_uppercase(); + + set_log_level(match level.as_str() { + "ERROR" => LogLevel::Error, + "INFO" => LogLevel::Info, + "DEBUG" | "VERBOSE" => LogLevel::Debug, + "TRACE" => LogLevel::Trace, + _ => LogLevel::Warn, + }); + + // Initialize log file if LOG_FILE env var is set + if let Ok(log_file_path) = env::var("LOG_FILE") { + match OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path) + { + Ok(file) => { + if let Ok(mut log_file) = LOG_FILE.lock() { + *log_file = Some(file); + } + } + Err(e) => { + eprintln!("Failed to open log file '{log_file_path}': {e}"); + } + } + } + }); +} + +/// Where a single log line should be written. Each line goes to exactly one +/// sink; routing a line to more than one is what caused file lines to be +/// duplicated (written once eagerly and again when the buffer was flushed). +#[derive(Debug, PartialEq, Eq)] +enum LogSink { + File, + Buffer, + Stderr, +} + +const fn choose_sink(buffering: bool, has_file: bool) -> LogSink { + match (buffering, has_file) { + // A configured file always takes the line directly and bypasses + // buffering, so the flush step never re-writes it. + (_, true) => LogSink::File, + // No file: while the TUI holds the screen we defer stderr writes. + (true, false) => LogSink::Buffer, + (false, false) => LogSink::Stderr, + } +} + +fn has_log_file() -> bool { + LOG_FILE.lock().is_ok_and(|f| f.is_some()) +} + +pub fn log(level: LogLevel, message: &str) { + if level <= log_level() { + { + let (name, colour) = match level { + LogLevel::Error => ("ERROR", "\x1b[31m"), // Red + LogLevel::Warn => ("WARN", "\x1b[33m"), // Yellow + LogLevel::Info => ("INFO", "\x1b[32m"), // Green + LogLevel::Debug => ("DEBUG", "\x1b[34m"), // Blue + LogLevel::Trace => ("TRACE", "\x1b[35m"), // Magenta + }; + + let now = std::time::SystemTime::now(); + let datetime = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = datetime.as_secs(); + let subsec_millis = datetime.subsec_millis(); + + // Format as YYYY-MM-DD HH:MM:SS.mmm + let days_since_epoch = secs / 86400; + let days_since_1970 = days_since_epoch as i32; + + // Calculate year (approximately) + let mut year = 1970; + let mut remaining_days = days_since_1970; + + while remaining_days >= 365 { + let is_leap = + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let days_in_year = if is_leap { 366 } else { 365 }; + if remaining_days >= days_in_year { + remaining_days -= days_in_year; + year += 1; + } else { + break; + } + } + + // Calculate month and day (simplified) + let month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let is_leap = + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let mut month = 1; + let mut day = remaining_days + 1; + + for &days_in_month in &month_days { + let actual_days = if month == 2 && is_leap { + 29 + } else { + days_in_month + }; + if day > actual_days { + day -= actual_days; + month += 1; + } else { + break; + } + } + + // Calculate time of day + let seconds_in_day = secs % 86400; + let hours = seconds_in_day / 3600; + let minutes = (seconds_in_day % 3600) / 60; + let seconds = seconds_in_day % 60; + + let timestamp = format!( + "{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}.{subsec_millis:03}" + ); + + match choose_sink(BUFFERING.load(Ordering::Relaxed), has_log_file()) + { + LogSink::File => { + if let Ok(mut log_file) = LOG_FILE.lock() + && let Some(file) = log_file.as_mut() + { + let _ = + writeln!(file, "[{timestamp}] [{name}] {message}"); + let _ = file.flush(); + } + } + LogSink::Buffer => { + if let Ok(mut buffer) = BUFFER.lock() { + buffer.push(format!( + "[{timestamp}] [{colour}{name}\x1b[0m] {message}" + )); + } + } + LogSink::Stderr => { + eprintln!( + "[{timestamp}] [{colour}{name}\x1b[0m] {message}" + ); + } + } + } + } +} + +pub fn enable_buffering() { + BUFFERING.store(true, Ordering::Relaxed); +} + +pub fn disable_buffering() { + BUFFERING.store(false, Ordering::Relaxed); +} + +/// Only lines that were buffered land here, and a line is buffered only when +/// no log file is configured, so the buffer is always stderr-bound. +pub fn flush_buffered_logs() { + disable_buffering(); + + if let Ok(mut buffer) = BUFFER.lock() { + for message in buffer.iter() { + eprintln!("{message}"); + } + buffer.clear(); + } +} + +#[macro_export] +macro_rules! error { + ($($arg:tt)*) => { + $crate::utils::logger::log($crate::utils::logger::LogLevel::Error, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => { + $crate::utils::logger::log($crate::utils::logger::LogLevel::Warn, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! info { + ($($arg:tt)*) => { + $crate::utils::logger::log($crate::utils::logger::LogLevel::Info, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! debug { + ($($arg:tt)*) => { + $crate::utils::logger::log($crate::utils::logger::LogLevel::Debug, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! trace { + ($($arg:tt)*) => { + $crate::utils::logger::log($crate::utils::logger::LogLevel::Trace, &format!($($arg)*)) + }; +} + +#[cfg(test)] +mod tests { + use super::{LogLevel, LogSink, choose_sink, log_level, set_log_level}; + + #[test] + fn the_level_can_be_changed_while_running() { + let before = log_level(); + set_log_level(LogLevel::Trace); + assert_eq!(log_level(), LogLevel::Trace); + set_log_level(before); + assert_eq!(log_level(), before); + } + + #[test] + fn a_configured_file_bypasses_buffering_so_lines_are_not_duplicated() { + // With a file configured, the line goes straight to the file whether or + // not buffering is on, so it is never also queued for a flush that would + // write it a second time. + assert_eq!(choose_sink(true, true), LogSink::File); + assert_eq!(choose_sink(false, true), LogSink::File); + } + + #[test] + fn without_a_file_buffering_routes_to_the_buffer_else_stderr() { + assert_eq!(choose_sink(true, false), LogSink::Buffer); + assert_eq!(choose_sink(false, false), LogSink::Stderr); + } +} diff --git a/soulseek-rs-lib/src/utils/md5.rs b/soulseek-rs-lib/src/utils/md5.rs new file mode 100644 index 0000000..fd427de --- /dev/null +++ b/soulseek-rs-lib/src/utils/md5.rs @@ -0,0 +1,470 @@ +use std::convert::TryInto; +// The MD5 Message-Digest Algorithm, written in Rust. +// Implemented by michael dimovich (@mdimovich) +// +// The purpose of this code is to act as a learning resource +// for the md5 message digest algorithm. I wanted to provide +// a straightforward walk-through of the md5 algorithm using +// Rust. +// +// This code was implemented using the original md5 message digest +// RFC, written by R. Rivest in April 1992. +// +// For more information about the md5 algorithm, or the original C +// source code implementation, please reference RFC 1321. +// +// (source: https://datatracker.ietf.org/doc/html/rfc1321) + +/** +* This function is specified in the rfc as a way to generate the +* table that we use in rounds 1-4 as part of the hashing operation. +* The function is specified as: +* K[i] = floor(2^32 * abs(sin(i+1))) +*/ +fn table_construction_function(i: u32) -> u32 { + let x: f64 = f64::from(i); + let sin_eval = x.sin().abs(); + // note: 4294967296 == 2^32 + (4294967296.0 * sin_eval) as u32 +} + +/** +* 4 auxiliary functions that take 3 32-bit +* integers as input and return 1 32 bit integer as output. +* +* these bitwise operations are the crux of the md5 hashing +* algorithm, and they are performed a number of times to end +* up with our final result. +*/ +const fn f(x: u32, y: u32, z: u32) -> u32 { + x & y | !x & z +} + +const fn g(x: u32, y: u32, z: u32) -> u32 { + x & z | y & !z +} + +const fn h(x: u32, y: u32, z: u32) -> u32 { + x ^ y ^ z +} + +const fn i(x: u32, y: u32, z: u32) -> u32 { + y ^ (x | !z) +} + +// utility function to convert a vector of type T with size N into an array of type T with size N. +fn vec_to_array(v: Vec) -> [T; N] { + v.try_into().unwrap_or_else(|_v: Vec| { + panic!("error converting vector to array - sizes don't match") + }) +} + +/** +* Round 1 function is specified as 16 operations using the following: +* Let [abcd, k s i] denote the operation - +* a = b + ((a + f(b,c,d) + x[k] + table[i]) <<< s); +* where x[k] is a 32 bit chunk of our original message, +* table[i] is an entry generated by our sine function, s is an unsigned +* integer, and a,b,c,d are our 32 bit registers we perform operations on. +*/ +const fn round_one_operations( + mut a: u32, + mut b: u32, + mut c: u32, + mut d: u32, + table: &[u32], + x: &[u32], +) -> [u32; 4] { + let _ = x; + let _ = table; + macro_rules! round1 { + ( $a:ident, $b:ident, $c:ident, $d:ident, $k:expr, $s:expr, $i: expr ) => { + $a = $b.wrapping_add( + ($a.wrapping_add(f($b, $c, $d)) + .wrapping_add(x[$k]) + .wrapping_add(table[$i])) + .rotate_left($s), + ) + }; + } + + round1!(a, b, c, d, 0, 7, 1); + round1!(d, a, b, c, 1, 12, 2); + round1!(c, d, a, b, 2, 17, 3); + round1!(b, c, d, a, 3, 22, 4); + + round1!(a, b, c, d, 4, 7, 5); + round1!(d, a, b, c, 5, 12, 6); + round1!(c, d, a, b, 6, 17, 7); + round1!(b, c, d, a, 7, 22, 8); + + round1!(a, b, c, d, 8, 7, 9); + round1!(d, a, b, c, 9, 12, 10); + round1!(c, d, a, b, 10, 17, 11); + round1!(b, c, d, a, 11, 22, 12); + + round1!(a, b, c, d, 12, 7, 13); + round1!(d, a, b, c, 13, 12, 14); + round1!(c, d, a, b, 14, 17, 15); + round1!(b, c, d, a, 15, 22, 16); + + [a, b, c, d] +} + +/** +* Round 2 is equivalent to round 1, except we swap out our f function for g as such: +* a = b + ((a + g(b,c,d) + x[k] + table[i]) <<< s); +*/ +const fn round_two_operations( + mut a: u32, + mut b: u32, + mut c: u32, + mut d: u32, + table: &[u32], + x: &[u32], +) -> [u32; 4] { + macro_rules! round2 { + ( $a:ident, $b:ident, $c:ident, $d:ident, $k:expr, $s:expr, $i:expr) => { + $a = $b.wrapping_add( + ($a.wrapping_add(g($b, $c, $d)) + .wrapping_add(x[$k]) + .wrapping_add(table[$i])) + .rotate_left($s), + ) + }; + } + + round2!(a, b, c, d, 1, 5, 17); + round2!(d, a, b, c, 6, 9, 18); + round2!(c, d, a, b, 11, 14, 19); + round2!(b, c, d, a, 0, 20, 20); + + round2!(a, b, c, d, 5, 5, 21); + round2!(d, a, b, c, 10, 9, 22); + round2!(c, d, a, b, 15, 14, 23); + round2!(b, c, d, a, 4, 20, 24); + + round2!(a, b, c, d, 9, 5, 25); + round2!(d, a, b, c, 14, 9, 26); + round2!(c, d, a, b, 3, 14, 27); + round2!(b, c, d, a, 8, 20, 28); + + round2!(a, b, c, d, 13, 5, 29); + round2!(d, a, b, c, 2, 9, 30); + round2!(c, d, a, b, 7, 14, 31); + round2!(b, c, d, a, 12, 20, 32); + + [a, b, c, d] +} + +/** +* Round 3 is equivalent to round 1, except we swap out our f function for h as such: +* a = b + ((a + h(b,c,d) + x[k] + table[i]) <<< s); +*/ +const fn round_three_operations( + mut a: u32, + mut b: u32, + mut c: u32, + mut d: u32, + table: &[u32], + x: &[u32], +) -> [u32; 4] { + macro_rules! round3 { + ( $a:ident, $b:ident, $c:ident, $d:ident, $k:expr, $s:expr, $i:expr ) => { + $a = $b.wrapping_add( + ($a.wrapping_add(h($b, $c, $d)) + .wrapping_add(x[$k]) + .wrapping_add(table[$i])) + .rotate_left($s), + ) + }; + } + + round3!(a, b, c, d, 5, 4, 33); + round3!(d, a, b, c, 8, 11, 34); + round3!(c, d, a, b, 11, 16, 35); + round3!(b, c, d, a, 14, 23, 36); + + round3!(a, b, c, d, 1, 4, 37); + round3!(d, a, b, c, 4, 11, 38); + round3!(c, d, a, b, 7, 16, 39); + round3!(b, c, d, a, 10, 23, 40); + + round3!(a, b, c, d, 13, 4, 41); + round3!(d, a, b, c, 0, 11, 42); + round3!(c, d, a, b, 3, 16, 43); + round3!(b, c, d, a, 6, 23, 44); + + round3!(a, b, c, d, 9, 4, 45); + round3!(d, a, b, c, 12, 11, 46); + round3!(c, d, a, b, 15, 16, 47); + round3!(b, c, d, a, 2, 23, 48); + + [a, b, c, d] +} + +/** +* Round 4 is equivalent to round 1, except we swap out our f function for h as such: +* a = b + ((a + i(b,c,d) + x[k] + table[i]) <<< s); +*/ +const fn round_four_operations( + mut a: u32, + mut b: u32, + mut c: u32, + mut d: u32, + table: &[u32], + x: &[u32], +) -> [u32; 4] { + macro_rules! round4 { + ( $a:ident, $b:ident, $c:ident, $d:ident, $k:expr, $s:expr, $i:expr ) => { + $a = $b.wrapping_add( + ($a.wrapping_add(i($b, $c, $d)) + .wrapping_add(x[$k]) + .wrapping_add(table[$i])) + .rotate_left($s), + ) + }; + } + + round4!(a, b, c, d, 0, 6, 49); + round4!(d, a, b, c, 7, 10, 50); + round4!(c, d, a, b, 14, 15, 51); + round4!(b, c, d, a, 5, 21, 52); + + round4!(a, b, c, d, 12, 6, 53); + round4!(d, a, b, c, 3, 10, 54); + round4!(c, d, a, b, 10, 15, 55); + round4!(b, c, d, a, 1, 21, 56); + + round4!(a, b, c, d, 8, 6, 57); + round4!(d, a, b, c, 15, 10, 58); + round4!(c, d, a, b, 6, 15, 59); + round4!(b, c, d, a, 13, 21, 60); + + round4!(a, b, c, d, 4, 6, 61); + round4!(d, a, b, c, 11, 10, 62); + round4!(c, d, a, b, 2, 15, 63); + round4!(b, c, d, a, 9, 21, 64); + + [a, b, c, d] +} + +/** +* utility function to iterate over our slice of u8 ints +* and convert into a vector of unsigned 32 bit ints +*/ +fn convert_u8_chunk_to_u32(chunk: &mut [u8]) -> Vec { + let mut x: Vec = Vec::new(); + + let mut count = 0; + let mut temporary_vec: Vec = Vec::new(); + // iterate over our block and take + // our 8 bit ints and convert them to + // 32 bit integers + for item in chunk { + temporary_vec.push(*item); + count += 1; + if count == 4 { + let temp_arr: [u8; 4] = vec_to_array(temporary_vec.clone()); + let value = u32::from_ne_bytes(temp_arr); + x.push(value); + count = 0; + temporary_vec.clear(); + } + } + x +} + +fn compute_md5_digest(mut v: Vec) -> String { + // as described in the rfc, + // 4 32-bit words initialized as fixed constants. + let mut word_a = 0x67452301u32; + let mut word_b = 0xefcdab89u32; + let mut word_c = 0x98badcfeu32; + let mut word_d = 0x10325476u32; + + // construct the 64 element constant table. + let table = construct_value_table(); + + // let M[0 .. N-1] = words of resulting message, where N is multiple of 16 + for chunk in v.as_chunks_mut::<64>().0 { + let x = convert_u8_chunk_to_u32(chunk); + + // set all values of a,b,c,d to aa,bb,cc, and dd respectively. + // this is to save the initial values. + let word_aa = word_a; + let word_bb = word_b; + let word_cc = word_c; + let word_dd = word_d; + + let result = + round_one_operations(word_a, word_b, word_c, word_d, &table, &x); + word_a = result[0]; + word_b = result[1]; + word_c = result[2]; + word_d = result[3]; + + let result = + round_two_operations(word_a, word_b, word_c, word_d, &table, &x); + + word_a = result[0]; + word_b = result[1]; + word_c = result[2]; + word_d = result[3]; + + let result = + round_three_operations(word_a, word_b, word_c, word_d, &table, &x); + word_a = result[0]; + word_b = result[1]; + word_c = result[2]; + word_d = result[3]; + + let result = + round_four_operations(word_a, word_b, word_c, word_d, &table, &x); + word_a = result[0]; + word_b = result[1]; + word_c = result[2]; + word_d = result[3]; + + // at end of loop iteration, add original word + // to the current word value + word_a = word_a.wrapping_add(word_aa); + word_b = word_b.wrapping_add(word_bb); + word_c = word_c.wrapping_add(word_cc); + word_d = word_d.wrapping_add(word_dd); + } + + // format and return the final result, which + // is a 128-bit digest string. + let message_digest = format!( + "{:08x}{:08x}{:08x}{:08x}", + word_a.swap_bytes(), + word_b.swap_bytes(), + word_c.swap_bytes(), + word_d.swap_bytes() + ); + message_digest +} + +/* +* BIT PADDING STEP +* 1. Append a 1 bit to the end of original message. +* 2. Until the bit length of the data is 64 bits +* short of being a multiple of 512, append 0's +* to the data. +* 3. With the last 64 bits, append the length in 64 bits +* (in lower-order bits first). +*/ +fn bit_padding(input: &str) -> Vec { + let mut input_vector: Vec = convert_str_to_vec(input); + let bit_length: u64 = (input.len() as u64) * 8u64; + + // 128_u8 is the equivalent of padding 1 as an unsigned 8-bit integer + // with lower-order bits first + input_vector.push(128_u8); + //check if bit length % 512 is 448 (64 less than 512) + while (input_vector.len() * 8) % 512 != 448 { + input_vector.push(0_u8); // push in another 8-bit 0 padded value until the correct + // result is reached; + } + + let length_bits_as_u8_array = split_u64_to_u8_array(bit_length); + input_vector.extend(length_bits_as_u8_array); + + input_vector +} + +const fn split_u64_to_u8_array(s: u64) -> [u8; 8] { + [ + s as u8, + (s >> 8) as u8, + (s >> 16) as u8, + (s >> 24) as u8, + (s >> 32) as u8, + (s >> 40) as u8, + (s >> 48) as u8, + (s >> 56) as u8, + ] +} + +fn construct_value_table() -> Vec { + let mut t: Vec = Vec::new(); + t.push(0x00000000); + for i in 1..=64 { + t.push(table_construction_function(i)); + } + t +} + +// this should only work with utf-8 encoding and not full unicode support +// due to multi-byte unicode chars +fn convert_str_to_vec(input: &str) -> Vec { + let mut byte_vec: Vec = Vec::new(); + byte_vec.extend(input.as_bytes()); + byte_vec +} + +/** +* A Basic Overview of this MD5 Implementation: +* 1. Take in a command line string and convert into +* a vector of unsigned 8 bit ints (Vec) +* 2. Pad the message with a single 1, followed by n 0's, +* such that the message length in bits % 512 is equal to 448 +* 3. Pad the last 64 bits with a little-endian representation +* of the original message length. +* 4. Convert our vector of unsigned 8-bit integers into +* blocks of 32 bit unsigned ints (u32). +* 5. Using the 4 auxiliary functions described in rfc 1321, +* begin a series of 64 bitwise operations on each 512 bit +* block of our message. +* 6. Once we are done, combine our 4 32 bit registers used to +* during our bitwise operations to retrieve our message digest. +* (This is a simplification, but describes the core functionality) +*/ +#[must_use] +pub fn md5(input: &str) -> String { + let input_vec = bit_padding(input); + compute_md5_digest(input_vec) +} +#[test] +fn a_correct_hash() { + assert_eq!("0cc175b9c0f1b6a831c399e269772661", md5("a")); +} + +#[test] +fn empty_string_correct_hash() { + assert_eq!("d41d8cd98f00b204e9800998ecf8427e", md5("")); +} + +#[test] +fn abc_correct_hash() { + assert_eq!("900150983cd24fb0d6963f7d28e17f72", md5("abc")); +} + +#[test] +fn message_digest_correct_hash() { + assert_eq!("f96b697d7cb7938d525a2f31aaf161d0", md5("message digest")); +} + +#[test] +fn lowercase_alphabet_correct_hash() { + assert_eq!( + "c3fcd3d76192e4007dfb496cca67e13b", + md5("abcdefghijklmnopqrstuvwxyz") + ); +} + +#[test] +fn all_alphanumeric_correct_hash() { + let alpha_num_str = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + assert_eq!("d174ab98d277d9f5a5611c2c9f419d9f", md5(alpha_num_str)); +} + +#[test] +fn repeated_numbers_correct_hash() { + let repeated_number_str = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"; + + assert_eq!("57edf4a22be3c955ac49da2e2107b67a", md5(repeated_number_str)); +} diff --git a/soulseek-rs-lib/src/utils/mod.rs b/soulseek-rs-lib/src/utils/mod.rs new file mode 100644 index 0000000..b7ea3bd --- /dev/null +++ b/soulseek-rs-lib/src/utils/mod.rs @@ -0,0 +1,12 @@ +#[macro_use] +pub mod logger; +pub(crate) mod keepalive; +pub mod lock; +pub mod md5; +pub mod path; +pub(crate) mod rate_limit; +pub(crate) mod semaphore; +pub mod zlib; + +// Re-export commonly used items +pub use md5::md5; diff --git a/soulseek-rs-lib/src/utils/path.rs b/soulseek-rs-lib/src/utils/path.rs new file mode 100644 index 0000000..80d0462 --- /dev/null +++ b/soulseek-rs-lib/src/utils/path.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +/// Extension of the file a download streams into while it is still running. +/// +/// Downloads usually land in a shared directory, so the transfer code that +/// creates these and the share scanner that must skip them agree here. +pub const PART_SUFFIX: &str = ".part"; + +#[must_use] +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(stripped) = path.strip_prefix('~') + && let Ok(home) = std::env::var("HOME") + { + return PathBuf::from(home).join(stripped.trim_start_matches('/')); + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_tilde_returned_as_is() { + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); + } + + #[test] + fn tilde_expanded_when_home_set() { + // SAFETY: tests run single-threaded by default in this crate; if you + // ever run with --test-threads >1 this needs serialization. + unsafe { + std::env::set_var("HOME", "/home/test"); + } + assert_eq!(expand_tilde("~"), PathBuf::from("/home/test")); + assert_eq!( + expand_tilde("~/Downloads"), + PathBuf::from("/home/test/Downloads") + ); + } +} diff --git a/soulseek-rs-lib/src/utils/rate_limit.rs b/soulseek-rs-lib/src/utils/rate_limit.rs new file mode 100644 index 0000000..a5a261a --- /dev/null +++ b/soulseek-rs-lib/src/utils/rate_limit.rs @@ -0,0 +1,136 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +const TICK: Duration = Duration::from_millis(100); +const TICKS_PER_SECOND: u64 = 1000 / TICK.as_millis() as u64; + +#[derive(Default)] +pub struct RateLimit { + bytes_per_second: AtomicU64, + bucket: Mutex, +} + +#[derive(Default)] +struct Bucket { + tokens: u64, + refilled: Option, +} + +impl RateLimit { + pub fn set(&self, bytes_per_second: u64) { + self.bytes_per_second + .store(bytes_per_second, Ordering::Relaxed); + } + + pub fn take(&self, want: usize) -> usize { + loop { + let rate = self.bytes_per_second.load(Ordering::Relaxed); + if rate == 0 { + return want; + } + let mut bucket = + self.bucket.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + let since = bucket.refilled.map_or(TICK, |at| now - at); + if since >= TICK { + bucket.tokens = (rate / TICKS_PER_SECOND).max(1); + bucket.refilled = Some(now); + } + if bucket.tokens > 0 { + let grant = bucket.tokens.min(want as u64); + bucket.tokens -= grant; + return grant as usize; + } + drop(bucket); + std::thread::sleep(TICK.saturating_sub(since)); + } + } + + pub fn give_back(&self, unused: usize) { + if unused == 0 || self.bytes_per_second.load(Ordering::Relaxed) == 0 { + return; + } + self.bucket + .lock() + .unwrap_or_else(PoisonError::into_inner) + .tokens += unused as u64; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_limit_grants_everything_at_once() { + let limit = RateLimit::default(); + let started = Instant::now(); + assert_eq!(limit.take(1 << 20), 1 << 20); + assert!(started.elapsed() < TICK); + } + + #[test] + fn a_limit_paces_what_is_taken() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let mut taken = 0; + while taken < 6_000 { + let grant = limit.take(4096); + assert!((1..=4096).contains(&grant), "granted {grant}"); + taken += grant; + } + let elapsed = started.elapsed(); + assert!( + (2 * TICK..10 * TICK).contains(&elapsed), + "{taken} bytes at 20 kB/s took {elapsed:?}" + ); + } + + #[test] + fn concurrent_takers_share_one_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + std::thread::scope(|s| { + for _ in 0..3 { + s.spawn(|| { + let mut taken = 0; + while taken < 2_000 { + taken += limit.take(4096); + } + }); + } + }); + assert!( + started.elapsed() >= 2 * TICK, + "6 kB at 20 kB/s across three takers took {:?}", + started.elapsed() + ); + } + + #[test] + fn unused_allowance_goes_back_to_the_budget() { + let limit = RateLimit::default(); + limit.set(20_000); + let started = Instant::now(); + let grant = limit.take(4096); + limit.give_back(grant); + assert_eq!(limit.take(4096), grant); + assert!(started.elapsed() < TICK); + } + + #[test] + fn lifting_a_limit_frees_a_take_that_is_waiting() { + let limit = RateLimit::default(); + limit.set(100); + assert_eq!(limit.take(1000), 10); + std::thread::scope(|s| { + let waiting = s.spawn(|| limit.take(1000)); + std::thread::sleep(Duration::from_millis(10)); + limit.set(0); + assert_eq!(waiting.join().unwrap(), 1000); + }); + } +} diff --git a/soulseek-rs-lib/src/utils/semaphore.rs b/soulseek-rs-lib/src/utils/semaphore.rs new file mode 100644 index 0000000..0508551 --- /dev/null +++ b/soulseek-rs-lib/src/utils/semaphore.rs @@ -0,0 +1,73 @@ +use std::sync::{Arc, Condvar, Mutex, PoisonError}; + +pub struct Semaphore { + permits: Mutex, + available: Condvar, +} + +pub struct Permit(Arc); + +impl Semaphore { + #[must_use] + pub const fn new(permits: usize) -> Self { + Self { + permits: Mutex::new(permits), + available: Condvar::new(), + } + } + + #[must_use] + pub fn acquire(self: &Arc) -> Permit { + let mut permits = + self.permits.lock().unwrap_or_else(PoisonError::into_inner); + while *permits == 0 { + permits = self + .available + .wait(permits) + .unwrap_or_else(PoisonError::into_inner); + } + *permits -= 1; + Permit(Arc::clone(self)) + } +} + +impl Drop for Permit { + fn drop(&mut self) { + let mut permits = self + .0 + .permits + .lock() + .unwrap_or_else(PoisonError::into_inner); + *permits += 1; + self.0.available.notify_one(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn permits_bound_concurrent_holders() { + let semaphore = Arc::new(Semaphore::new(2)); + let first = semaphore.acquire(); + let _second = semaphore.acquire(); + + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _third = semaphore.acquire(); + tx.send(()).unwrap(); + }); + + assert!( + rx.recv_timeout(Duration::from_millis(100)).is_err(), + "a third acquire must block while both permits are held" + ); + drop(first); + assert!( + rx.recv_timeout(Duration::from_secs(2)).is_ok(), + "releasing a permit must wake the waiter" + ); + } +} diff --git a/soulseek-rs-lib/src/utils/zlib.rs b/soulseek-rs-lib/src/utils/zlib.rs new file mode 100644 index 0000000..bace9ae --- /dev/null +++ b/soulseek-rs-lib/src/utils/zlib.rs @@ -0,0 +1,762 @@ +//https://www.rfc-editor.org/rfc/rfc1950 + +struct BitReader { + mem: Vec, + pos: usize, + b: u8, + numbits: i32, +} + +impl BitReader { + const fn new(mem: Vec) -> Self { + Self { + mem, + pos: 0, + b: 0, + numbits: 0, + } + } + + fn read_byte(&mut self) -> std::result::Result { + self.numbits = 0; // discard unread bits + if self.pos >= self.mem.len() { + return Err("End of data".to_string()); + } + let b = self.mem[self.pos]; + self.pos += 1; + Ok(b) + } + + fn read_bit(&mut self) -> std::result::Result { + if self.numbits <= 0 { + self.b = self.read_byte()?; + self.numbits = 8; + } + self.numbits -= 1; + // shift bit out of byte + let bit = self.b & 1; + self.b >>= 1; + Ok(bit) + } + + fn read_bits(&mut self, n: usize) -> std::result::Result { + let mut o = 0u32; + for i in 0..n { + o |= u32::from(self.read_bit()?) << i; + } + Ok(o) + } + + fn read_bytes(&mut self, n: usize) -> std::result::Result { + // read bytes as an integer in little-endian + let mut o = 0u32; + for i in 0..n { + o |= u32::from(self.read_byte()?) << (8 * i); + } + Ok(o) + } +} + +use crate::error::{Result, SoulseekRs}; + +/// The most a peer payload may inflate to unless the caller says otherwise. +/// +/// Nicotine+ passes this ceiling to its decompressor for a search or folder +/// reply, and a larger one only for a browse listing. +pub const MAX_INFLATED: usize = 128 * 1024 * 1024; + +/// The ceiling for a whole browse listing, which a large sharer legitimately +/// fills. The same number Nicotine+ allows. +pub const MAX_INFLATED_LISTING: usize = 2 * 1024 * 1024 * 1024; + +/// What a payload that outgrows its ceiling is refused with. +const TOO_BIG: &str = "compressed payload expands past its limit"; + +/// Inflate a zlib stream, checking its adler32 trailer. +/// +/// Bounded by [`MAX_INFLATED`]; use [`inflate_limited`] for a payload with a +/// different ceiling. +pub fn inflate(input: &[u8]) -> Result> { + inflate_limited(input, MAX_INFLATED) +} + +/// Inflate a zlib stream, refusing to grow the output past `max_out`. +/// +/// The ceiling is the point: deflate reaches roughly 1000:1, so a few +/// kilobytes from a peer we have never met can otherwise expand into +/// gigabytes and take the process with it. +pub fn inflate_limited(input: &[u8], max_out: usize) -> Result> { + let mut r = BitReader::new(input.to_vec()); + let cmf = r.read_byte()?; + let cm = cmf & 15; // Compression method + if cm != 8 { + // only CM=8 is supported + return Err(SoulseekRs::CompressionError("invalid CM".to_string())); + } + let cinfo = (cmf >> 4) & 15; // Compression info + if cinfo > 7 { + return Err(SoulseekRs::CompressionError("invalid CINFO".to_string())); + } + let flg = r.read_byte()?; + if !(u32::from(cmf) * 256 + u32::from(flg)).is_multiple_of(31) { + return Err(SoulseekRs::CompressionError( + "CMF+FLG checksum failed".to_string(), + )); + } + let fdict = (flg >> 5) & 1; // preset dictionary? + if fdict != 0 { + return Err(SoulseekRs::CompressionError( + "preset dictionary not supported".to_string(), + )); + } + 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( + "adler32 checksum failed".to_string(), + )); + } + Ok(out) +} + +/// RFC 1950 adler32 checksum. +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + u32::from(byte)) % 65521; + b = (b + a) % 65521; + } + (b << 16) | a +} + +fn inflate_blocks( + r: &mut BitReader, + max_out: usize, +) -> std::result::Result, String> { + let mut bfinal = 0; + let mut out = Vec::new(); + while bfinal == 0 { + bfinal = r.read_bit()?; + let btype = r.read_bits(2)?; + match btype { + 0 => inflate_block_no_compression(r, &mut out, 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) +} + +fn inflate_block_no_compression( + r: &mut BitReader, + o: &mut Vec, + max_out: usize, +) -> std::result::Result<(), String> { + let len = r.read_bytes(2)?; + let _nlen = r.read_bytes(2)?; + if o.len().saturating_add(len as usize) > max_out { + return Err(TOO_BIG.to_string()); + } + for _ in 0..len { + o.push(r.read_byte()?); + } + Ok(()) +} + +#[derive(Clone)] +struct Node { + symbol: Option, + left: Option>, + right: Option>, +} + +impl Node { + const fn new() -> Self { + Self { + symbol: None, + left: None, + right: None, + } + } +} + +struct HuffmanTree { + root: Node, +} + +impl HuffmanTree { + const fn new() -> Self { + Self { root: Node::new() } + } + + fn insert(&mut self, codeword: u32, n: usize, symbol: u32) { + // Insert an entry into the tree mapping `codeword` of len `n` to `symbol` + let mut node = &mut self.root; + for i in (0..n).rev() { + let b = (codeword >> i) & 1; + let child = if b != 0 { + &mut node.right + } else { + &mut node.left + }; + node = child.get_or_insert_with(|| Box::new(Node::new())); + } + node.symbol = Some(symbol); + } +} + +fn decode_symbol( + r: &mut BitReader, + t: &HuffmanTree, +) -> std::result::Result { + let mut node = &t.root; + while node.left.is_some() || node.right.is_some() { + let b = r.read_bit()?; + let next = if b != 0 { &node.right } else { &node.left }; + // A degenerate/incomplete Huffman code (attacker-controlled) can point + // at a missing child; error out instead of unwrap-panicking. + node = next + .as_ref() + .ok_or_else(|| "Invalid Huffman code".to_string())?; + } + node.symbol.ok_or_else(|| "No symbol found".to_string()) +} + +const LENGTH_EXTRA_BITS: [usize; 29] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, + 5, 5, 5, 0, +]; +const LENGTH_BASE: [u32; 29] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, + 67, 83, 99, 115, 131, 163, 195, 227, 258, +]; +const DISTANCE_EXTRA_BITS: [usize; 30] = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, + 11, 11, 12, 12, 13, 13, +]; +const DISTANCE_BASE: [u32; 30] = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, + 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, +]; + +fn inflate_block_data( + r: &mut BitReader, + literal_length_tree: &HuffmanTree, + distance_tree: &HuffmanTree, + out: &mut Vec, + max_out: usize, +) -> std::result::Result<(), String> { + loop { + // Checked per symbol, not per block: a single block can emit output + // without bound, which is exactly how a bomb is built. + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } + let sym = decode_symbol(r, literal_length_tree)?; + if sym <= 255 { + // Literal byte + out.push(sym as u8); + } else if sym == 256 { + // End of block + return Ok(()); + } else { + // pair + let sym_idx = (sym - 257) as usize; + if sym_idx >= LENGTH_EXTRA_BITS.len() { + return Err("Invalid length symbol".to_string()); + } + let length = + r.read_bits(LENGTH_EXTRA_BITS[sym_idx])? + LENGTH_BASE[sym_idx]; + let dist_sym = decode_symbol(r, distance_tree)?; + if dist_sym as usize >= DISTANCE_EXTRA_BITS.len() { + return Err("Invalid distance symbol".to_string()); + } + let dist = r.read_bits(DISTANCE_EXTRA_BITS[dist_sym as usize])? + + DISTANCE_BASE[dist_sym as usize]; + if dist as usize > out.len() { + return Err("Distance too large".to_string()); + } + for _ in 0..length { + let idx = out.len() - dist as usize; + let byte = out[idx]; + out.push(byte); + } + } + } +} + +fn bl_list_to_tree(bl: &[usize], alphabet: &[u32]) -> HuffmanTree { + let max_bits = *bl.iter().max().unwrap_or(&0); + let mut bl_count = vec![0; max_bits + 1]; + for &bitlen in bl { + if bitlen != 0 { + bl_count[bitlen] += 1; + } + } + + let mut next_code = vec![0; max_bits + 1]; + for bits in 2..=max_bits { + next_code[bits] = (next_code[bits - 1] + bl_count[bits - 1]) << 1; + } + + let mut t = HuffmanTree::new(); + for (i, &bitlen) in bl.iter().enumerate() { + if bitlen != 0 && i < alphabet.len() { + t.insert(next_code[bitlen], bitlen, alphabet[i]); + next_code[bitlen] += 1; + } + } + t +} + +const CODE_LENGTH_CODES_ORDER: [usize; 19] = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; + +fn decode_trees( + r: &mut BitReader, +) -> std::result::Result<(HuffmanTree, HuffmanTree), String> { + // The number of literal/length codes + let hlit = r.read_bits(5)? + 257; + + // The number of distance codes + let hdist = r.read_bits(5)? + 1; + + // The number of code length codes + let hclen = r.read_bits(4)? + 4; + + // Read code lengths for the code length alphabet + let mut code_length_tree_bl = vec![0; 19]; + for i in 0..hclen as usize { + code_length_tree_bl[CODE_LENGTH_CODES_ORDER[i]] = + r.read_bits(3)? as usize; + } + + // Construct code length tree + let code_length_alphabet: Vec = (0..19).collect(); + let code_length_tree = + bl_list_to_tree(&code_length_tree_bl, &code_length_alphabet); + + // Read literal/length + distance code length list + let mut bl = Vec::new(); + while bl.len() < (hlit + hdist) as usize { + let sym = decode_symbol(r, &code_length_tree)?; + if sym <= 15 { + // literal value + bl.push(sym as usize); + } else if sym == 16 { + // copy the previous code length 3..6 times. + // the next 2 bits indicate repeat length ( 0 = 3, ..., 3 = 6 ) + if bl.is_empty() { + return Err("No previous code length".to_string()); + } + let prev_code_length = bl[bl.len() - 1]; + let repeat_length = r.read_bits(2)? + 3; + for _ in 0..repeat_length { + bl.push(prev_code_length); + } + } else if sym == 17 { + // repeat code length 0 for 3..10 times. (3 bits of length) + let repeat_length = r.read_bits(3)? + 3; + bl.resize(bl.len() + repeat_length as usize, 0); + } else if sym == 18 { + // repeat code length 0 for 11..138 times. (7 bits of length) + let repeat_length = r.read_bits(7)? + 11; + bl.resize(bl.len() + repeat_length as usize, 0); + } else { + return Err("Invalid symbol".to_string()); + } + } + + // Construct trees + let literal_length_alphabet: Vec = (0..286).collect(); + let literal_length_tree = + bl_list_to_tree(&bl[..hlit as usize], &literal_length_alphabet); + + let distance_alphabet: Vec = (0..30).collect(); + let distance_tree = + bl_list_to_tree(&bl[hlit as usize..], &distance_alphabet); + + Ok((literal_length_tree, distance_tree)) +} + +fn inflate_block_dynamic( + r: &mut BitReader, + o: &mut Vec, + max_out: usize, +) -> std::result::Result<(), String> { + let (literal_length_tree, distance_tree) = decode_trees(r)?; + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) +} + +fn inflate_block_fixed( + r: &mut BitReader, + o: &mut Vec, + max_out: usize, +) -> std::result::Result<(), String> { + let mut bl = Vec::new(); + bl.extend(vec![8; 144]); // 0-143: 8 bits + bl.extend(vec![9; 112]); // 144-255: 9 bits + bl.extend(vec![7; 24]); // 256-279: 7 bits + bl.extend(vec![8; 8]); // 280-287: 8 bits + + let literal_length_alphabet: Vec = (0..286).collect(); + let literal_length_tree = bl_list_to_tree(&bl, &literal_length_alphabet); + + let bl_dist = vec![5; 30]; + let distance_alphabet: Vec = (0..30).collect(); + let distance_tree = bl_list_to_tree(&bl_dist, &distance_alphabet); + + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) +} + +struct BitWriter { + out: Vec, + bit: u32, + held: u32, +} + +impl BitWriter { + fn new() -> Self { + Self { + out: vec![0x78, 0x01], + bit: 0, + held: 0, + } + } + + /// Extra bits, least significant first. + fn bits(&mut self, value: u32, count: u32) { + self.held |= value << self.bit; + self.bit += count; + while self.bit >= 8 { + self.out.push((self.held & 0xFF) as u8); + self.held >>= 8; + self.bit -= 8; + } + } + + /// A Huffman code, most significant bit first. + fn code(&mut self, value: u32, count: u32) { + for i in (0..count).rev() { + self.bits((value >> i) & 1, 1); + } + } + + fn finish(mut self, trailer: u32) -> Vec { + if self.bit > 0 { + self.out.push((self.held & 0xFF) as u8); + } + self.out.extend_from_slice(&trailer.to_be_bytes()); + self.out + } +} + +const MIN_MATCH: usize = 3; +const MAX_MATCH: usize = 258; +const WINDOW: usize = 32_768; +const MATCH_TRIES: usize = 128; +/// A match this long is good enough to stop walking the chain. +const NICE_MATCH: usize = 64; + +/// Hash table size for `len` bytes: a search reply of a few hundred bytes +/// must not zero 256 KiB of table, a listing of megabytes wants all of it. +fn hash_bits(len: usize) -> u32 { + len.max(2).next_power_of_two().trailing_zeros().clamp(8, 15) +} + +/// The fixed Huffman code for a literal or length symbol (RFC 1951 3.2.6). +fn symbol(w: &mut BitWriter, symbol: usize) { + let symbol = symbol as u32; + match symbol { + 0..=143 => w.code(0x30 + symbol, 8), + 144..=255 => w.code(0x190 + symbol - 144, 9), + 256..=279 => w.code(symbol - 256, 7), + _ => w.code(0xC0 + symbol - 280, 8), + } +} + +fn emit_match(w: &mut BitWriter, length: usize, distance: usize) { + let l = LENGTH_BASE + .iter() + .rposition(|&base| base as usize <= length) + .unwrap_or(0); + symbol(w, 257 + l); + w.bits(length as u32 - LENGTH_BASE[l], LENGTH_EXTRA_BITS[l] as u32); + let d = DISTANCE_BASE + .iter() + .rposition(|&base| base as usize <= distance) + .unwrap_or(0); + w.code(d as u32, 5); + w.bits( + distance as u32 - DISTANCE_BASE[d], + DISTANCE_EXTRA_BITS[d] as u32, + ); +} + +fn hash(data: &[u8], at: usize, bits: u32) -> usize { + let three = u32::from(data[at]) << 16 + | u32::from(data[at + 1]) << 8 + | u32::from(data[at + 2]); + (three.wrapping_mul(2_654_435_761) >> (32 - bits)) as usize +} + +/// Chain the three bytes at `at` into the match finder. +fn remember(head: &mut [usize], prev: &mut [usize], data: &[u8], at: usize) { + if at + MIN_MATCH > data.len() { + return; + } + let h = hash(data, at, head.len().trailing_zeros()); + prev[at] = head[h]; + head[h] = at; +} + +/// Deflate `data` into a zlib stream. +/// +/// One fixed-Huffman block over an LZ77 hash-chain match finder, which is +/// what shrinks a listing's repeated path prefixes. ponytail: fixed codes, not +/// dynamic; listings compress 5-10x either way and this is a tenth of the code. +#[must_use] +pub fn deflate(data: &[u8]) -> Vec { + let mut w = BitWriter::new(); + w.bits(1, 1); // final block + w.bits(1, 2); // fixed Huffman codes + let bits = hash_bits(data.len()); + let mut head = vec![usize::MAX; 1 << bits]; + let mut prev = vec![usize::MAX; data.len()]; + let mut i = 0; + while i < data.len() { + let mut best = (0, 0); + if i + MIN_MATCH <= data.len() { + let h = hash(data, i, bits); + let mut candidate = head[h]; + let mut tries = MATCH_TRIES; + let max = MAX_MATCH.min(data.len() - i); + while candidate != usize::MAX + && tries > 0 + && i - candidate <= WINDOW + { + let len = (0..max) + .take_while(|&k| data[candidate + k] == data[i + k]) + .count(); + if len > best.0 { + best = (len, i - candidate); + if len == max || len >= NICE_MATCH { + break; + } + } + candidate = prev[candidate]; + tries -= 1; + } + } + remember(&mut head, &mut prev, data, i); + let (length, distance) = best; + if length < MIN_MATCH { + symbol(&mut w, usize::from(data[i])); + i += 1; + continue; + } + emit_match(&mut w, length, distance); + for at in i + 1..i + length { + remember(&mut head, &mut prev, data, at); + } + i += length; + } + symbol(&mut w, 256); // end of block + w.finish(adler32(data)) +} + +#[cfg(test)] +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"); + let last = stream.len() - 1; + stream[last] ^= 0xFF; + assert!(inflate(&stream).is_err(), "a bad checksum must not pass"); + } + + #[test] + fn a_reference_stream_inflates() { + let stream = [ + 120, 156, 203, 72, 205, 201, 201, 87, 8, 207, 47, 202, 73, 1, 0, + 25, 107, 4, 61, + ]; + assert_eq!(inflate(&stream).unwrap(), b"hello World"); + } + + fn listing() -> Vec { + (0..3000) + .flat_map(|n| { + format!("Artist - Album - {n:04} - Track Title.flac") + .into_bytes() + }) + .collect() + } + + #[test] + fn every_shape_round_trips() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut noise = |n: usize| -> Vec { + (0..n) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 24) as u8 + }) + .collect() + }; + let cases = [ + Vec::new(), + vec![0], + vec![0x41; 258], + vec![0x41; 70_000], + vec![0x5A; 100_000], + b"the same words the same words the same words".to_vec(), + listing(), + noise(1), + noise(4096), + noise(70_000), + ]; + for data in cases { + assert_eq!( + inflate(&deflate(&data)).unwrap(), + data, + "{}", + data.len() + ); + } + } + + #[test] + fn a_repetitive_listing_shrinks() { + let data = listing(); + assert!(deflate(&data).len() * 4 < data.len()); + } + + #[test] + fn decode_symbol_on_degenerate_tree_errors_instead_of_panicking() { + // A malformed Huffman table from an untrusted peer can produce a node + // with only one child. Inserting codeword "0" gives the root a left + // child but no right child; decoding a '1' bit must error rather than + // unwrap-panic. + let mut tree = HuffmanTree::new(); + tree.insert(0, 1, 42); + let mut reader = BitReader::new(vec![0b0000_0001]); // first bit = 1 + assert!(decode_symbol(&mut reader, &tree).is_err()); + } + + #[test] + fn test_bitreader_read_bits() { + let data = vec![0b11010010, 0b10110101]; + let mut reader = BitReader::new(data); + + assert_eq!(reader.read_bits(3).unwrap(), 0b010); // First 3 bits: 010 + assert_eq!(reader.read_bits(5).unwrap(), 0b11010); // Next 5 bits: 11010 + } + + #[test] + fn test_bitreader_read_bytes() { + let data = vec![0x12, 0x34, 0x56, 0x78]; + let mut reader = BitReader::new(data); + + assert_eq!(reader.read_bytes(2).unwrap(), 0x3412); // Little-endian: 0x3412 + assert_eq!(reader.read_bytes(2).unwrap(), 0x7856); // Little-endian: 0x7856 + } + + #[test] + fn test_extract_header_fail_to_short() { + let data = vec![120]; // Too short + let mut reader = BitReader::new(data); + let result = reader.read_byte(); + assert!(result.is_ok()); + let result = reader.read_byte(); + assert!(result.is_err()); + } + + #[test] + fn a_dynamic_huffman_stream_from_a_real_peer_inflates() { + let data = vec![ + 120, 156, 99, 103, 96, 96, 72, 201, 79, 201, 76, 79, 204, 203, 213, + 158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135, 162, 156, 148, 194, + 188, 152, 228, 252, 220, 130, 156, 212, 146, 212, 24, 231, 196, + 188, 228, 204, 252, 188, 212, 226, 152, 144, 162, 210, 226, 226, + 212, 28, 93, 67, 75, 115, 75, 93, 119, 160, 144, 130, 91, 126, 145, + 66, 72, 70, 170, 66, 120, 106, 106, 118, 106, 94, 138, 174, 161, + 89, 82, 102, 137, 174, 137, 137, 142, 161, 119, 70, 149, 94, 90, + 78, 98, 114, 203, 175, 243, 32, 163, 193, 128, 25, 100, 7, 16, 23, + 0, 9, 22, 32, 237, 178, 134, 129, 129, 21, 72, 11, 128, 196, 243, + 176, 217, 29, 156, 153, 151, 158, 147, 90, 12, 54, 95, 193, 216, + 84, 193, 200, 192, 200, 36, 198, 45, 181, 168, 40, 53, 57, 91, 193, + 37, 177, 60, 79, 71, 193, 55, 177, 44, 181, 40, 19, 200, 13, 78, + 76, 42, 74, 85, 80, 83, 240, 75, 45, 7, 10, 38, 103, 100, 2, 221, + 167, 139, 238, 66, 5, 13, 144, 17, 154, 96, 167, 173, 228, 215, 98, + 68, 119, 218, 74, 6, 76, 167, 49, 60, 153, 202, 200, 160, 199, 128, + 0, 0, 161, 99, 76, 142, + ]; + let expect = vec![ + 7, 0, 0, 0, 100, 111, 100, 105, 103, 97, 110, 109, 43, 148, 52, 2, + 0, 0, 0, 1, 79, 0, 0, 0, 64, 64, 114, 108, 100, 113, 110, 92, 99, + 111, 109, 112, 108, 101, 116, 101, 92, 67, 97, 110, 99, 105, 111, + 110, 101, 115, 92, 84, 114, 117, 115, 115, 101, 108, 45, 49, 57, + 55, 57, 45, 71, 111, 110, 101, 32, 70, 111, 114, 32, 84, 104, 101, + 32, 87, 101, 101, 107, 101, 110, 100, 45, 49, 54, 98, 105, 116, 45, + 52, 52, 44, 49, 75, 104, 122, 46, 102, 108, 97, 99, 132, 250, 207, + 2, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 1, 0, 0, 0, 112, 1, 0, 0, 4, + 0, 0, 0, 68, 172, 0, 0, 5, 0, 0, 0, 16, 0, 0, 0, 1, 110, 0, 0, 0, + 64, 64, 114, 108, 100, 113, 110, 92, 99, 111, 109, 112, 108, 101, + 116, 101, 92, 83, 105, 110, 103, 108, 101, 115, 32, 87, 101, 101, + 107, 32, 51, 53, 32, 50, 48, 50, 52, 92, 70, 101, 114, 114, 101, + 99, 107, 32, 68, 97, 119, 110, 44, 32, 77, 97, 118, 101, 114, 105, + 99, 107, 32, 83, 97, 98, 114, 101, 32, 38, 32, 78, 101, 119, 32, + 77, 97, 99, 104, 105, 110, 101, 32, 45, 32, 70, 111, 114, 32, 84, + 104, 101, 32, 87, 101, 101, 107, 101, 110, 100, 32, 40, 50, 48, 50, + 52, 41, 46, 102, 108, 97, 99, 169, 15, 42, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 3, 0, 0, 0, 1, 0, 0, 0, 169, 0, 0, 0, 4, 0, 0, 0, 68, 172, 0, 0, + 5, 0, 0, 0, 16, 0, 0, 0, 0, 228, 149, 1, 0, 46, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + ] + .to_vec(); + let result = inflate(&data); + + assert!(result.is_ok()); + let decompressed = result.unwrap(); + + assert_eq!(decompressed, expect); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs new file mode 100644 index 0000000..1e1147c --- /dev/null +++ b/soulseek-rs-lib/tests/e2e.rs @@ -0,0 +1,5756 @@ +//! End-to-end integration tests against a real Soulseek server (soulfind). +//! +//! These tests are SERVER-OPTIONAL so `cargo test` stays green everywhere: +//! * If `SOULSEEK_TEST_SERVER=host:port` is set, they connect to it. +//! * Else if a soulfind binary is found (via `SOULFIND_BIN`, or a sibling +//! `../soulfind/bin/soulfind` checkout), they spawn it on an ephemeral port +//! with a throwaway database. +//! * Else they SKIP with a printed notice (the test still passes). +//! +//! To run them against a locally built soulfind: +//! ```sh +//! SOULFIND_BIN=/path/to/soulfind \ +//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture +//! ``` +//! +//! The harness sticks to `std` and the library's own public API. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::{Duration, Instant}; + +use soulseek_rs::message::Message; +use soulseek_rs::message::distributed; +use soulseek_rs::message::server::MessageFactory; +use soulseek_rs::{ + Client, ClientSettings, ClientVersion, ConnectionType, DownloadStatus, + PeerAddress, SessionLoss, UploadStatus, +}; + +/// Only one test at a time may drive a server. +/// +/// Each of these tests runs its own soulfind plus real clients and, for the +/// queue tests, peer sockets held open on purpose. Letting the harness start a +/// handful of those at once starves them, and a login that goes unanswered +/// fails a test for a reason that has nothing to do with the code under test: +/// seen on CI as `login: Timeout` in two download tests that the change under +/// test never went near. The CLI suite already carries this gate for the same +/// reason. +static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A Soulseek server to test against: either a child soulfind process we +/// spawned, or an external server referenced by `SOULSEEK_TEST_SERVER`. +struct TestServer { + host: String, + port: u16, + child: Option, + db: Option, + _gate: std::sync::MutexGuard<'static, ()>, +} + +impl TestServer { + /// Resolve a server to test against, or `None` if the suite should skip. + fn resolve() -> Option { + // A test that panicked while holding the gate poisoned it; the lock + // guards nothing but scheduling, so take it back and carry on. + let gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") { + let (host, port) = addr.rsplit_once(':')?; + let port = port.parse().ok()?; + wait_until_listening(host, port, Duration::from_secs(2))?; + return Some(Self { + host: host.to_string(), + port, + child: None, + db: None, + _gate: gate, + }); + } + Self::spawn(gate) + } + + /// Spawn a local soulfind on an ephemeral port with a throwaway database. + /// + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a child that dies is retried on a + /// fresh port rather than read as a missing server. + fn spawn(gate: std::sync::MutexGuard<'static, ()>) -> Option { + let bin = soulfind_binary()?; + for _ in 0..3 { + let port = free_port()?; + let db = + std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); + let _ = std::fs::remove_file(&db); + + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && child.try_wait().ok().flatten().is_none() + { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + _gate: gate, + }); + } + std::thread::sleep(Duration::from_millis(100)); + } + // Never came up (e.g. a toolchain/SQLite issue) or died. + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_file(&db); + } + None + } + + fn settings(&self, username: &str, password: &str) -> ClientSettings { + ClientSettings { + username: username.to_string(), + password: password.to_string(), + server_address: PeerAddress::new(self.host.clone(), self.port), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + } + } + + /// Settings with the peer listener enabled on `port`, exercising the + /// `SetWaitPort` step of the post-login handshake. + fn listening_settings( + &self, + username: &str, + password: &str, + port: u16, + ) -> ClientSettings { + ClientSettings { + enable_listen: true, + listen_port: port, + ..self.settings(username, password) + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(db) = self.db.as_ref() { + let _ = std::fs::remove_file(db); + } + } +} + +fn soulfind_binary() -> Option { + if let Ok(p) = std::env::var("SOULFIND_BIN") { + let p = PathBuf::from(p); + return p.exists().then_some(p); + } + // Fall back to a `soulfind/bin/soulfind` checkout somewhere above this + // workspace (the workspace may be nested, e.g. under `.../src/rust/`). + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .map(|dir| dir.join("soulfind/bin/soulfind")) + .find(|candidate| candidate.exists()) +} + +fn free_port() -> Option { + let listener = std::net::TcpListener::bind("127.0.0.1:0").ok()?; + listener.local_addr().ok().map(|addr| addr.port()) +} + +fn wait_until_listening( + host: &str, + port: u16, + timeout: Duration, +) -> Option<()> { + let addr = format!("{host}:{port}"); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Ok(mut addrs) = addr.to_socket_addrs() + && let Some(sa) = addrs.next() + && TcpStream::connect_timeout(&sa, Duration::from_millis(200)) + .is_ok() + { + return Some(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +/// Resolve a test server or return early with a skip notice. +/// +/// Set `SOULSEEK_E2E_REQUIRED=1` (as CI does) to turn a missing server into a +/// hard failure instead of a silent skip, so the suite genuinely runs there. +macro_rules! server_or_skip { + () => { + match TestServer::resolve() { + Some(server) => server, + None => { + let required = std::env::var("SOULSEEK_E2E_REQUIRED") + .is_ok_and(|v| v != "0" && !v.is_empty()); + assert!( + !required, + "SOULSEEK_E2E_REQUIRED is set but no soulfind server could \ + be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)" + ); + println!( + "e2e skipped: no soulfind server (set SOULFIND_BIN or \ + SOULSEEK_TEST_SERVER to run)" + ); + return; + } + } + }; +} + +#[test] +fn connect_and_login_succeed() { + let server = server_or_skip!(); + let mut client = + Client::with_settings(server.settings("e2e_user", "e2e_pw")); + client.connect().expect("connect to soulfind"); + let logged_in = client.login().expect("login to soulfind"); + assert!(logged_in, "login should succeed (soulfind auto-registers)"); +} + +#[test] +fn search_round_trips_without_error() { + let server = server_or_skip!(); + let mut client = + Client::with_settings(server.settings("e2e_search", "e2e_pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + // A fresh server has no shared files, so the search simply has to + // round-trip without error and leave an (empty) queryable result set. + let query = "nonexistent query xyzzy"; + let _ = client.search(query, Duration::from_secs(2)); + assert!(client.get_search_results(query).is_empty()); + + // The search must also be tracked in client state under its key, proving + // the request was actually registered and not silently dropped. + assert!( + client.get_all_searches().contains_key(query), + "the issued search should be registered under its query key" + ); +} + +#[test] +fn a_search_is_forwarded_to_a_connected_peer() { + let server = server_or_skip!(); + + // The server distributes each search to other connected users, so a second + // client exercises the *incoming* FileSearch handler. We can't observe that + // handler's state directly, but if it mishandled the forwarded bytes it + // would take the receiver's session down — so we prove the receiver is + // still alive afterwards by round-tripping its own search. + let mut searcher = + Client::with_settings(server.settings("e2e_searcher", "pw")); + let mut receiver = + Client::with_settings(server.settings("e2e_receiver", "pw")); + searcher.connect().expect("searcher connect"); + receiver.connect().expect("receiver connect"); + assert!(searcher.login().expect("searcher login")); + assert!(receiver.login().expect("receiver login")); + + let _ = searcher.search("some shared song", Duration::from_secs(2)); + + // Receiver stays functional after handling the forwarded search. + let probe = "receiver still alive"; + let _ = receiver.search(probe, Duration::from_secs(2)); + assert!(receiver.get_all_searches().contains_key(probe)); +} + +#[test] +fn a_private_message_is_delivered_between_users() { + let server = server_or_skip!(); + + // Two logged-in users, one messages the other through the server. + let mut alice = + Client::with_settings(server.settings("e2e_alice_pm", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_bob_pm", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let body = "hello bob, this is alice"; + alice + .send_private_message("e2e_bob_pm", body) + .expect("send private message"); + + // Delivery is asynchronous; poll Bob's inbox until the message arrives. + let deadline = Instant::now() + Duration::from_secs(5); + let mut received = Vec::new(); + while Instant::now() < deadline { + received.extend(bob.take_private_messages()); + if !received.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let message = received + .iter() + .find(|m| m.message() == body) + .expect("bob should receive alice's message"); + assert_eq!(message.username(), "e2e_alice_pm"); +} + +#[test] +fn a_chat_room_message_is_delivered_between_users() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_chat"; + let mut alice = + Client::with_settings(server.settings("e2e_alice_room", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_bob_room", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins room"); + bob.join_room(room).expect("bob joins room"); + + // Each is in once the server has sent them the member list; a line said + // before bob is in never reaches him. + let deadline = Instant::now() + Duration::from_secs(5); + while alice.room_members(room).is_empty() + || bob.room_members(room).is_empty() + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } + let _ = alice.take_room_events(); + let _ = bob.take_room_events(); + + let body = "hello room, this is alice"; + alice.say_in_room(room, body).expect("alice says in room"); + + // Delivery is asynchronous; poll Bob's room events until the message lands. + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = None; + while Instant::now() < deadline { + for event in bob.take_room_events() { + if let RoomEvent::Message { + room: r, + username, + message, + } = event + && r == room + && message == body + { + got = Some(username); + } + } + if got.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + assert_eq!( + got.as_deref(), + Some("e2e_alice_room"), + "bob should receive alice's room message" + ); +} + +#[test] +fn the_room_list_includes_a_joined_room() { + let server = server_or_skip!(); + + let room = "e2e_room_listed"; + let mut alice = + Client::with_settings(server.settings("e2e_alice_list", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins room"); + + // Once a user is in the room the server should advertise it in RoomList. + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline { + alice.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + if alice.room_list().iter().any(|r| r.name == room) { + listed = true; + break; + } + } + assert!(listed, "the joined room should appear in the room list"); +} + +#[test] +fn login_succeeds_with_listener_enabled() { + let server = server_or_skip!(); + + // With the listener enabled the client also sends SetWaitPort during the + // post-login handshake; the server must accept it and keep the session. + let port = free_port().expect("free listener port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_listener", + "pw", + port, + )); + client.connect().expect("connect with listener"); + assert!( + client.login().expect("login with listener enabled"), + "the handshake including SetWaitPort should still log in" + ); +} + +#[test] +fn wrong_password_is_rejected() { + let server = server_or_skip!(); + + // soulfind auto-registers a username on first login and binds it to that + // password, so a second login with a different password must be rejected. + let user = "e2e_pw_user"; + let mut first = + Client::with_settings(server.settings(user, "correct-horse")); + first.connect().expect("connect (registering login)"); + assert!( + first.login().expect("first login"), + "registration should log in" + ); + drop(first); + + let mut second = + Client::with_settings(server.settings(user, "wrong-password")); + second.connect().expect("connect (wrong password)"); + // The server may signal rejection either as a non-success status or as an + // authentication error; both mean "not logged in", only `Ok(true)` accepts. + assert!( + !matches!(second.login(), Ok(true)), + "a mismatched password must not be accepted" + ); +} + +#[test] +fn registered_username_can_relogin_with_same_password() { + let server = server_or_skip!(); + + // The TUI's stored-credentials flow depends on this pair of server + // behaviors: a fresh username is registered by simply logging in, and a + // later session (a "restart") with the same credentials is accepted. + let user = "e2e_relogin_user"; + let mut first = Client::with_settings(server.settings(user, "pw-123")); + first.connect().expect("connect (registering login)"); + assert!( + first.login().expect("registering login"), + "a fresh username should be auto-registered" + ); + drop(first); + + let mut second = Client::with_settings(server.settings(user, "pw-123")); + second.connect().expect("connect (relogin)"); + assert!( + second.login().expect("relogin"), + "the same credentials must log in again after a restart" + ); +} + +// --------------------------------------------------------------------------- +// Peer-to-peer download coverage. +// +// To exercise the download path on its own, the peer is a minimal in-process +// "mock uploader" rather than a second client. It speaks +// the peer protocol using the library's own public `Message` wire format. It +// drives the same path a real peer would after a search: a `P` control +// connection to our listener, a `QueueUpload` → `TransferRequest` → +// `TransferResponse` negotiation, then an `F` connection that streams the bytes. +// --------------------------------------------------------------------------- + +/// Configuration for a one-shot mock uploader. +struct MockUpload { + listen_addr: String, + peer_username: String, + filename: String, + content: Vec, + token: u32, + ready: Sender<()>, + token_delay: Duration, +} + +/// Build a `PeerInit` (peer code 1) frame: `[len][1][username][conn_type][token]`. +fn peer_init_bytes(username: &str, conn_type: &str, token: u32) -> Vec { + let mut m = Message::new(); + m.write_int8(1) + .write_string(username) + .write_string(conn_type) + .write_int32(token); + m.get_buffer() +} + +/// Read one length-prefixed peer message (`[len:4 LE][payload]`) from a blocking +/// stream. The returned `Message` keeps the length prefix, so `get_message_code` +/// and `set_pointer(8)` behave exactly as they do inside the library. +fn read_framed(stream: &mut TcpStream) -> std::io::Result { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload)?; + let mut data = len_buf.to_vec(); + data.extend_from_slice(&payload); + Ok(Message::new_with_data(data)) +} + +fn connect_retry(addr: &str, timeout: Duration) -> std::io::Result { + let deadline = Instant::now() + timeout; + loop { + match TcpStream::connect(addr) { + Ok(stream) => return Ok(stream), + Err(e) => { + if Instant::now() >= deadline { + return Err(e); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } +} + +fn open_control_and_await_queue( + cfg: &MockUpload, +) -> std::io::Result { + let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(10)))?; + p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?; + // The client answers a user-info request through the peer it registered + // for this connection, so the answer means a download queues on it + // rather than dialling a peer the server has never heard of. + p.write_all( + &soulseek_rs::message::peer::build_user_info_request().get_buffer(), + )?; + p.flush()?; + expect_code(&mut p, 16, Duration::from_secs(10))?; + let _ = cfg.ready.send(()); + + loop { + let msg = read_framed(&mut p)?; + if msg.get_message_code() == 43 { + break; + } + } + Ok(p) +} + +/// Build a TransferRequest (peer code 40). The size becomes the download's +/// expected size, so it must match the content. +fn transfer_request(cfg: &MockUpload) -> Message { + let mut tr = Message::new(); + tr.write_int32(40) + .write_int32(1) // direction: upload + .write_int32(cfg.token) + .write_string(&cfg.filename) + .write_int64(cfg.content.len() as u64); + tr +} + +fn run_mock_uploader(cfg: &MockUpload) -> std::io::Result { + let mut p = open_control_and_await_queue(cfg)?; + + p.write_all(&transfer_request(cfg).get_buffer())?; + p.flush()?; + + // Wait for the downloader to allow it (TransferResponse, peer code 41). + loop { + let msg = read_framed(&mut p)?; + if msg.get_message_code() == 41 { + break; + } + } + + serve_file_over_f( + &cfg.listen_addr, + &cfg.peer_username, + cfg.token, + &cfg.content, + cfg.token_delay, + ) +} + +fn run_mock_uploader_f_first(cfg: &MockUpload) -> std::io::Result { + let mut p = open_control_and_await_queue(cfg)?; + + let listen_addr = cfg.listen_addr.clone(); + let peer_username = cfg.peer_username.clone(); + let token = cfg.token; + let content = cfg.content.clone(); + let token_delay = cfg.token_delay; + let f = std::thread::spawn(move || { + serve_file_over_f( + &listen_addr, + &peer_username, + token, + &content, + token_delay, + ) + }); + + std::thread::sleep(Duration::from_millis(300)); + + p.write_all(&transfer_request(cfg).get_buffer())?; + p.flush()?; + + f.join() + .map_err(|_| std::io::Error::other("F connection thread panicked"))? +} + +fn run_mock_slow_uploader( + cfg: &MockUpload, + stall_after: Option, +) -> std::io::Result { + let mut p = open_control_and_await_queue(cfg)?; + p.write_all(&transfer_request(cfg).get_buffer())?; + p.flush()?; + expect_code(&mut p, 41, Duration::from_secs(10))?; + + let mut f = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?; + f.set_read_timeout(Some(Duration::from_secs(10)))?; + f.set_write_timeout(Some(Duration::from_secs(5)))?; + let mut init = peer_init_bytes(&cfg.peer_username, "F", cfg.token); + init.extend_from_slice(&cfg.token.to_le_bytes()); + f.write_all(&init)?; + let mut start = [0u8; 8]; + f.read_exact(&mut start)?; + + let mut sent = 0; + for (n, chunk) in cfg.content.chunks(4096).enumerate() { + if stall_after == Some(n) { + f.set_read_timeout(Some(Duration::from_secs(15)))?; + match f.read(&mut [0u8; 1]) { + Ok(0) => { + return Err(std::io::Error::other( + "the downloader hung up", + )); + } + Ok(_) => {} + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + ) => {} + Err(e) => return Err(e), + } + } + f.write_all(chunk)?; + f.flush()?; + sent += chunk.len(); + std::thread::sleep(Duration::from_millis(20)); + } + Ok(sent) +} + +fn run_mock_uploader_offering_after( + cfg: &MockUpload, + queued: &Sender<()>, + go: &Receiver<()>, +) -> std::io::Result<(bool, String)> { + let mut p = open_control_and_await_queue(cfg)?; + let _ = queued.send(()); + go.recv() + .map_err(|_| std::io::Error::other("the test never said go"))?; + + p.write_all(&transfer_request(cfg).get_buffer())?; + p.flush()?; + let mut msg = expect_code(&mut p, 41, Duration::from_secs(10))?; + msg.set_pointer(8); + let _token = msg.read_int32(); + let allowed = msg.read_bool(); + let reason = if allowed { + String::new() + } else { + msg.read_string() + }; + Ok((allowed, reason)) +} + +fn run_refusing_uploader( + cfg: &MockUpload, + refusal: &Message, +) -> std::io::Result<()> { + let mut p = open_control_and_await_queue(cfg)?; + + p.write_all(&refusal.get_buffer())?; + p.flush()?; + std::thread::sleep(Duration::from_millis(500)); + Ok(()) +} + +/// Open an F (file transfer) connection to `downloader_addr` and stream +/// `content`; the downloader sends an 8-byte START_DOWNLOAD offset before we +/// send the bytes. +/// +/// Like a real peer, only the bytes past that offset are sent. Returns the +/// offset the downloader asked for. +fn serve_file_over_f( + downloader_addr: &str, + username: &str, + token: u32, + content: &[u8], + token_delay: Duration, +) -> std::io::Result { + let mut f = connect_retry(downloader_addr, Duration::from_secs(5))?; + f.set_read_timeout(Some(Duration::from_secs(10)))?; + if token_delay.is_zero() { + let mut init = peer_init_bytes(username, "F", token); + init.extend_from_slice(&token.to_le_bytes()); + f.write_all(&init)?; + } else { + f.write_all(&peer_init_bytes(username, "F", token))?; + std::thread::sleep(token_delay); + f.write_all(&token.to_le_bytes())?; + } + f.flush()?; + + let mut start = [0u8; 8]; + f.read_exact(&mut start)?; + let offset = u64::from_le_bytes(start); + f.write_all(&content[offset as usize..])?; + f.flush()?; + + // Keep the connection open briefly so the reader drains everything. + std::thread::sleep(Duration::from_millis(500)); + Ok(offset) +} + +/// Log a raw socket in to the server and drain up to the login response, +/// returning the still-open stream (the user stays online while it lives). +fn login_raw( + server_addr: &str, + username: &str, + password: &str, +) -> std::io::Result { + let mut srv = connect_retry(server_addr, Duration::from_secs(5))?; + srv.set_read_timeout(Some(Duration::from_secs(10)))?; + srv.write_all( + &MessageFactory::build_login_message( + username, + password, + ClientVersion::default(), + ) + .get_buffer(), + )?; + srv.flush()?; + loop { + let msg = read_framed(&mut srv)?; + if msg.get_message_code() == 1 { + break; + } + } + Ok(srv) +} + +/// Read framed messages from `stream` until one has `code`, or the deadline +/// passes. Returns the matching message. +fn read_until_code( + stream: &mut TcpStream, + code: u32, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match read_framed(stream) { + Ok(msg) if msg.get_message_code() == code => return Some(msg), + Ok(_) => {} + Err(_) => return None, + } + } + None +} + +/// Like [`read_until_code`] but turns a miss into an `io::Error` for `?`. +fn expect_code( + stream: &mut TcpStream, + code: u32, + timeout: Duration, +) -> std::io::Result { + read_until_code(stream, code, timeout).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("timed out waiting for message code {code}"), + ) + }) +} + +/// Wait for a download to reach `Completed`, watching both its status channel +/// and the client's download list (whichever reports first). Returns false if it +/// fails, times out, or the deadline passes. +fn wait_for_completion( + client: &Client, + status_rx: &Receiver, + timeout: Duration, +) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match status_rx.recv_timeout(Duration::from_millis(500)) { + Ok(DownloadStatus::Completed) => return true, + Ok(DownloadStatus::Failed(_) | DownloadStatus::TimedOut) => { + return false; + } + _ => {} + } + if client + .get_all_downloads() + .iter() + .any(|d| matches!(d.status, DownloadStatus::Completed)) + { + return true; + } + } + false +} + +fn unique_download_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "soulseek-e2e-dl-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::create_dir_all(&dir); + dir +} + +fn file_downloads_over_p_and_f( + peer_username: &str, + token_delay: Duration, + run_uploader: fn(&MockUpload) -> std::io::Result, +) { + let server = server_or_skip!(); + + // Downloader: connected to the server with its peer listener enabled. + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + &format!("{peer_username}_dl"), + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "mock_song.mp3"; + let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let size = content.len() as u64; + let token = 424_242_u32; + let download_dir = unique_download_dir(); + + // Start the mock uploader; it signals once its P connection is established. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: peer_username.to_string(), + filename: filename.to_string(), + content: content.clone(), + token, + ready: ready_tx, + token_delay, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_uploader(&cfg) { + eprintln!("[mock uploader] {e}"); + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let (_download, status_rx) = client + .download( + filename.to_string(), + peer_username.to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&client, &status_rx, Duration::from_secs(20)); + let _ = uploader.join(); + + assert!(completed, "the download should reach Completed"); + + let written = std::fs::read(download_dir.join(filename)) + .expect("downloaded file should exist"); + assert_eq!(written, content, "downloaded bytes should match the source"); + + let _ = std::fs::remove_dir_all(&download_dir); +} + +#[test] +fn a_file_downloads_from_a_peer_over_p_and_f_connections() { + file_downloads_over_p_and_f( + "e2e_mockpeer", + Duration::ZERO, + run_mock_uploader, + ); +} + +#[test] +fn a_file_downloads_when_the_transfer_token_arrives_late() { + file_downloads_over_p_and_f( + "e2e_latepeer", + Duration::from_millis(300), + run_mock_uploader, + ); +} + +#[test] +fn a_download_completes_when_the_f_connection_beats_token_registration() { + file_downloads_over_p_and_f( + "e2e_earlypeer", + Duration::ZERO, + run_mock_uploader_f_first, + ); +} + +#[test] +fn a_download_speed_limit_paces_a_transfer_until_it_is_lifted() { + let server = server_or_skip!(); + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_cappedpeer_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + client.set_download_speed_limit(64 * 1024); + + let filename = "capped.mp3"; + let content: Vec = + (0..1024 * 1024u32).map(|i| (i % 251) as u8).collect(); + let download_dir = unique_download_dir(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_cappedpeer".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 434_343, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_uploader(&cfg) { + eprintln!("[mock uploader] {e}"); + } + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let started = Instant::now(); + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_cappedpeer".to_string(), + content.len() as u64, + download_dir.display().to_string(), + ) + .expect("start download"); + let in_progress = || { + client + .get_all_downloads() + .iter() + .any(|d| matches!(d.status, DownloadStatus::InProgress { .. })) + }; + assert!(wait_for(in_progress), "the download should start"); + std::thread::sleep(Duration::from_secs(1)); + assert!( + in_progress(), + "1 MiB at 64 KiB/s must still be arriving a second in" + ); + + client.set_download_speed_limit(0); + assert!( + wait_for_completion(&client, &status_rx, Duration::from_secs(20)), + "the download should reach Completed" + ); + let elapsed = started.elapsed(); + let _ = uploader.join(); + assert!( + elapsed < Duration::from_secs(8), + "capped for the whole file this takes 16s; lifted it took {elapsed:?}" + ); + assert_eq!( + std::fs::read(download_dir.join(filename)).expect("downloaded file"), + content + ); + let _ = std::fs::remove_dir_all(&download_dir); +} + +fn cancel_mid_transfer(peer_username: &str, stall_after: Option) { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + &format!("{peer_username}_dl"), + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "cancel_me.mp3"; + let content: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let download_dir = unique_download_dir(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: peer_username.to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 424_250_u32, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = + std::thread::spawn(move || run_mock_slow_uploader(&cfg, stall_after)); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let (_download, status_rx) = client + .download( + filename.to_string(), + peer_username.to_string(), + content.len() as u64, + download_dir.display().to_string(), + ) + .expect("start download"); + + let part = download_dir.join(format!("{filename}.part")); + assert!( + wait_for(|| part.exists()), + "the transfer should be streaming" + ); + if stall_after.is_some() { + std::thread::sleep(Duration::from_secs(2)); + } + let cancelled_at = Instant::now(); + assert!(client.cancel_download(peer_username, filename)); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut cancelled = false; + while Instant::now() < deadline { + match status_rx.recv_timeout(Duration::from_millis(500)) { + Ok(DownloadStatus::Cancelled) => { + cancelled = true; + break; + } + Ok(DownloadStatus::Completed | DownloadStatus::Failed(_)) => break, + _ => {} + } + } + assert!(cancelled, "the status channel should report the cancel"); + assert!(wait_for(|| !part.exists()), "the partial file should go"); + assert!(!download_dir.join(filename).exists()); + assert!(client.get_all_downloads().iter().any(|d| { + d.filename == filename && matches!(d.status, DownloadStatus::Cancelled) + })); + + let outcome = uploader.join().expect("mock uploader thread"); + assert!( + outcome.is_err(), + "the uploader should see its connection drop, got {outcome:?}" + ); + assert!( + cancelled_at.elapsed() < Duration::from_secs(8), + "the socket should be released within seconds, not at the read \ + deadline" + ); + assert!( + status_rx.try_recv().is_err(), + "nothing follows Cancelled on the channel" + ); + let _ = std::fs::remove_dir_all(&download_dir); +} + +#[test] +fn a_cancelled_download_stops_and_drops_its_partial_file() { + cancel_mid_transfer("e2e_cancel_peer", None); +} + +#[test] +fn a_cancelled_stalled_download_is_released_promptly() { + cancel_mid_transfer("e2e_stalled_peer", Some(2)); +} + +#[test] +fn a_cancelled_queued_download_declines_the_peers_offer() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_cancel_queued_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "never_starts.mp3"; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (queued_tx, queued_rx) = std::sync::mpsc::channel(); + let (go_tx, go_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_cancel_queued_peer".to_string(), + filename: filename.to_string(), + content: vec![0; 10], + token: 424_251_u32, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = std::thread::spawn(move || { + run_mock_uploader_offering_after(&cfg, &queued_tx, &go_rx) + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_cancel_queued_peer".to_string(), + 10, + download_dir.display().to_string(), + ) + .expect("start download"); + queued_rx + .recv_timeout(Duration::from_secs(10)) + .expect("the peer should receive our queue request"); + + assert!(client.cancel_download("e2e_cancel_queued_peer", filename)); + assert!(matches!( + status_rx.recv_timeout(Duration::from_secs(2)), + Ok(DownloadStatus::Cancelled) + )); + + go_tx.send(()).expect("mock uploader waiting"); + let (allowed, reason) = uploader + .join() + .expect("mock uploader thread") + .expect("the peer should get an answer to its offer"); + assert!( + !allowed, + "a cancelled download must not accept the transfer" + ); + assert_eq!(reason, "Cancelled"); + assert!(client.get_all_downloads().iter().any(|d| { + d.filename == filename && matches!(d.status, DownloadStatus::Cancelled) + })); + assert!(!download_dir.join(format!("{filename}.part")).exists()); + let _ = std::fs::remove_dir_all(&download_dir); +} + +fn refusal_fails_the_download_quickly( + peer_username: &str, + build_refusal: impl FnOnce(&str) -> Message, +) { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + &format!("{peer_username}_dl"), + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "gone_song.mp3"; + let refusal = build_refusal(filename); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: peer_username.to_string(), + filename: filename.to_string(), + content: Vec::new(), + token: 0, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_refusing_uploader(&cfg, &refusal) { + eprintln!("[mock refusing uploader] {e}"); + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = client + .download( + filename.to_string(), + peer_username.to_string(), + 0, + download_dir.display().to_string(), + ) + .expect("start download"); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut failed = false; + while Instant::now() < deadline { + match status_rx.recv_timeout(Duration::from_millis(500)) { + Ok(DownloadStatus::Failed(_)) => { + failed = true; + break; + } + Ok(DownloadStatus::Completed) => { + panic!("the refused download must not complete") + } + _ => {} + } + } + let _ = uploader.join(); + let _ = std::fs::remove_dir_all(&download_dir); + + assert!(failed, "the peer's refusal should fail the download fast"); +} + +#[test] +fn an_upload_failed_reply_fails_the_download_quickly() { + refusal_fails_the_download_quickly("e2e_failing_peer", |filename| { + let mut refusal = Message::new(); + refusal.write_int32(46).write_string(filename); + refusal + }); +} + +#[test] +fn an_upload_denied_reply_fails_the_download_quickly() { + refusal_fails_the_download_quickly("e2e_denying_peer", |filename| { + let mut refusal = Message::new(); + refusal + .write_int32(50) + .write_string(filename) + .write_string("File not shared."); + refusal + }); +} + +#[test] +fn an_interrupted_download_resumes_from_its_partial_file() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_resume_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "resumed_file.bin"; + let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let size = content.len() as u64; + let already_have = 800usize; + let download_dir = unique_download_dir(); + + // Stand in for a transfer that died after 800 of 2000 bytes. + std::fs::write( + download_dir.join(format!("{filename}.part")), + &content[..already_have], + ) + .expect("seed the partial file"); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_resume_peer".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 424_243_u32, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let (offset_tx, offset_rx) = std::sync::mpsc::channel(); + let uploader = std::thread::spawn(move || match run_mock_uploader(&cfg) { + Ok(offset) => { + let _ = offset_tx.send(offset); + } + Err(e) => eprintln!("[mock uploader] {e}"), + }); + + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_resume_peer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&client, &status_rx, Duration::from_secs(20)); + let _ = uploader.join(); + + assert!(completed, "the resumed download should reach Completed"); + assert_eq!( + offset_rx.recv_timeout(Duration::from_secs(1)), + Ok(already_have as u64), + "the peer should be asked to start past the bytes we already had" + ); + assert_eq!( + std::fs::read(download_dir.join(filename)) + .expect("downloaded file should exist"), + content, + "the resumed prefix and the fetched tail should form the whole file" + ); + assert!( + !download_dir.join(format!("{filename}.part")).exists(), + "the .part should be renamed away once complete" + ); + + let _ = std::fs::remove_dir_all(&download_dir); +} + +#[test] +fn soulfind_brokers_connect_to_peer_between_users() { + // The firewalled download mode relies on the server forwarding a + // ConnectToPeer request to the target user. Confirm soulfind does this: a + // requester (with a wait port so the server knows its address) asks the + // server to broker a connection to an online target, and the target must + // receive a forwarded ConnectToPeer (server code 18) naming the requester. + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + let mut target = + login_raw(&addr, "e2e_broker_target", "pw").expect("target login"); + + let mut requester = + login_raw(&addr, "e2e_broker_req", "pw").expect("requester login"); + let req_port = free_port().expect("free port"); + requester + .write_all( + &MessageFactory::build_set_wait_port_message(req_port).get_buffer(), + ) + .expect("set wait port"); + requester.flush().expect("flush wait port"); + + let token = 987_654_u32; + requester + .write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_broker_target", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("send ConnectToPeer"); + requester.flush().expect("flush ConnectToPeer"); + + let mut brokered = read_until_code(&mut target, 18, Duration::from_secs(5)) + .expect("target should receive a brokered ConnectToPeer"); + brokered.set_pointer(8); + assert_eq!( + brokered.read_string(), + "e2e_broker_req", + "the brokered message should name the requester" + ); +} + +// --------------------------------------------------------------------------- +// Direct-connection download: the client initiates the peer connection. +// +// Here the mock is a server-registered peer. It logs in to soulfind and +// advertises a listen port (SetWaitPort), so when our client asks the server +// for the peer's address (GetPeerAddress) and dials it directly, the mock +// accepts that inbound P connection and then serves the file. This exercises +// the outbound PeerInit handshake and the auto-connecting download() path. +// --------------------------------------------------------------------------- + +struct MockDirectUpload { + server_addr: String, + username: String, + password: String, + listen_port: u16, + downloader_listen_addr: String, + downloader_username: String, + filename: String, + content: Vec, + token: u32, + ready: Sender<()>, + /// Hang up the control connection before streaming, the way clients that + /// drop idle peer sockets do while a transfer runs on its own connection. + close_control_first: bool, +} + +fn run_mock_direct_peer(cfg: &MockDirectUpload) -> std::io::Result<()> { + // 1. Log in to the server so it knows this user is online. + let mut srv = connect_retry(&cfg.server_addr, Duration::from_secs(5))?; + srv.set_read_timeout(Some(Duration::from_secs(10)))?; + srv.write_all( + &MessageFactory::build_login_message( + &cfg.username, + &cfg.password, + ClientVersion::default(), + ) + .get_buffer(), + )?; + srv.flush()?; + loop { + let msg = read_framed(&mut srv)?; + if msg.get_message_code() == 1 { + break; // login response + } + } + + // 2. Bind the peer listener, then advertise its port so the server can hand + // our address to the downloader. Bind all interfaces: soulfind reports + // the host's LAN address (not 127.0.0.1), and the downloader dials that. + let listener = std::net::TcpListener::bind(("0.0.0.0", cfg.listen_port))?; + srv.write_all( + &MessageFactory::build_set_wait_port_message(cfg.listen_port) + .get_buffer(), + )?; + srv.flush()?; + let _ = cfg.ready.send(()); + + // 3. Accept the downloader's inbound P (control) connection and validate its + // PeerInit (peer code 1, int8 code so the fields start at offset 5). The + // accept is bounded so a misrouted connection fails the test instead of + // hanging it. + listener.set_nonblocking(true)?; + let accept_deadline = Instant::now() + Duration::from_secs(15); + let (mut p, _addr) = loop { + match listener.accept() { + Ok(pair) => break pair, + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "no inbound P connection from the downloader", + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(e), + } + }; + p.set_nonblocking(false)?; + p.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut init = read_framed(&mut p)?; + assert_eq!(init.get_init_code(), 1, "expected inbound PeerInit"); + init.set_pointer(5); + assert_eq!(init.read_string(), cfg.downloader_username, "PeerInit user"); + assert_eq!(init.read_string(), "P", "PeerInit connection type"); + + // 4. Negotiate the transfer exactly as in the passive path. + loop { + let mut msg = read_framed(&mut p)?; + if msg.get_message_code() == 43 { + msg.set_pointer(8); + let _requested = msg.read_string(); + break; + } + } + let mut tr = Message::new(); + tr.write_int32(40) + .write_int32(1) + .write_int32(cfg.token) + .write_string(&cfg.filename) + .write_int64(cfg.content.len() as u64); + p.write_all(&tr.get_buffer())?; + p.flush()?; + loop { + let msg = read_framed(&mut p)?; + if msg.get_message_code() == 41 { + break; // TransferResponse + } + } + + if cfg.close_control_first { + drop(p); + // Give the downloader time to notice the hangup before the bytes move, + // so the test measures what it does about it rather than outrunning it. + std::thread::sleep(Duration::from_secs(2)); + } + + // 5. Stream the bytes over an F connection to the downloader's listener. + // `srv` stays in scope so the peer remains online for the whole transfer. + serve_file_over_f( + &cfg.downloader_listen_addr, + &cfg.username, + cfg.token, + &cfg.content, + Duration::ZERO, + ) + .map(|_| ()) +} + +#[test] +fn a_file_downloads_from_a_peer_via_direct_connection() { + let server = server_or_skip!(); + + // Downloader with a listener enabled (needed for the F leg). + let client_port = free_port().expect("free client listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_direct_dl", + "pw", + client_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mock_port = free_port().expect("free mock listen port"); + let filename = "direct_song.mp3"; + let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let size = content.len() as u64; + let token = 515_151_u32; + let download_dir = unique_download_dir(); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockDirectUpload { + server_addr: format!("{}:{}", server.host, server.port), + username: "e2e_directpeer".to_string(), + password: "pw".to_string(), + listen_port: mock_port, + downloader_listen_addr: format!("127.0.0.1:{client_port}"), + downloader_username: "e2e_direct_dl".to_string(), + filename: filename.to_string(), + content: content.clone(), + token, + ready: ready_tx, + close_control_first: false, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_direct_peer(&cfg) { + eprintln!("[mock direct peer] {e}"); + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(10)) + .expect("mock direct peer ready"); + // Let the server finish processing SetWaitPort before we resolve the address. + std::thread::sleep(Duration::from_secs(1)); + + // Target the plain username so download() takes the direct-connect path. + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_directpeer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&client, &status_rx, Duration::from_secs(20)); + let _ = uploader.join(); + + assert!(completed, "the direct download should reach Completed"); + + let written = std::fs::read(download_dir.join(filename)) + .expect("downloaded file should exist"); + assert_eq!(written, content, "downloaded bytes should match the source"); + + let _ = std::fs::remove_dir_all(&download_dir); +} + +// --------------------------------------------------------------------------- +// Firewalled download: the peer is unreachable directly, so the connection is +// brokered through the server. +// +// The mock advertises a port nobody listens on, so the downloader's direct +// connection fails. The client then asks the server to broker the connection +// (ConnectToPeer); the mock, reading its server stream, sees the forwarded +// request and connects back to the downloader with a PierceFirewall. That +// pierced connection becomes the P control channel, and the file is served. +// --------------------------------------------------------------------------- + +struct MockFirewalledUpload { + server_addr: String, + username: String, + password: String, + bogus_port: u16, + downloader_listen_addr: String, + filename: String, + content: Vec, + token: u32, + ready: Sender<()>, +} + +fn run_mock_firewalled_peer(cfg: &MockFirewalledUpload) -> std::io::Result<()> { + // 1. Log in and advertise a port that nobody listens on, so the downloader's + // direct connection is refused and it falls back to server brokering. + let mut srv = login_raw(&cfg.server_addr, &cfg.username, &cfg.password)?; + srv.write_all( + &MessageFactory::build_set_wait_port_message(cfg.bogus_port) + .get_buffer(), + )?; + srv.flush()?; + let _ = cfg.ready.send(()); + + // 2. Wait for the server-brokered ConnectToPeer (server code 18) and read + // the correlation token (after username, type, ip and port). + let mut ctp = expect_code(&mut srv, 18, Duration::from_secs(15))?; + ctp.set_pointer(8); + let _who = ctp.read_string(); + let _conn_type = ctp.read_string(); + let _ip = ctp.read_int32(); + let _port = ctp.read_int32(); + let connect_token = ctp.read_int32(); + + // 3. Connect back to the downloader with a PierceFirewall (peer code 0); + // this becomes the P control connection. + let mut p = + connect_retry(&cfg.downloader_listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(10)))?; + p.write_all( + &MessageFactory::build_pierce_firewall_message(connect_token) + .get_buffer(), + )?; + p.flush()?; + + // 4. Negotiate the transfer over the pierced connection. + let _queue = expect_code(&mut p, 43, Duration::from_secs(10))?; + let mut tr = Message::new(); + tr.write_int32(40) + .write_int32(1) + .write_int32(cfg.token) + .write_string(&cfg.filename) + .write_int64(cfg.content.len() as u64); + p.write_all(&tr.get_buffer())?; + p.flush()?; + let _response = expect_code(&mut p, 41, Duration::from_secs(10))?; + + // 5. Serve the bytes over an F connection to the downloader's listener. + serve_file_over_f( + &cfg.downloader_listen_addr, + &cfg.username, + cfg.token, + &cfg.content, + Duration::ZERO, + ) + .map(|_| ()) +} + +#[test] +fn a_file_downloads_from_a_firewalled_peer_via_server_broker() { + let server = server_or_skip!(); + + let client_port = free_port().expect("free client listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_fw_dl", + "pw", + client_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let bogus_port = free_port().expect("bogus port"); // advertised, unlistened + let filename = "firewalled_song.mp3"; + let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let size = content.len() as u64; + let token = 606_060_u32; + let download_dir = unique_download_dir(); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockFirewalledUpload { + server_addr: format!("{}:{}", server.host, server.port), + username: "e2e_fw_peer".to_string(), + password: "pw".to_string(), + bogus_port, + downloader_listen_addr: format!("127.0.0.1:{client_port}"), + filename: filename.to_string(), + content: content.clone(), + token, + ready: ready_tx, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_firewalled_peer(&cfg) { + eprintln!("[mock firewalled peer] {e}"); + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(10)) + .expect("mock firewalled peer ready"); + std::thread::sleep(Duration::from_secs(1)); + + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_fw_peer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&client, &status_rx, Duration::from_secs(25)); + let _ = uploader.join(); + + assert!(completed, "the firewalled download should reach Completed"); + + let written = std::fs::read(download_dir.join(filename)) + .expect("downloaded file should exist"); + assert_eq!(written, content, "downloaded bytes should match the source"); + + let _ = std::fs::remove_dir_all(&download_dir); +} + +// --------------------------------------------------------------------------- +// Two real clients: one shares a file, the other searches for it and downloads +// it — the entire search + connect + upload/download stack, no mock peer. +// --------------------------------------------------------------------------- + +/// A sharer serving `share_dir` and a searcher, both logged in and listening, +/// after a beat for soulfind to register both SetWaitPorts. +fn sharer_and_searcher( + server: &TestServer, + share_dir: &std::path::Path, + sharer: &str, + searcher: &str, +) -> (Client, Client) { + let mut sharing = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings( + sharer, + "pw", + free_port().expect("sharer port"), + ) + }); + sharing.connect().expect("sharer connect"); + assert!(sharing.login().expect("sharer login")); + + let mut searching = Client::with_settings(server.listening_settings( + searcher, + "pw", + free_port().expect("searcher port"), + )); + searching.connect().expect("searcher connect"); + assert!(searching.login().expect("searcher login")); + + std::thread::sleep(Duration::from_secs(1)); + (sharing, searching) +} + +/// `sharer`'s answer to `query`, once it reaches `searcher`. +fn reply_from( + searcher: &Client, + query: &str, + sharer: &str, +) -> Option { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let reply = searcher + .get_search_results(query) + .into_iter() + .find(|result| result.username == sharer); + if reply.is_some() || Instant::now() >= deadline { + return reply; + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +#[test] +fn two_real_clients_search_and_download() { + let server = server_or_skip!(); + + // Sharer with one distinctively named file. + let share_dir = unique_download_dir(); + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_probe_xyzzy.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let (sharer, leecher) = + sharer_and_searcher(&server, &share_dir, "e2e_sharer", "e2e_leecher"); + + let query = "xyzzy"; + let _ = leecher.search(query, Duration::from_secs(3)); + + let (result_path, size) = reply_from(&leecher, query, "e2e_sharer") + .and_then(|reply| { + reply + .files + .into_iter() + .find(|file| file.name.contains("e2e_probe_xyzzy")) + }) + .map(|file| (file.name, file.size)) + .expect("leecher should find the sharer's file"); + assert_eq!(size, content.len() as u64); + + // Download it from the sharer. + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + result_path.clone(), + "e2e_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&leecher, &status_rx, Duration::from_secs(25)); + assert!(completed, "the download should complete"); + + // The virtual path is backslash-separated; the saved file uses the basename. + let basename = result_path.rsplit(['\\', '/']).next().unwrap(); + let written = std::fs::read(download_dir.join(basename)) + .expect("downloaded file should exist"); + assert_eq!(written, content, "downloaded bytes should match the source"); + + // The uploader side tracked the transfer and saw it complete. + let deadline = Instant::now() + Duration::from_secs(5); + let mut uploads = sharer.uploads(); + while Instant::now() < deadline + && !uploads + .iter() + .any(|u| u.status == soulseek_rs::types::UploadStatus::Completed) + { + std::thread::sleep(Duration::from_millis(200)); + uploads = sharer.uploads(); + } + let upload = uploads + .iter() + .find(|u| u.status == soulseek_rs::types::UploadStatus::Completed) + .expect("uploader should record a completed upload"); + assert_eq!(upload.username, "e2e_leecher"); + assert_eq!(upload.bytes_sent, size); + + // The next reply advertises what that upload measured. + let _ = leecher.search(query, Duration::from_secs(1)); + let reply = reply_from(&leecher, query, "e2e_sharer") + .expect("the sharer answers again"); + assert!( + reply.speed > 0, + "a finished upload sets the advertised speed" + ); + + // The server hears about it too (SendUploadSpeed), which is where every + // other client's `user` lookup and the server's own ranking read it. + let deadline = Instant::now() + Duration::from_secs(10); + let mut recorded = 0; + while Instant::now() < deadline && recorded == 0 { + leecher + .request_user_info("e2e_sharer") + .expect("ask about the sharer"); + std::thread::sleep(Duration::from_millis(250)); + recorded = leecher + .user_info("e2e_sharer") + .and_then(|info| info.stats) + .map_or(0, |stats| stats.average_speed); + } + assert!(recorded > 0, "the server should record the upload's speed"); + + let _ = std::fs::remove_dir_all(share_dir); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_runtime_share_update_is_visible_to_browsers() { + let server = server_or_skip!(); + + // The sharer starts sharing NOTHING, then adds a directory at runtime + // (what the TUI settings screen does). + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(server.listening_settings( + "e2e_reshare", + "pw", + sharer_port, + )); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + assert!(sharer.shared_directories().is_empty()); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("new")).unwrap(); + std::fs::write(share_dir.join("new").join("late.mp3"), b"yyyy").unwrap(); + sharer + .set_shared_directories(vec![share_dir.display().to_string()]) + .expect("runtime share update"); + assert_eq!(sharer.shared_directories().len(), 1); + + let browser_port = free_port().expect("browser port"); + let mut browser = Client::with_settings(server.listening_settings( + "e2e_reshare_browser", + "pw", + browser_port, + )); + browser.connect().expect("browser connect"); + assert!(browser.login().expect("browser login")); + + std::thread::sleep(Duration::from_secs(1)); + browser.browse_user("e2e_reshare").expect("browse request"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline { + if let Some(result) = browser.take_browse_result("e2e_reshare") { + listing = Some(result); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let directories = listing.expect("browser should receive the listing"); + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|f| f.name == "late.mp3")), + "the listing should include the file shared at runtime" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn browse_a_peers_shared_files() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("track.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_browsee", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let browser_port = free_port().expect("browser port"); + let mut browser = Client::with_settings(server.listening_settings( + "e2e_browser", + "pw", + browser_port, + )); + browser.connect().expect("browser connect"); + assert!(browser.login().expect("browser login")); + + std::thread::sleep(Duration::from_secs(1)); + + browser.browse_user("e2e_browsee").expect("browse request"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline { + if let Some(result) = browser.take_browse_result("e2e_browsee") { + listing = Some(result); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let directories = listing.expect("browser should receive the listing"); + assert!( + directories + .iter() + .any(|d| { d.files.iter().any(|f| f.name == "track.flac") }), + "the listing should include the shared file" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Browsing a peer that is NOT listening exercises the server-brokered +// (firewalled) path: the direct dial fails, the client asks the server to +// broker, and the peer connects back to our listener. This is the path that +// matters on the real network, where most peers are firewalled. The browser +// MUST be listening so the peer can connect back. +#[test] +fn browse_a_firewalled_peer_via_broker() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("hidden.flac"), b"xxxx") + .unwrap(); + + // Sharer does NOT listen (firewalled): the browser's direct dial will fail, + // forcing the server-brokered connect-back. + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_fw_sharer", "pw") + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // Browser listens so the firewalled peer can connect back to it. + let browser_port = free_port().expect("browser port"); + let mut browser = Client::with_settings(server.listening_settings( + "e2e_fw_browser", + "pw", + browser_port, + )); + browser.connect().expect("browser connect"); + assert!(browser.login().expect("browser login")); + + std::thread::sleep(Duration::from_secs(1)); + + browser + .browse_user("e2e_fw_sharer") + .expect("browse request"); + + // The brokered round-trip has more hops; give it a generous deadline. + let deadline = Instant::now() + Duration::from_secs(30); + let mut listing = None; + while Instant::now() < deadline { + if let Some(result) = browser.take_browse_result("e2e_fw_sharer") { + listing = Some(result); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let directories = + listing.expect("browser should receive the firewalled peer's listing"); + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|f| f.name == "hidden.flac")), + "the brokered listing should include the shared file" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +/// Browse `sharer_addr` exactly the way a third-party client (SoulseekQt, +/// Nicotine+) does: dial the peer's listener directly, announce with a +/// `PeerInit(P)`, ask for the share list (peer code 4) and read the +/// `SharedFileListResponse` (peer code 5) back off the same socket. +fn third_party_browse( + sharer_addr: &str, + username: &str, + read_delay: Duration, + timeout: Duration, +) -> std::io::Result> { + let mut p = connect_retry(sharer_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(timeout))?; + p.write_all(&peer_init_bytes(username, "P", 0))?; + p.write_all(&MessageFactory::build_get_share_file_list().get_buffer())?; + p.flush()?; + + // A real peer is not always ready to drain the socket the instant the + // response starts arriving; a slow reader must not cost us the listing. + std::thread::sleep(read_delay); + + let mut response = expect_code(&mut p, 5, timeout)?; + response.set_pointer(8); + Ok(soulseek_rs::message::peer::parse_shared_file_list( + &mut response, + )) +} + +// A third-party client browsing us over a direct connection — the scenario of +// running soulseek-rs and SoulseekQt side by side. The other two browse tests +// only prove soulseek-rs can talk to itself. +#[test] +fn a_third_party_client_browses_our_shares_directly() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("track.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_qt_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // The browsing client is a real logged-in user, as SoulseekQt would be. + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_qt_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_qt_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("third-party client should receive a SharedFileListResponse"); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|f| f.name == "track.flac")), + "the listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// A third-party client that cannot dial us directly browses us through the +// server broker: it asks the server to broker, we connect back with a +// PierceFirewall, and it browses over that connection. This is the path a +// second client on the same machine takes whenever the direct dial to our +// advertised (public) address does not come back to us. +#[test] +fn a_third_party_client_browses_our_shares_via_the_server_broker() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("brokered.flac"), b"xxxx") + .unwrap(); + + // The sharer does not listen at all, so the browse can only be brokered. + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_qt_brok_sharer", "pw") + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // The third-party client listens and advertises its port, then asks the + // server to broker a P connection from the sharer. + // + // Bind every interface, not just loopback: the server brokers back the + // address it observed for this client, which is the host's routable IP + // (172.17.0.2 under Docker, 10.x on a CI runner), not 127.0.0.1. A + // loopback-only listener refuses that dial and the browse never arrives. + let listener = + std::net::TcpListener::bind("0.0.0.0:0").expect("browser listener"); + let browser_port = listener.local_addr().unwrap().port(); + let server_addr = format!("{}:{}", server.host, server.port); + let mut srv = login_raw(&server_addr, "e2e_qt_brok_browser", "pw") + .expect("third-party client logs in"); + srv.write_all( + &MessageFactory::build_set_wait_port_message(browser_port).get_buffer(), + ) + .expect("set wait port"); + srv.flush().expect("flush wait port"); + std::thread::sleep(Duration::from_secs(1)); + + let token = 424_242_u32; + srv.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_qt_brok_sharer", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + srv.flush().expect("flush ConnectToPeer"); + + // The sharer must dial us back, quoting our correlation token. + listener.set_nonblocking(true).expect("non-blocking accept"); + let deadline = Instant::now() + Duration::from_secs(20); + let mut accepted = None; + while Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) => { + accepted = Some(stream); + break; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => panic!("accept failed: {e}"), + } + } + let mut p = accepted.expect("the sharer should connect back to us"); + p.set_nonblocking(false).expect("blocking peer socket"); + p.set_read_timeout(Some(Duration::from_secs(20))) + .expect("read timeout"); + + let mut pierce = read_framed(&mut p).expect("a PierceFirewall frame"); + assert_eq!( + pierce.get_init_code(), + 0, + "the brokered connect-back must start with a PierceFirewall" + ); + pierce.set_pointer(5); // length prefix (4) + int8 code (1) + assert_eq!( + pierce.read_int32(), + token, + "the PierceFirewall must quote the token we brokered with" + ); + + p.write_all(&MessageFactory::build_get_share_file_list().get_buffer()) + .expect("request the share list"); + p.flush().expect("flush share list request"); + + let mut response = expect_code(&mut p, 5, Duration::from_secs(20)) + .expect("a SharedFileListResponse over the brokered connection"); + response.set_pointer(8); + let directories = + soulseek_rs::message::peer::parse_shared_file_list(&mut response); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|f| f.name == "brokered.flac")), + "the brokered listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Real peers connect to our listener and go away again (a dropped dial, a port +// scan, a client that gave up). None of that may cost us the listener: the next +// client to come along must still be able to browse. +#[test] +fn a_stalled_peer_connection_does_not_wedge_the_listener() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("still.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_wedge_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let sharer_addr = format!("127.0.0.1:{sharer_port}"); + + // A peer that connects and hangs up without ever sending a PeerInit. + drop( + connect_retry(&sharer_addr, Duration::from_secs(5)) + .expect("dial the listener"), + ); + // A peer that connects and then just sits there, saying nothing. + let _silent = connect_retry(&sharer_addr, Duration::from_secs(5)) + .expect("dial the listener"); + + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_wedge_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &sharer_addr, + "e2e_wedge_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("the listener must still serve browse requests"); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|f| f.name == "still.flac")), + "the listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// The same direct browse against a share big enough that the response cannot +// fit in one socket buffer. This is the realistic case: a shared music library +// serialises to hundreds of KB, and the payload is zlib-STORED (not actually +// compressed), so the listing goes out at full size. +#[test] +fn a_third_party_client_browses_a_large_share() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir().join("big"); + let _ = std::fs::remove_dir_all(&share_dir); + std::fs::create_dir_all(&share_dir).unwrap(); + // ~4000 entries with long names: well over any socket buffer once framed. + let padding = "x".repeat(150); + for i in 0..4000 { + std::fs::write(share_dir.join(format!("{padding}-{i:05}.flac")), b"z") + .unwrap(); + } + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_qt_big_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_qt_big_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_qt_big_browser", + Duration::from_secs(1), + Duration::from_secs(20), + ) + .expect("third-party client should receive the full large listing"); + + let file_count: usize = directories.iter().map(|d| d.files.len()).sum(); + assert_eq!( + file_count, 4000, + "the whole listing must arrive, not just what fit in one socket buffer" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn the_server_reports_another_users_status_and_share_counts() { + let server = server_or_skip!(); + + // A sharer with a known number of files, so the statistics are checkable. + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + for name in ["one.flac", "two.flac"] { + std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap(); + } + let mut subject = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_info_subject", "pw") + }); + subject.connect().expect("subject connect"); + assert!(subject.login().expect("subject login")); + + let mut asker = + Client::with_settings(server.settings("e2e_info_asker", "pw")); + asker.connect().expect("asker connect"); + assert!(asker.login().expect("asker login")); + + asker + .request_user_info("e2e_info_subject") + .expect("ask about the subject"); + + // Status and statistics arrive as two separate replies. + let deadline = Instant::now() + Duration::from_secs(15); + let mut info = None; + while Instant::now() < deadline { + match asker.user_info("e2e_info_subject") { + Some(found) if found.is_complete() => { + info = Some(found); + break; + } + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + let info = info.expect("the server should answer with status and stats"); + + assert_eq!(info.username, "e2e_info_subject"); + let presence = info.presence.expect("presence should have arrived"); + assert!( + presence.status.is_reachable(), + "a logged-in user should not read as offline, got {}", + presence.status + ); + let stats = info.stats.expect("stats should have arrived"); + assert_eq!(stats.shared_files, 2, "the subject shares two files"); + assert!(stats.shared_folders >= 1, "and at least one folder"); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// --- upload slots and privilege recognition -------------------------------- +// +// Soulseek's rules ask an alternative client to *recognise* privileges. That is +// only observable if there is a queue to jump, so these tests hold the single +// upload slot open with a peer that queues a file and then never answers the +// TransferRequest — the same thing a stalled client does, and deterministic in +// a way that racing real 4 KB transfers is not. + +/// A raw peer that logs in, dials our listener, queues `filename`, and then +/// goes quiet. Returned so the caller can keep it alive: dropping the streams +/// would release the slot it is holding. +struct QueueingPeer { + _server: TcpStream, + peer: TcpStream, +} + +impl QueueingPeer { + /// Wait for the upload offer (peer code 40) and deliberately not answer it. + /// + /// Receiving the offer is the only positive proof that this peer was given a + /// slot — an unaccepted offer never reaches `Client::uploads()`, and "has no + /// place in the queue" is also true of a peer that never asked, so polling + /// for that would pass against a completely broken pump. + fn takes_a_slot(&mut self) -> bool { + self.takes_a_slot_within(Duration::from_secs(10)) + } + + fn takes_a_slot_within(&mut self, timeout: Duration) -> bool { + let _ = self.peer.set_read_timeout(Some(timeout)); + read_until_code(&mut self.peer, 40, timeout).is_some() + } +} + +fn queue_as( + server_addr: &str, + listen_addr: &str, + username: &str, + filename: &str, +) -> std::io::Result { + let server = login_raw(server_addr, username, "pw")?; + let mut peer = connect_retry(listen_addr, Duration::from_secs(5))?; + peer.set_read_timeout(Some(Duration::from_secs(10)))?; + peer.write_all(&peer_init_bytes(username, "P", 0))?; + let mut queue = Message::new(); + queue.write_int32(43).write_string(filename); + peer.write_all(&queue.get_buffer())?; + peer.flush()?; + Ok(QueueingPeer { + _server: server, + peer, + }) +} + +/// Wait for `check` to hold, polling the client's view of its own queue. +fn wait_for(mut check: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if check() { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false +} + +/// Where `username` sits in `sharer`'s upload queue, read the way the CLI reads +/// it — through `uploads()` — rather than through a point query kept alive only +/// for tests. +fn place_of(sharer: &Client, username: &str, filename: &str) -> Option { + sharer.uploads().into_iter().find_map(|upload| { + match (upload.status, upload.username == username) { + (UploadStatus::Queued(place), true) + if upload.filename == filename => + { + Some(place) + } + _ => None, + } + }) +} + +/// Set `column` ("privileges", or "admin" for the user whose private +/// messages to "server" are commands) on `username` in soulfind's own +/// database, good for a day: soulfind stores both as the unix timestamp +/// they expire at. +/// +/// `false` means it could not be done — an external server, or no `sqlite3` +/// on this machine — which the caller treats as a skip rather than a failure. +fn grant(server: &TestServer, username: &str, column: &str) -> bool { + let Some(db) = server.db.as_ref() else { + return false; + }; + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs() + 86_400); + Command::new("sqlite3") + .arg(db) + .arg(format!( + "UPDATE users SET {column} = {expiry} WHERE username = '{username}';" + )) + .status() + .is_ok_and(|status| status.success()) +} + +/// A share directory of its own, so the virtual paths a peer has to name are +/// predictable: soulfind-facing paths are `\`. +fn queue_share(label: &str, files: &[&str]) -> (PathBuf, String) { + // Keyed on the thread as well as the process, matching + // `unique_download_dir`: two tests are two threads, and a shared folder + // name would make their virtual paths collide. + let dir = std::env::temp_dir().join(format!( + "soulseek-e2e-queue-{label}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("share dir"); + for name in files { + std::fs::write(dir.join(name), vec![9u8; 4096]).expect("share file"); + } + let folder = dir + .file_name() + .and_then(|n| n.to_str()) + .expect("utf-8 folder") + .to_string(); + (dir, folder) +} + +#[test] +fn a_second_request_waits_when_the_only_upload_slot_is_taken() { + let server = server_or_skip!(); + let (share, folder) = + queue_share("slots", &["blocker.mp3", "waiter.mp3", "third.mp3"]); + let server_addr = format!("{}:{}", server.host, server.port); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share.display().to_string()], + ..server.listening_settings("e2e_q_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(1); + let listen_addr = format!("127.0.0.1:{sharer_port}"); + + let blocker_file = format!("{folder}\\blocker.mp3"); + let mut blocker = + queue_as(&server_addr, &listen_addr, "e2e_q_blocker", &blocker_file) + .expect("blocker queues"); + assert!( + blocker.takes_a_slot(), + "the first request should be offered the free slot" + ); + + let waiter_file = format!("{folder}\\waiter.mp3"); + let _waiter = + queue_as(&server_addr, &listen_addr, "e2e_q_waiter", &waiter_file) + .expect("waiter queues"); + assert!( + wait_for(|| place_of(&sharer, "e2e_q_waiter", &waiter_file) == Some(1)), + "with the slot held, the next request must wait at place 1, got {:?}", + place_of(&sharer, "e2e_q_waiter", &waiter_file) + ); + + let third_file = format!("{folder}\\third.mp3"); + let _third = + queue_as(&server_addr, &listen_addr, "e2e_q_third", &third_file) + .expect("third queues"); + assert!( + wait_for(|| place_of(&sharer, "e2e_q_third", &third_file) == Some(2)), + "and the one after that at place 2, got {:?}", + place_of(&sharer, "e2e_q_third", &third_file) + ); + + // Both waiters show up as queued uploads with their places, which is what + // `serve` reports to an operator. + let queued: Vec<(String, u32)> = sharer + .uploads() + .into_iter() + .filter_map(|upload| match upload.status { + UploadStatus::Queued(place) => Some((upload.username, place)), + _ => None, + }) + .collect(); + assert_eq!( + queued, + [ + ("e2e_q_waiter".to_string(), 1), + ("e2e_q_third".to_string(), 2) + ] + ); + + let _ = std::fs::remove_dir_all(share); +} + +#[test] +fn a_privileged_peer_overtakes_one_already_waiting_for_a_slot() { + let server = server_or_skip!(); + let server_addr = format!("{}:{}", server.host, server.port); + + // The account has to exist before it can be given privileges, and the + // privileges have to exist before we log in: the server sends the + // privileged-user list once, at login. + drop( + login_raw(&server_addr, "e2e_q_donor", "pw") + .expect("the donor registers"), + ); + if !grant(&server, "e2e_q_donor", "privileges") { + eprintln!( + "privilege e2e skipped: cannot write soulfind's database (needs a \ + locally spawned server and sqlite3)" + ); + return; + } + + let (share, folder) = + queue_share("privileged", &["blocker.mp3", "plain.mp3", "donor.mp3"]); + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share.display().to_string()], + ..server.listening_settings("e2e_q_psharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(1); + let listen_addr = format!("127.0.0.1:{sharer_port}"); + + // Not every soulfind build answers login with a populated code-69 list, so + // a server that names nobody is a skip rather than a failure — the + // re-ranking itself is covered by `client::upload_queue::context_tests`. + if !wait_for(|| sharer.is_privileged("e2e_q_donor")) { + eprintln!( + "privilege ordering e2e skipped: this server declared no \ + privileged users even after granting them in its database" + ); + let _ = std::fs::remove_dir_all(share); + return; + } + assert!(!sharer.is_privileged("e2e_q_plain"), "and nobody else"); + + let blocker_file = format!("{folder}\\blocker.mp3"); + let mut blocker = + queue_as(&server_addr, &listen_addr, "e2e_q_pblocker", &blocker_file) + .expect("blocker queues"); + assert!( + blocker.takes_a_slot(), + "the blocker should be offered the only slot" + ); + + // A plain user asks first and is alone in the queue… + let plain_file = format!("{folder}\\plain.mp3"); + let _plain = + queue_as(&server_addr, &listen_addr, "e2e_q_plain", &plain_file) + .expect("plain queues"); + assert!( + wait_for(|| place_of(&sharer, "e2e_q_plain", &plain_file) == Some(1)), + "got {:?}", + place_of(&sharer, "e2e_q_plain", &plain_file) + ); + + // …and then the donor asks, and goes ahead of them. + let donor_file = format!("{folder}\\donor.mp3"); + let _donor = + queue_as(&server_addr, &listen_addr, "e2e_q_donor", &donor_file) + .expect("donor queues"); + assert!( + wait_for(|| place_of(&sharer, "e2e_q_donor", &donor_file) == Some(1)), + "the privileged peer should take place 1, got {:?}", + place_of(&sharer, "e2e_q_donor", &donor_file) + ); + assert_eq!( + place_of(&sharer, "e2e_q_plain", &plain_file), + Some(2), + "and the plain user should be told they were overtaken" + ); + + let _ = std::fs::remove_dir_all(share); +} + +#[test] +fn a_peer_asking_where_it_sits_is_answered_with_its_place() { + let server = server_or_skip!(); + let (share, folder) = queue_share("place", &["blocker.mp3", "asker.mp3"]); + let server_addr = format!("{}:{}", server.host, server.port); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share.display().to_string()], + ..server.listening_settings("e2e_q_asharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(1); + let listen_addr = format!("127.0.0.1:{sharer_port}"); + + let blocker_file = format!("{folder}\\blocker.mp3"); + let mut blocker = + queue_as(&server_addr, &listen_addr, "e2e_q_ablocker", &blocker_file) + .expect("blocker queues"); + assert!( + blocker.takes_a_slot(), + "the blocker should be offered the only slot" + ); + + // The asker keeps its own connection so it can read the answer back. + let asker_file = format!("{folder}\\asker.mp3"); + let mut asker = + queue_as(&server_addr, &listen_addr, "e2e_q_asker", &asker_file) + .expect("asker queues"); + + assert!( + wait_for(|| place_of(&sharer, "e2e_q_asker", &asker_file) == Some(1)), + "the asker should be waiting at place 1 first" + ); + + let mut request = Message::new(); + request.write_int32(51).write_string(&asker_file); + asker.peer.write_all(&request.get_buffer()).expect("ask"); + asker.peer.flush().expect("flush"); + + let mut answer = expect_code(&mut asker.peer, 44, Duration::from_secs(10)) + .expect("a PlaceInQueueResponse should come back"); + answer.set_pointer(8); + assert_eq!(answer.read_string(), asker_file); + assert_eq!(answer.read_int32(), 1, "and it should carry the real place"); + + let _ = std::fs::remove_dir_all(share); +} + +#[test] +fn the_server_answers_how_much_privilege_time_we_have() { + let server = server_or_skip!(); + let mut client = + Client::with_settings(server.settings("e2e_priv_asker", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + client.check_privileges().expect("ask"); + assert!( + wait_for(|| client.own_privilege_seconds().is_some()), + "the server should answer code 92" + ); + assert_eq!( + client.own_privilege_seconds(), + Some(0), + "a fresh account has no privileges, which is zero rather than unknown" + ); +} + +#[test] +fn a_user_who_never_logged_in_reads_as_offline() { + let server = server_or_skip!(); + let mut asker = + Client::with_settings(server.settings("e2e_info_asker_b", "pw")); + asker.connect().expect("asker connect"); + assert!(asker.login().expect("asker login")); + + asker.request_user_info("nobody_by_this_name").expect("ask"); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut info = None; + while Instant::now() < deadline && info.is_none() { + info = asker + .user_info("nobody_by_this_name") + .filter(|found| found.presence.is_some()); + if info.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + + let presence = info + .expect("the server should answer for an unknown user") + .presence + .expect("with a presence"); + assert!(!presence.status.is_reachable(), "got {}", presence.status); +} + +// --------------------------------------------------------------------------- +// Concurrent sessions: several one-shot invocations running at once. +// --------------------------------------------------------------------------- + +/// Ask the server what address it hands out for `username`. +fn advertised_port(server_addr: &str, asker: &str, username: &str) -> u32 { + let mut peer = login_raw(server_addr, asker, "pw").expect("asker login"); + peer.write_all( + &MessageFactory::build_get_peer_address(username).get_buffer(), + ) + .expect("ask for the address"); + peer.flush().expect("flush"); + let mut reply = expect_code(&mut peer, 3, Duration::from_secs(5)) + .expect("the server should answer with an address"); + reply.set_pointer(8); + let _name = reply.read_string(); + for _ in 0..4 { + let _ = reply.read_int8(); + } + reply.read_int32() +} + +#[test] +fn a_taken_listen_port_does_not_cost_us_the_listener() { + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + // A sibling process already holds the configured port: the ordinary case + // when several invocations run at once with one config file between them. + let taken = free_port().expect("port to occupy"); + let _squatter = + std::net::TcpListener::bind(("0.0.0.0", taken)).expect("occupy"); + + let mut client = Client::with_settings(server.listening_settings( + "e2e_port_clash", + "pw", + taken, + )); + client.connect().expect("connect despite the taken port"); + assert!(client.login().expect("login")); + + let bound = client.listen_port().expect("a listener was still bound"); + assert_ne!(bound, taken, "a port someone else holds cannot be ours"); + std::thread::sleep(Duration::from_secs(1)); + + // Whatever port we ended up on, the server must hand peers that one: + // advertising a port held by someone else sends our search responses to a + // stranger, and we wait out the search seeing nothing. + let advertised = advertised_port(&addr, "e2e_port_asker", "e2e_port_clash"); + assert_eq!( + advertised, + u32::from(bound), + "the server must advertise the port we really bound" + ); +} + +#[test] +fn a_listening_client_reports_the_port_it_bound() { + let server = server_or_skip!(); + let port = free_port().expect("free port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_port_ok", + "pw", + port, + )); + client.connect().expect("connect"); + assert_eq!(client.listen_port(), Some(port)); + + let mut quiet = + Client::with_settings(server.settings("e2e_port_none", "pw")); + quiet.connect().expect("connect"); + assert_eq!( + quiet.listen_port(), + None, + "a client that does not listen has no port to report" + ); +} + +#[test] +fn a_second_login_under_one_name_reports_the_first_session_as_lost() { + let server = server_or_skip!(); + + let mut first = + Client::with_settings(server.settings("e2e_displaced", "pw")); + first.connect().expect("first connect"); + assert!(first.login().expect("first login")); + assert_eq!(first.session_loss(), None, "a fresh session is alive"); + std::thread::sleep(Duration::from_secs(1)); + + // The server allows one session per account, so this login evicts the one + // above. Silently seeing nothing from then on is what made concurrent runs + // report files as unavailable when they were not. + let mut second = + Client::with_settings(server.settings("e2e_displaced", "pw")); + second.connect().expect("second connect"); + assert!(second.login().expect("second login")); + + let deadline = Instant::now() + Duration::from_secs(15); + while first.session_loss().is_none() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + first.session_loss(), + Some(SessionLoss::Displaced), + "the evicted session must say why it went quiet" + ); + assert_eq!(second.session_loss(), None, "the winner keeps its session"); +} + +#[test] +fn clients_with_distinct_names_search_the_same_sharer_at_once() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..2048u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_probe_parallel.bin"), &content).unwrap(); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings( + "e2e_par_sharer", + "pw", + free_port().expect("sharer port"), + ) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // Three sessions on one machine, all configured with the same listen port: + // exactly what several agents running the CLI at once look like. + let shared_port = free_port().expect("shared port"); + let seekers: Vec = (0..3) + .map(|i| { + let mut client = Client::with_settings(server.listening_settings( + &format!("e2e_par_seeker_{i}"), + "pw", + shared_port, + )); + client.connect().expect("seeker connect"); + assert!(client.login().expect("seeker login")); + client + }) + .collect(); + + let ports: Vec = + seekers.iter().filter_map(Client::listen_port).collect(); + assert_eq!(ports.len(), 3, "every seeker keeps a listener"); + let mut unique = ports.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), 3, "each seeker holds its own port: {ports:?}"); + std::thread::sleep(Duration::from_secs(1)); + + std::thread::scope(|scope| { + for client in &seekers { + scope.spawn(move || { + let _ = client.search("parallel", Duration::from_secs(5)); + }); + } + }); + + for (i, client) in seekers.iter().enumerate() { + assert_eq!( + client.session_loss(), + None, + "seeker {i} should still hold its session" + ); + let hit = client + .get_search_results("parallel") + .into_iter() + .flat_map(|result| result.files) + .find(|file| file.name.contains("e2e_probe_parallel")); + assert!(hit.is_some(), "seeker {i} found nothing"); + } + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_peer_that_hangs_up_its_control_connection_still_delivers_the_file() { + let server = server_or_skip!(); + + // The bytes travel over their own F connection, so a peer is free to close + // the control connection first — other clients drop idle peer sockets as a + // matter of course. Treating that hangup as a failure used to fail every + // download queued with that peer, the one streaming included. + let client_port = free_port().expect("free client listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_fin_dl", + "pw", + client_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mock_port = free_port().expect("free mock listen port"); + let filename = "hangup_song.mp3"; + let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let size = content.len() as u64; + let download_dir = unique_download_dir(); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockDirectUpload { + server_addr: format!("{}:{}", server.host, server.port), + username: "e2e_finpeer".to_string(), + password: "pw".to_string(), + listen_port: mock_port, + downloader_listen_addr: format!("127.0.0.1:{client_port}"), + downloader_username: "e2e_fin_dl".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 616_161_u32, + ready: ready_tx, + close_control_first: true, + }; + let uploader = std::thread::spawn(move || { + if let Err(e) = run_mock_direct_peer(&cfg) { + eprintln!("[mock hangup peer] {e}"); + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(10)) + .expect("mock peer ready"); + std::thread::sleep(Duration::from_secs(1)); + + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_finpeer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&client, &status_rx, Duration::from_secs(20)); + let _ = uploader.join(); + + assert!( + completed, + "a closed control connection must not fail the transfer" + ); + let written = std::fs::read(download_dir.join(filename)) + .expect("downloaded file should exist"); + assert_eq!(written, content, "downloaded bytes should match the source"); + + let _ = std::fs::remove_dir_all(&download_dir); +} + +#[test] +fn a_joined_room_reports_its_members_statistics() { + let server = server_or_skip!(); + + let room = "e2e_room_stats"; + + // A member with a known share, so the statistics are checkable rather + // than merely present. + let share_dir = unique_download_dir().join("stats_share"); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + for name in ["one.flac", "two.flac", "three.flac"] { + std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap(); + } + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_stats_sharer", "pw") + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.join_room(room).expect("sharer joins room"); + + // The sharer's membership and share counts have to land first, so the + // observer's own join reply describes a room that already has them. + fence(&sharer, "e2e_stats_observer"); + + let mut observer = + Client::with_settings(server.settings("e2e_stats_observer", "pw")); + observer.connect().expect("observer connect"); + assert!(observer.login().expect("observer login")); + observer.join_room(room).expect("observer joins room"); + + // The stats ride along with the membership list, so poll for the member + // rather than for a fixed delay. + let deadline = Instant::now() + Duration::from_secs(15); + let mut sharer_stats = None; + while Instant::now() < deadline { + if let Some(found) = observer + .room_member_stats(room) + .into_iter() + .find(|s| s.username == "e2e_stats_sharer") + { + sharer_stats = Some(found); + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + let stats = + sharer_stats.expect("the join reply should describe the members"); + + assert!( + stats.status.is_reachable(), + "a member who is in the room cannot read as offline, got {}", + stats.status + ); + assert_eq!( + stats.shared_files, 3, + "the member's file count must survive the parallel stat vectors" + ); + assert_eq!(stats.shared_folders, 1); + assert!( + !stats.slots_full, + "a client that never filled its slots must not read as full" + ); + + // Every member is described, not just the one we looked up: a short or + // misread vector would drop or shift the rest. + let members = observer.room_member_stats(room); + let names: Vec<&str> = + members.iter().map(|s| s.username.as_str()).collect(); + assert!( + names.contains(&"e2e_stats_sharer") + && names.contains(&"e2e_stats_observer"), + "both members should be described, got {names:?}" + ); + assert_eq!( + members.len(), + observer.room_members(room).len(), + "the stats must line up one-to-one with the roster" + ); +} + +#[test] +fn watching_a_user_returns_their_status_and_share_counts() { + let server = server_or_skip!(); + + // A subject with a known share, so the watch reply's statistics are + // checkable rather than merely present. + let share_dir = unique_download_dir().join("watch_share"); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + for name in ["one.flac", "two.flac"] { + std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap(); + } + let mut subject = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_watch_subject", "pw") + }); + subject.connect().expect("subject connect"); + assert!(subject.login().expect("subject login")); + + let mut watcher = + Client::with_settings(server.settings("e2e_watch_watcher", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + // The subject's share counts have to reach the server before watching, + // so the reply describes a user it already knows the statistics for. + fence(&subject, "e2e_watch_watcher"); + + watcher + .watch_user("e2e_watch_subject") + .expect("watch the subject"); + assert_eq!( + watcher.watched_users(), + vec!["e2e_watch_subject".to_string()] + ); + + // One reply carries both halves, unlike the two GetUserStatus and + // GetUserStats answers request_user_info waits on. + let deadline = Instant::now() + Duration::from_secs(15); + let mut info = None; + while Instant::now() < deadline { + match watcher.user_info("e2e_watch_subject") { + Some(found) if found.is_complete() => { + info = Some(found); + break; + } + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + let info = info.expect("the watch reply should carry status and stats"); + + let presence = info.presence.expect("presence should have arrived"); + assert!( + presence.status.is_reachable(), + "a logged-in user should not read as offline, got {}", + presence.status + ); + let stats = info.stats.expect("stats should have arrived"); + assert_eq!( + stats.shared_files, 2, + "the file count must survive the obsolete fields ahead of it" + ); + assert_eq!(stats.shared_folders, 1); + + // Unwatching drops the user and what we knew about them, so a later + // re-watch reports a fresh answer rather than this stale one. + watcher + .unwatch_user("e2e_watch_subject") + .expect("unwatch the subject"); + assert!(watcher.watched_users().is_empty()); + assert!(watcher.user_info("e2e_watch_subject").is_none()); +} + +#[test] +fn watching_a_user_the_server_does_not_know_reports_their_absence() { + let server = server_or_skip!(); + + let mut watcher = + Client::with_settings(server.settings("e2e_watch_ghost_asker", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + watcher + .watch_user("e2e_watch_nobody_here") + .expect("watch an unknown user"); + + // The server answers that it has no such user; the watch is dropped + // rather than left showing a permanently blank row. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && !watcher.watched_users().is_empty() { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + watcher.watched_users().is_empty(), + "a username the server does not know must not stay watched" + ); + assert!(watcher.user_info("e2e_watch_nobody_here").is_none()); +} + +// A query that matches most of a big share must not ship the whole share to +// whoever typed it: a reply is a shortlist, and the searcher's other sources +// cover the rest. +#[test] +fn a_search_reply_carries_at_most_the_file_cap() { + let server = server_or_skip!(); + + let cap = soulseek_rs::types::MAX_SEARCH_REPLY_FILES; + let share_dir = unique_download_dir(); + for i in 0..cap + 20 { + std::fs::write(share_dir.join(format!("probe_cap_{i:04}.bin")), b"x") + .unwrap(); + } + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_cap_sharer", + "e2e_cap_searcher", + ); + + let query = "probe_cap"; + let _ = searcher.search(query, Duration::from_secs(3)); + + let reply = reply_from(&searcher, query, "e2e_cap_sharer") + .expect("the sharer should answer the search"); + assert_eq!(reply.files.len(), cap, "a reply is capped at {cap} files"); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Tokens were the first five hex digits of the query's MD5, so two queries +// could share one and a peer's answer to either went to whichever search the +// map handed back first. These two queries collide under that scheme. +#[test] +fn two_live_searches_never_share_a_token() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("tokenprobe aeabna.bin"), b"x").unwrap(); + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_token_sharer", + "e2e_token_searcher", + ); + + let matching = "tokenprobe aeabna"; + let colliding = "tokenprobe soyvgt"; + let _ = searcher.search(colliding, Duration::from_millis(100)); + let _ = searcher.search(matching, Duration::from_secs(3)); + + let live = searcher.get_all_searches(); + assert_ne!( + live[matching].token, live[colliding].token, + "two live searches must not share a token" + ); + + assert!( + reply_from(&searcher, matching, "e2e_token_sharer").is_some(), + "the sharer's answer should land in the search it answers" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Searchers pick sources by the free-slot flag in a reply, and every reply +// said "free" whatever the queue looked like. +#[test] +fn a_search_reply_reports_no_free_slot_while_the_only_slot_is_taken() { + let server = server_or_skip!(); + let (share, folder) = + queue_share("honest", &["blocker.mp3", "probe_honest.bin"]); + let (sharer, searcher) = sharer_and_searcher( + &server, + &share, + "e2e_honest_sharer", + "e2e_honest_searcher", + ); + sharer.set_upload_slots(1); + + let query = "probe_honest"; + let _ = searcher.search(query, Duration::from_secs(1)); + let reply = reply_from(&searcher, query, "e2e_honest_sharer") + .expect("the sharer answers"); + assert_eq!(reply.slots, 1, "nothing is uploading yet"); + + let server_addr = format!("{}:{}", server.host, server.port); + let listen_addr = + format!("127.0.0.1:{}", sharer.listen_port().expect("listening")); + let mut blocker = queue_as( + &server_addr, + &listen_addr, + "e2e_honest_blocker", + &format!("{folder}\\blocker.mp3"), + ) + .expect("blocker queues"); + assert!( + blocker.takes_a_slot(), + "the blocker is offered the only slot" + ); + + let _ = searcher.search(query, Duration::from_secs(1)); + let reply = reply_from(&searcher, query, "e2e_honest_sharer") + .expect("the sharer answers again"); + assert_eq!(reply.slots, 0, "the only slot is taken"); + + let _ = std::fs::remove_dir_all(share); +} + +// "Download folder" in SoulseekQt and Nicotine+ asks the sharer for that one +// folder (peer code 36) instead of the whole share; a sharer that never +// answers leaves the folder download hanging. +#[test] +fn a_third_party_client_fetches_one_folder_of_our_shares() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::create_dir_all(share_dir.join("other")).unwrap(); + std::fs::write(share_dir.join("album").join("one.flac"), b"xxxx").unwrap(); + std::fs::write(share_dir.join("album").join("two.flac"), b"yy").unwrap(); + std::fs::write(share_dir.join("other").join("three.flac"), b"z").unwrap(); + let root = share_dir.file_name().unwrap().to_str().unwrap().to_string(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_folder_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_folder_browser", "pw") + .expect("third-party client logs in"); + + let folder = format!("{root}\\album"); + let mut p = connect_retry( + &format!("127.0.0.1:{sharer_port}"), + Duration::from_secs(5), + ) + .expect("dial the sharer"); + p.set_read_timeout(Some(Duration::from_secs(15))).unwrap(); + p.write_all(&peer_init_bytes("e2e_folder_browser", "P", 0)) + .unwrap(); + let mut request = Message::new(); + request + .write_int32(36) + .write_int32(77) + .write_string(&folder); + p.write_all(&request.get_buffer()).unwrap(); + p.flush().unwrap(); + + let mut response = expect_code(&mut p, 37, Duration::from_secs(15)) + .expect("the sharer answers with a FolderContentsResponse"); + response.set_pointer(8); + let (token, echoed, directories) = + soulseek_rs::message::peer::parse_folder_contents(&mut response) + .expect("a well-formed folder listing"); + assert_eq!(token, 77); + assert_eq!(echoed, folder); + let mut names: Vec<(String, Vec)> = directories + .into_iter() + .map(|d| (d.name, d.files.into_iter().map(|f| f.name).collect())) + .collect(); + for (_, files) in &mut names { + files.sort(); + } + assert_eq!( + names, + [(folder, vec!["one.flac".to_string(), "two.flac".to_string()])], + "only the requested folder, with its files" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// "User info" in SoulseekQt and Nicotine+ asks the peer directly (peer code +// 15); a peer that never answers shows an endless spinner. +#[test] +fn a_third_party_client_reads_our_user_info() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("probe.flac"), b"xxxx").unwrap(); + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_info_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(3); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_info_asker", "pw") + .expect("third-party client logs in"); + + let mut p = connect_retry( + &format!("127.0.0.1:{sharer_port}"), + Duration::from_secs(5), + ) + .expect("dial the sharer"); + p.set_read_timeout(Some(Duration::from_secs(15))).unwrap(); + p.write_all(&peer_init_bytes("e2e_info_asker", "P", 0)) + .unwrap(); + p.write_all(&Message::new().write_int32(15).get_buffer()) + .unwrap(); + p.flush().unwrap(); + + let mut reply = expect_code(&mut p, 16, Duration::from_secs(15)) + .expect("the sharer answers with a UserInfoResponse"); + reply.set_pointer(8); + let description = reply.read_string(); + let has_picture = reply.read_bool(); + let upload_slots = reply.read_int32(); + let queue_size = reply.read_int32(); + let slots_free = reply.read_bool(); + assert!(description.is_empty(), "no description is configured"); + assert!(!has_picture); + assert_eq!(upload_slots, 3, "the configured slot count"); + assert_eq!(queue_size, 0, "nobody is waiting"); + assert!(slots_free, "nothing is uploading"); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Progress assumed every read filled the buffer, so a peer that trickles +// small chunks was reported at several times its real rate. +#[test] +fn download_progress_reports_the_rate_actually_received() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_rate_dl", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "trickle.mp3"; + let content: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let download_dir = unique_download_dir(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let cfg = MockUpload { + listen_addr: format!("127.0.0.1:{listen_port}"), + peer_username: "e2e_rate_up".to_string(), + filename: filename.to_string(), + content: content.clone(), + token: 424_260_u32, + ready: ready_tx, + token_delay: Duration::ZERO, + }; + let uploader = + std::thread::spawn(move || run_mock_slow_uploader(&cfg, None)); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock uploader P connection"); + + let (_download, status_rx) = client + .download( + filename.to_string(), + "e2e_rate_up".to_string(), + content.len() as u64, + download_dir.display().to_string(), + ) + .expect("start download"); + + let mut fastest = 0.0f64; + let mut completed = false; + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline && !completed { + match status_rx.recv_timeout(Duration::from_millis(500)) { + Ok(DownloadStatus::InProgress { + speed_bytes_per_sec, + .. + }) => fastest = fastest.max(speed_bytes_per_sec), + Ok(DownloadStatus::Completed) => completed = true, + Ok(DownloadStatus::Failed(_) | DownloadStatus::TimedOut) => break, + _ => {} + } + } + assert!(completed, "the trickled download should complete"); + assert!(fastest > 0.0, "progress should report a rate"); + // The mock sends 4 KiB every 20 ms. + let sent_rate = 4096.0 / 0.020; + assert!( + fastest < 1.5 * sent_rate, + "reported {fastest} B/s for a peer sending {sent_rate} B/s" + ); + + let _ = uploader.join(); + let _ = std::fs::remove_dir_all(&download_dir); +} + +// A listing went out in zlib "stored" blocks, so a big share cost every +// browser its full uncompressed size; names repeat enough to shrink well. +#[test] +fn a_large_listing_travels_compressed() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let album = share_dir.join("Artist - Album"); + std::fs::create_dir_all(&album).unwrap(); + let mut raw_bytes = 0; + for n in 0..3000 { + let name = format!("Artist - Album - {n:04} - Track Title.flac"); + raw_bytes += name.len() + 21; + std::fs::write(album.join(name), b"x").unwrap(); + } + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_zlib_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_zlib_browser", "pw") + .expect("third-party client logs in"); + + let mut p = connect_retry( + &format!("127.0.0.1:{sharer_port}"), + Duration::from_secs(5), + ) + .expect("dial the sharer"); + p.set_read_timeout(Some(Duration::from_secs(15))).unwrap(); + p.write_all(&peer_init_bytes("e2e_zlib_browser", "P", 0)) + .unwrap(); + p.write_all(&MessageFactory::build_get_share_file_list().get_buffer()) + .unwrap(); + p.flush().unwrap(); + let mut response = expect_code(&mut p, 5, Duration::from_secs(15)) + .expect("the sharer answers the browse"); + + let frame = response.get_size(); + assert!( + frame < raw_bytes / 4, + "a {raw_bytes}-byte listing travelled as {frame} bytes" + ); + response.set_pointer(8); + let directories = + soulseek_rs::message::peer::parse_shared_file_list(&mut response); + assert_eq!( + directories.iter().map(|d| d.files.len()).sum::(), + 3000 + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// The server's announcements (code 66) were dropped on the floor; they are +// how a server tells everyone it is going down. +#[test] +fn a_server_announcement_arrives_as_a_message_from_the_server() { + let server = server_or_skip!(); + + let mut admin = Client::with_settings(server.settings("e2e_admin", "pw")); + let mut bob = + Client::with_settings(server.settings("e2e_announce_bob", "pw")); + admin.connect().expect("admin connect"); + bob.connect().expect("bob connect"); + assert!(admin.login().expect("admin login")); + assert!(bob.login().expect("bob login")); + if !grant(&server, "e2e_admin", "admin") { + println!("e2e skipped: cannot make an admin on this server"); + return; + } + + admin + .send_private_message("server", "announcement going down at nine") + .expect("send the admin command"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut received = Vec::new(); + while Instant::now() < deadline { + received.extend(bob.take_private_messages()); + if received.iter().any(|m| m.username() == "server") { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + let announcement = received + .iter() + .find(|m| m.username() == "server") + .expect("bob should hear the announcement"); + assert_eq!(announcement.message(), "going down at nine"); +} + +// A peer that is offered a slot and never answers must not keep it: with +// two slots by default, two such peers shut uploads down for the session. +#[test] +fn an_unanswered_upload_offer_frees_its_slot() { + let server = server_or_skip!(); + let (share, folder) = queue_share("expiry", &["silent.mp3", "patient.mp3"]); + let server_addr = format!("{}:{}", server.host, server.port); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share.display().to_string()], + ..server.listening_settings("e2e_expiry_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(1); + let listen_addr = format!("127.0.0.1:{sharer_port}"); + + let mut silent = queue_as( + &server_addr, + &listen_addr, + "e2e_expiry_silent", + &format!("{folder}\\silent.mp3"), + ) + .expect("silent peer queues"); + assert!(silent.takes_a_slot(), "the silent peer is offered the slot"); + + let mut patient = queue_as( + &server_addr, + &listen_addr, + "e2e_expiry_patient", + &format!("{folder}\\patient.mp3"), + ) + .expect("patient peer queues"); + assert!( + patient.takes_a_slot_within(Duration::from_mins(1)), + "the slot must come free once the silent peer's offer expires" + ); + assert!( + sharer.take_upload_events().iter().any(|event| { + event.username == "e2e_expiry_silent" + && matches!(event.status, UploadStatus::Failed(_)) + }), + "the operator sees the unanswered offer fail" + ); + + let _ = std::fs::remove_dir_all(share); +} + +/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps, +/// 44.1 kHz, so every searcher sees a bitrate to filter and sort on. +fn cbr_mp3(frames: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..frames { + out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); + out.resize(out.len() + 413, 0); + } + out +} + +// A shared file with no bitrate fails every `--min-bitrate` filter and sorts +// last in SoulseekQt; the attributes come from the file's own headers. +#[test] +fn a_shared_mp3_advertises_its_bitrate_and_duration() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + // 1000 frames is 26 seconds. + std::fs::write(share_dir.join("attrprobe song.mp3"), cbr_mp3(1000)) + .unwrap(); + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_attr_sharer", + "e2e_attr_searcher", + ); + + let query = "attrprobe"; + let _ = searcher.search(query, Duration::from_secs(3)); + let reply = reply_from(&searcher, query, "e2e_attr_sharer") + .expect("the sharer answers"); + let file = &reply.files[0]; + assert_eq!(file.attribs.get(&0), Some(&128), "bitrate in kbps"); + assert_eq!(file.attribs.get(&1), Some(&26), "duration in seconds"); + assert_eq!(file.attribs.get(&2), Some(&0), "constant bitrate"); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// A browse shows what a search shows: the bitrate and duration a searcher +// filters on are in the listing too, as Nicotine+ sends them. +#[test] +fn a_browse_listing_carries_the_files_attributes() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("listed.mp3"), cbr_mp3(1000)).unwrap(); + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_lattr_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_lattr_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_lattr_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("a listing"); + let listed = directories + .iter() + .flat_map(|d| d.files.iter()) + .find(|f| f.name == "listed.mp3") + .expect("the mp3 is listed"); + assert!( + listed.attributes.contains(&(0, 128)), + "{:?}", + listed.attributes + ); + assert!( + listed.attributes.contains(&(1, 26)), + "{:?}", + listed.attributes + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +/// The next inbound connection, or a timeout error: a mock that never gets +/// dialled must fail its test rather than hang it. +fn accept_within( + listener: &std::net::TcpListener, + timeout: Duration, +) -> std::io::Result { + listener.set_nonblocking(true)?; + let deadline = Instant::now() + timeout; + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "no inbound connection", + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(e), + } + }; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(15)))?; + Ok(stream) +} + +/// The PeerInit that opens an inbound connection: who dialled, and as what. +fn peer_init_of(stream: &mut TcpStream) -> std::io::Result<(String, String)> { + let mut init = read_framed(stream)?; + assert_eq!(init.get_init_code(), 1, "expected a PeerInit"); + init.set_pointer(5); + Ok((init.read_string(), init.read_string())) +} + +// Searches also travel peer to peer, down a tree of parents and children the +// server assembles from PossibleParents. A leaf dials the candidates with a D +// connection, adopts the first that passes it a search after stating its +// branch, and answers those searches exactly like ones from the server. +// soulfind never hands out parents, so the parent here is a mock: a logged-in +// user the leaf is pointed at directly. +#[test] +fn a_leaf_adopts_a_parent_and_answers_the_searches_it_passes_down() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("treesearch.mp3"), b"xxxx") + .unwrap(); + + // The parent and the searcher are two logged-in users with a listener + // each. Bind all interfaces: soulfind reports the LAN address. + let server_addr = format!("{}:{}", server.host, server.port); + let raw_user = |name: &str| { + let port = free_port().expect("a port for the raw user"); + let listener = std::net::TcpListener::bind(("0.0.0.0", port)).unwrap(); + let mut srv = login_raw(&server_addr, name, "pw").expect("raw login"); + srv.write_all( + &MessageFactory::build_set_wait_port_message(port).get_buffer(), + ) + .unwrap(); + (listener, port, srv) + }; + let (listener, parent_port, _parent_srv) = raw_user("e2e_parent"); + let (searcher, _, _searcher_srv) = raw_user("e2e_searcher"); + + let mut leaf = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings( + "e2e_leaf", + "pw", + free_port().expect("leaf port"), + ) + }); + leaf.connect().expect("leaf connect"); + assert!(leaf.login().expect("leaf login")); + assert_eq!( + leaf.distributed_branch(), + ("e2e_leaf".to_string(), 0), + "a leaf without a parent is its own branch root" + ); + + leaf.consider_parents(vec![( + "e2e_parent".to_string(), + "127.0.0.1".to_string(), + parent_port, + )]) + .expect("consider parents"); + + let mut link = accept_within(&listener, Duration::from_secs(15)) + .expect("the leaf dials the candidate parent"); + assert_eq!( + peer_init_of(&mut link).unwrap(), + ("e2e_leaf".to_string(), "D".to_string()), + "a parent link opens with a distributed PeerInit" + ); + + // We are a branch root at level 0; the leaf hangs one level below us. + link.write_all(&distributed::build_branch_level(0).get_buffer()) + .unwrap(); + link.write_all(&distributed::build_branch_root("e2e_parent").get_buffer()) + .unwrap(); + link.write_all( + &distributed::build_search("e2e_searcher", 4242, "treesearch") + .get_buffer(), + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(10); + while leaf.distributed_branch().1 == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + leaf.distributed_branch(), + ("e2e_parent".to_string(), 1), + "the first search from a candidate with a branch adopts it" + ); + + // The answer goes to whoever asked, not to the parent that relayed it: + // a P connection to the searcher carrying a FileSearchResponse for the + // token. + let mut p = accept_within(&searcher, Duration::from_secs(20)) + .expect("the leaf dials the searcher to answer"); + assert_eq!( + peer_init_of(&mut p).unwrap(), + ("e2e_leaf".to_string(), "P".to_string()) + ); + let response = expect_code(&mut p, 9, Duration::from_secs(15)) + .expect("a FileSearchResponse for the search from the tree"); + let body = soulseek_rs::utils::zlib::inflate(&response.get_data()[8..]) + .expect("the response body inflates"); + let name_len = u32::from_le_bytes(body[..4].try_into().unwrap()) as usize; + let token_at = 4 + name_len; + assert_eq!(&body[4..token_at], b"e2e_leaf"); + assert_eq!( + u32::from_le_bytes(body[token_at..token_at + 4].try_into().unwrap()), + 4242 + ); + + // Losing the parent puts the leaf back on its own. + drop(link); + let deadline = Instant::now() + Duration::from_secs(10); + while leaf.distributed_branch().1 != 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!(leaf.distributed_branch(), ("e2e_leaf".to_string(), 0)); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// --------------------------------------------------------------------------- +// Room tickers, the global room feed, interests and the targeted searches. +// +// Every one of these is a message the client can now speak; the point of +// driving them through soulfind is that the wire shapes are right in both +// directions, not just that they parse. +// --------------------------------------------------------------------------- + +/// Poll `client`'s room events until `pick` matches one, or time out. +fn await_room_event( + client: &Client, + timeout: Duration, + mut pick: impl FnMut(&soulseek_rs::types::RoomEvent) -> Option, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + for event in client.take_room_events() { + if let Some(found) = pick(&event) { + return Some(found); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +/// Wait until the server has handled everything `client` has sent, including +/// requests it never answers: soulfind takes a connection's messages in order, +/// so the answer to a question about `other` asked now comes after them. +fn fence(client: &Client, other: &str) { + client.request_user_info(other).expect("ask about a user"); + let deadline = Instant::now() + Duration::from_secs(5); + while client.user_info(other).is_none() { + assert!(Instant::now() < deadline, "the server should answer"); + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn a_ticker_set_in_a_room_reaches_the_other_members() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_room"; + let mut alice = + Client::with_settings(server.settings("e2e_tick_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_tick_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins"); + bob.join_room(room).expect("bob joins"); + // A ticker reaches only who is in the room when it is set. + let deadline = Instant::now() + Duration::from_secs(5); + while bob.room_members(room).is_empty() { + assert!(Instant::now() < deadline, "bob should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } + let _ = bob.take_room_events(); + + alice + .set_room_ticker(room, "alice was here") + .expect("set ticker"); + + let seen = + await_room_event(&bob, Duration::from_secs(5), |event| match event { + RoomEvent::TickerAdded { + room: r, + username, + ticker, + } if r == room => Some((username.clone(), ticker.clone())), + _ => None, + }) + .expect("bob should see alice's ticker"); + assert_eq!( + seen, + ("e2e_tick_alice".to_string(), "alice was here".into()) + ); + + // The board is kept, not just the event: a UI reads it back from state. + let board = bob.room_tickers(room); + assert_eq!( + board + .iter() + .find(|t| t.username == "e2e_tick_alice") + .map(|t| t.ticker.as_str()), + Some("alice was here") + ); +} + +#[test] +fn a_client_joining_later_receives_the_whole_ticker_board() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_board"; + let mut alice = + Client::with_settings(server.settings("e2e_board_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + alice + .set_room_ticker(room, "standing message") + .expect("set ticker"); + // The room, alice included, is told once the board holds it. + await_room_event(&alice, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::TickerAdded { room: r, .. } if r == room) + .then_some(()) + }) + .expect("alice should see her ticker go up"); + + // Carol joins afterwards and must be handed the board that already exists. + let mut carol = + Client::with_settings(server.settings("e2e_board_carol", "pw")); + carol.connect().expect("carol connect"); + assert!(carol.login().expect("carol login")); + carol.join_room(room).expect("carol joins"); + + let tickers = + await_room_event(&carol, Duration::from_secs(5), |event| match event { + RoomEvent::Tickers { room: r, tickers } if r == room => { + Some(tickers.clone()) + } + _ => None, + }) + .expect("carol should receive the ticker board on join"); + assert!( + tickers.iter().any(|t| t.username == "e2e_board_alice" + && t.ticker == "standing message"), + "the board should carry alice's standing ticker, got {tickers:?}" + ); +} + +#[test] +fn the_global_room_feed_carries_a_room_we_never_joined() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_global_source"; + let mut watcher = + Client::with_settings(server.settings("e2e_global_watch", "pw")); + let mut talker = + Client::with_settings(server.settings("e2e_global_talk", "pw")); + watcher.connect().expect("watcher connect"); + talker.connect().expect("talker connect"); + assert!(watcher.login().expect("watcher login")); + assert!(talker.login().expect("talker login")); + + watcher.join_global_room().expect("join global room"); + talker.join_room(room).expect("talker joins a room"); + fence(&watcher, "e2e_global_talk"); + let _ = watcher.take_room_events(); + + let body = "spoken where nobody is watching"; + talker.say_in_room(room, body).expect("say in room"); + + let got = + await_room_event( + &watcher, + Duration::from_secs(5), + |event| match event { + RoomEvent::GlobalMessage { + room: r, + username, + message, + } if message == body => Some((r.clone(), username.clone())), + _ => None, + }, + ) + .expect("the global feed should carry the message"); + assert_eq!(got, (room.to_string(), "e2e_global_talk".to_string())); + + // And leaving the feed stops it: the next message must not arrive. + watcher.leave_global_room().expect("leave global room"); + fence(&watcher, "e2e_global_talk"); + let _ = watcher.take_room_events(); + talker + .say_in_room(room, "after leaving") + .expect("say again"); + let after = await_room_event(&watcher, Duration::from_secs(2), |event| { + matches!(event, RoomEvent::GlobalMessage { message, .. } + if message == "after leaving") + .then_some(()) + }); + assert!(after.is_none(), "the feed should stop once left"); +} + +#[test] +fn a_user_search_is_answered_by_the_user_it_names() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_usersearch_grail.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_us_sharer", + "e2e_us_seeker", + ); + + let query = "grail"; + searcher + .search_user("e2e_us_sharer", query) + .expect("user search"); + + let reply = reply_from(&searcher, query, "e2e_us_sharer") + .expect("the named user should answer a user search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_usersearch_grail")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_search_is_answered_by_the_rooms_members() { + let server = server_or_skip!(); + + let room = "e2e_search_room"; + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_roomsearch_relic.bin"), &content) + .unwrap(); + + let (sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_rs_sharer", + "e2e_rs_seeker", + ); + sharer.join_room(room).expect("sharer joins"); + searcher.join_room(room).expect("searcher joins"); + // A room search goes to whoever is in the room when it is sent. + let deadline = Instant::now() + Duration::from_secs(5); + while !searcher + .room_members(room) + .iter() + .any(|member| member == "e2e_rs_sharer") + { + assert!(Instant::now() < deadline, "both should get into the room"); + std::thread::sleep(Duration::from_millis(50)); + } + + let query = "relic"; + searcher.search_room(room, query).expect("room search"); + + let reply = reply_from(&searcher, query, "e2e_rs_sharer") + .expect("a member of the room should answer a room search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_roomsearch_relic")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn shared_interests_make_two_users_similar() { + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_like_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_like_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let item = "e2e_interest_krautrock"; + alice.add_interest(item).expect("alice likes it"); + bob.add_interest(item).expect("bob likes it too"); + alice + .add_dislike("e2e_interest_muzak") + .expect("alice dislikes something"); + fence(&bob, "e2e_like_alice"); + + // Who else likes this item (code 112). + let deadline = Instant::now() + Duration::from_secs(5); + let mut likers = Vec::new(); + while Instant::now() < deadline + && !likers.iter().any(|u| u == "e2e_like_bob") + { + alice + .request_item_similar_users(item) + .expect("ask who likes it"); + std::thread::sleep(Duration::from_millis(250)); + likers = alice.item_similar_users(item); + } + assert!( + likers.iter().any(|u| u == "e2e_like_bob"), + "bob likes the same item, got {likers:?}" + ); + + // The overlap also makes bob a similar user (code 110). + let deadline = Instant::now() + Duration::from_secs(5); + let mut similar = Vec::new(); + while Instant::now() < deadline + && !similar + .iter() + .any(|u: &soulseek_rs::SimilarUser| u.username == "e2e_like_bob") + { + alice + .request_similar_users() + .expect("ask for similar users"); + std::thread::sleep(Duration::from_millis(250)); + similar = alice.similar_users(); + } + assert!( + similar + .iter() + .any(|u| u.username == "e2e_like_bob" && u.weight > 0), + "bob should be similar with a non-zero weight, got {similar:?}" + ); + + // And alice's own likes and hates read back over code 57. + let deadline = Instant::now() + Duration::from_secs(5); + let mut interests = None; + while Instant::now() < deadline && interests.is_none() { + bob.request_user_interests("e2e_like_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + interests = bob.user_interests("e2e_like_alice"); + } + let interests = interests.expect("bob should learn alice's interests"); + assert!(interests.likes.iter().any(|i| i == item)); + assert!( + interests.hates.iter().any(|i| i == "e2e_interest_muzak"), + "hates should come back too, got {interests:?}" + ); + + // A dropped interest stops being reported. + alice.remove_interest(item).expect("alice drops it"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut still_liked = true; + while Instant::now() < deadline && still_liked { + bob.request_user_interests("e2e_like_alice") + .expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + still_liked = bob + .user_interests("e2e_like_alice") + .is_none_or(|i| i.likes.iter().any(|l| l == item)); + } + assert!( + !still_liked, + "a removed interest should stop being reported" + ); +} + +#[test] +fn recommendations_are_returned_for_our_interests() { + let server = server_or_skip!(); + + // Recommendations come from what *other* users who share an interest also + // like: bob likes both items, so alice, who likes only the first, should + // be recommended the second. + let mut alice = + Client::with_settings(server.settings("e2e_rec_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_rec_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let shared = "e2e_rec_shared"; + let other = "e2e_rec_other"; + alice.add_interest(shared).expect("alice likes shared"); + bob.add_interest(shared).expect("bob likes shared"); + bob.add_interest(other).expect("bob likes other"); + fence(&bob, "e2e_rec_alice"); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut recommended = Vec::new(); + while Instant::now() < deadline + && !recommended + .iter() + .any(|r: &soulseek_rs::Recommendation| r.item == other) + { + alice + .request_recommendations() + .expect("ask for recommendations"); + std::thread::sleep(Duration::from_millis(250)); + recommended = + alice.recommendations().map(|(r, _)| r).unwrap_or_default(); + } + assert!( + recommended.iter().any(|r| r.item == other), + "the other item bob likes should be recommended, got {recommended:?}" + ); + + // The server-wide list (code 56) answers too, with the same shape. + let deadline = Instant::now() + Duration::from_secs(8); + let mut global = None; + while Instant::now() < deadline && global.is_none() { + alice + .request_global_recommendations() + .expect("ask for global recommendations"); + std::thread::sleep(Duration::from_millis(250)); + global = alice.global_recommendations(); + } + let (global_items, _) = global.expect("the server should answer code 56"); + assert!( + global_items.iter().any(|r| r.item == shared), + "an item two users like should show server-wide, got {global_items:?}" + ); + + // And per-item recommendations (code 111) answer for a named item. + let deadline = Instant::now() + Duration::from_secs(8); + let mut item_recs = Vec::new(); + while Instant::now() < deadline && item_recs.is_empty() { + alice + .request_item_recommendations(shared) + .expect("ask about one item"); + std::thread::sleep(Duration::from_millis(250)); + item_recs = alice.item_recommendations(shared); + } + assert!( + item_recs.iter().any(|r| r.item == other), + "people who like {shared} also like {other}, got {item_recs:?}" + ); +} + +#[test] +fn a_ping_leaves_the_session_usable() { + let server = server_or_skip!(); + + // A ping has no reply, so what it must not do is upset the session: the + // very next request has to still be answered. + let mut client = + Client::with_settings(server.settings("e2e_ping_user", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + for _ in 0..3 { + client.ping_server().expect("ping"); + std::thread::sleep(Duration::from_millis(100)); + } + + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline && !listed { + client.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + listed = !client.room_list().is_empty() + || client.session_loss().is_none() && Instant::now() > deadline; + } + assert!( + client.session_loss().is_none(), + "pings must not cost us the session" + ); +} + +#[test] +fn one_message_reaches_several_users_at_once() { + let server = server_or_skip!(); + + let mut sender = + Client::with_settings(server.settings("e2e_many_sender", "pw")); + let mut first = + Client::with_settings(server.settings("e2e_many_first", "pw")); + let mut second = + Client::with_settings(server.settings("e2e_many_second", "pw")); + for client in [&mut sender, &mut first, &mut second] { + client.connect().expect("connect"); + assert!(client.login().expect("login")); + } + + let body = "one message, two recipients"; + sender + .send_private_message_to_many( + &["e2e_many_first".to_string(), "e2e_many_second".to_string()], + body, + ) + .expect("send to many"); + + for client in [&first, &second] { + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert!(got, "every named recipient should receive the message"); + } +} + +#[test] +fn a_changed_password_is_what_the_next_login_needs() { + let server = server_or_skip!(); + + let user = "e2e_pwchange_user"; + let mut client = Client::with_settings(server.settings(user, "first-pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login registers the account")); + + client + .change_password("second-pw") + .expect("change password"); + // soulfind stores the new password once it has hashed it, and forgets the + // change if our session closes first. The session stays until a login + // with the new password shows it landed; a refused one leaves it alone. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let mut fresh = + Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + if matches!(fresh.login(), Ok(true)) { + break; + } + assert!( + Instant::now() < deadline, + "the new password must be accepted" + ); + std::thread::sleep(Duration::from_millis(100)); + } + drop(client); + + let mut stale = Client::with_settings(server.settings(user, "first-pw")); + stale.connect().expect("connect with the old password"); + assert!( + !matches!(stale.login(), Ok(true)), + "the old password must stop working" + ); +} + +#[test] +fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { + // The peer asks the server to broker a connection to us but is itself + // unreachable — it advertises a port nothing listens on. Our dial fails, + // and the peer must be told (CantConnectToPeer, code 1001) rather than + // left waiting for a connection that is never coming. + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + let mut client = + Client::with_settings(server.settings("e2e_cantconn_us", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mut peer = + login_raw(&addr, "e2e_cantconn_peer", "pw").expect("peer login"); + // A port that is free is a port nothing answers on: the dial is refused. + let dead_port = free_port().expect("free port"); + peer.write_all( + &MessageFactory::build_set_wait_port_message(dead_port).get_buffer(), + ) + .expect("set wait port"); + peer.flush().expect("flush wait port"); + + let token = 424_242_u32; + peer.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_cantconn_us", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + peer.flush().expect("flush broker request"); + + let mut reply = read_until_code(&mut peer, 1001, Duration::from_secs(20)) + .expect("the peer should be told the connection failed"); + reply.set_pointer(8); + assert_eq!( + reply.read_int32(), + token, + "the reply should quote the token the peer asked with" + ); +} + +// --------------------------------------------------------------------------- +// Features that were implemented but never driven end to end: the away +// status, the phrases the server refuses to search for, and asking a peer +// where our queued file sits. +// --------------------------------------------------------------------------- + +#[test] +fn going_away_is_visible_to_another_user() { + use soulseek_rs::types::UserStatus; + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_away_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_away_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + // Online to begin with, as the post-login handshake announces. + let status_of = |client: &Client| -> Option { + client + .user_info("e2e_away_alice") + .and_then(|info| info.presence) + .map(|presence| presence.status) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice").expect("ask"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); + + alice.set_away(true).expect("alice goes away"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && status_of(&bob) != Some(UserStatus::Away) + { + bob.request_user_info("e2e_away_alice").expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!( + status_of(&bob), + Some(UserStatus::Away), + "the away status should reach another user" + ); + + // And coming back is visible too. + alice.set_away(false).expect("alice comes back"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice") + .expect("ask once more"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); +} + +/// A minimal server that answers a login and then sends `after_login`. +/// +/// soulfind keeps its search filters in its database, which the suite has no +/// way to seed, so the excluded-phrase path is driven from a stub that speaks +/// just enough of the protocol to deliver code 160 on the wire. +fn stub_server_sending( + after_login: Vec, +) -> (String, std::thread::JoinHandle<()>) { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server"); + let addr = listener.local_addr().expect("stub addr"); + let handle = std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("stub read timeout"); + // Wait for the login (code 1) before answering it. + loop { + match read_framed(&mut stream) { + Ok(msg) if msg.get_message_code() == 1 => break, + Ok(_) => {} + Err(_) => return, + } + } + let mut ok = Message::new(); + ok.write_int32(1) + .write_int8(1) + .write_string("stub greeting") + .write_int32(0) + .write_string("") + .write_int8(0); + if stream.write_all(&ok.get_buffer()).is_err() { + return; + } + for message in after_login { + if stream.write_all(&message.get_buffer()).is_err() { + return; + } + } + let _ = stream.flush(); + // Hold the connection open so the client stays logged in. + std::thread::sleep(Duration::from_secs(10)); + }); + (format!("{}:{}", addr.ip(), addr.port()), handle) +} + +#[test] +fn phrases_the_server_excludes_are_kept_for_our_replies() { + // The server tells clients which phrases are excluded from the search + // network (code 160). Nicotine+ applies them to the files it offers in a + // search reply, not to the searches it sends, and so do we — this pins + // that the announced list arrives and is kept for that use. + // It binds listeners, so it waits for the server tests: a port handed + // to a soulfind that is still starting is otherwise up for grabs. + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut phrases = Message::new(); + phrases + .write_int32(160) + .write_int32(2) + .write_string("banned-phrase") + .write_string("blocked"); + let (addr, stub) = stub_server_sending(vec![phrases]); + let (host, port) = addr.rsplit_once(':').expect("stub addr"); + + let mut client = Client::with_settings(ClientSettings { + username: "e2e_excluded".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + host.to_string(), + port.parse().expect("stub port"), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("connect to stub"); + assert!(client.login().expect("login to stub")); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && client.excluded_search_phrases().is_empty() + { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + client.excluded_search_phrases(), + ["banned-phrase", "blocked"], + "the announced phrases should be kept" + ); + + // Our own searches are not policed by them: that is the server's job, and + // no reference client refuses a query locally. + assert!( + client + .search("a banned-phrase query", Duration::from_millis(100)) + .is_ok() + ); + + drop(client); + let _ = stub.join(); +} + +#[test] +fn a_queued_download_can_ask_where_it_sits() { + // We queue a file with a peer that never starts it, then ask where it + // sits (peer code 51). The peer's answer (code 44) must land on the + // download as its queue position. + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_place_asker", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "waiting_in_line.mp3"; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (asked_tx, asked_rx) = std::sync::mpsc::channel(); + let listen_addr = format!("127.0.0.1:{listen_port}"); + let peer = std::thread::spawn(move || -> std::io::Result<()> { + let mut p = connect_retry(&listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(15)))?; + p.write_all(&peer_init_bytes("e2e_place_peer", "P", 0))?; + p.flush()?; + let _ = ready_tx.send(()); + + // Sit on the queue request, then answer the place request with 7. + loop { + let mut msg = read_framed(&mut p)?; + // A QueueUpload (43) is accepted but never started: the file + // stays in line, which is the state being asked about. + if msg.get_message_code() == 51 { + msg.set_pointer(8); + let asked = msg.read_string(); + let mut reply = Message::new(); + reply.write_int32(44).write_string(&asked).write_int32(7); + p.write_all(&reply.get_buffer())?; + p.flush()?; + let _ = asked_tx.send(asked); + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + Ok(()) + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock peer P connection"); + std::thread::sleep(Duration::from_secs(1)); + + let download_dir = unique_download_dir(); + let (_download, _status_rx) = client + .download( + filename.to_string(), + "e2e_place_peer".to_string(), + 10, + download_dir.display().to_string(), + ) + .expect("start download"); + + // Ask, retrying until the control connection carries it. + let deadline = Instant::now() + Duration::from_secs(10); + let mut asked = None; + while Instant::now() < deadline && asked.is_none() { + client + .request_place_in_queue("e2e_place_peer", filename) + .expect("ask for our place"); + asked = asked_rx.recv_timeout(Duration::from_millis(500)).ok(); + } + assert_eq!( + asked.as_deref(), + Some(filename), + "the peer should be asked about the file we queued" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut place = None; + while Instant::now() < deadline && place.is_none() { + place = client + .get_all_downloads() + .into_iter() + .find(|d| d.filename == filename) + .and_then(|d| d.queue_position); + if place.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert_eq!( + place, + Some(7), + "the peer's answer should land on the download" + ); + + let _ = peer.join(); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_private_room_is_owned_granted_and_revoked() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_club"; + let mut owner = + Client::with_settings(server.settings("e2e_priv_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_priv_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + // An invite reaches only a user already accepting invitations. + fence(&guest, "e2e_priv_owner"); + + // The guest is invited, and hears about it (code 139). + owner + .add_room_member(room, "e2e_priv_guest") + .expect("owner invites the guest"); + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!( + granted.is_some(), + "the guest should be told they are a member" + ); + + // The owner's own roster now carries the guest (code 134). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the member roster should carry the guest, got {:?}", + owner.private_room_members(room) + ); + + // A member can be made an operator (code 145 for them, 143 for the room). + owner + .add_room_operator(room, "e2e_priv_guest") + .expect("owner promotes the guest"); + let promoted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: false, granted: true + } if r == room) + .then_some(()) + }); + assert!( + promoted.is_some(), + "the guest should be told they run the room" + ); + + // The owner's operator roster carries them too (code 143). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the operator roster should carry the guest, got {:?}", + owner.private_room_operators(room) + ); + + // And an operator can stand down themselves (code 147), which the + // owner's roster reflects. + guest + .resign_room_operatorship(room) + .expect("guest stands down"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "an operator who stood down should leave the roster, got {:?}", + owner.private_room_operators(room) + ); + + // And membership can be taken away again (code 140). + owner + .remove_room_member(room, "e2e_priv_guest") + .expect("owner removes the guest"); + let revoked = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: false + } if r == room) + .then_some(()) + }); + assert!(revoked.is_some(), "the guest should be told they are out"); +} + +#[test] +fn a_private_room_someone_else_owns_cannot_be_taken() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_taken"; + let mut owner = + Client::with_settings(server.settings("e2e_taken_owner", "pw")); + owner.connect().expect("owner connect"); + assert!(owner.login().expect("owner login")); + owner + .join_private_room(room) + .expect("owner creates the room"); + fence(&owner, "e2e_taken_outsider"); + + // An outsider asking for the same name must be refused (code 1003), not + // handed the room. + let mut outsider = + Client::with_settings(server.settings("e2e_taken_outsider", "pw")); + outsider.connect().expect("outsider connect"); + assert!(outsider.login().expect("outsider login")); + outsider + .join_private_room(room) + .expect("outsider asks for the same room"); + + let refused = + await_room_event(&outsider, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_some(), + "a private room owned by someone else must be refused" + ); + assert!( + outsider.private_rooms().is_empty(), + "a refused room must not appear as one we belong to" + ); +} + +#[test] +fn an_acknowledged_offline_message_is_not_delivered_twice() { + // Private messages sent to an offline user are stored by the server and + // handed over at the next login; the client acknowledges each one + // (MessageAcked, code 23) so the server drops it. Without that ack the + // same message arrives again on every login, which is what this pins. + let server = server_or_skip!(); + + // Register the recipient so the server will hold mail for them. + let mut recipient = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + recipient.connect().expect("recipient connect"); + assert!(recipient.login().expect("recipient login")); + drop(recipient); + // The dropped session's socket must actually be gone before the mail is + // sent, or the server delivers it live to a connection nobody is reading. + std::thread::sleep(Duration::from_secs(2)); + + let mut sender = + Client::with_settings(server.settings("e2e_ack_sender", "pw")); + sender.connect().expect("sender connect"); + assert!(sender.login().expect("sender login")); + let body = "left while you were out"; + sender + .send_private_message("e2e_ack_recipient", body) + .expect("send to an offline user"); + fence(&sender, "e2e_ack_recipient"); + + let received_once = |label: &str| -> bool { + let mut client = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + client.connect().unwrap_or_else(|e| panic!("{label}: {e}")); + assert!(client.login().expect("recipient login"), "{label}"); + let deadline = Instant::now() + Duration::from_secs(3); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + // The acknowledgement goes out ahead of the message it answers, so + // once the server has handled what followed, it has the ack too. + fence(&client, "e2e_ack_sender"); + got + }; + + assert!( + received_once("first login"), + "the stored message should arrive at the next login" + ); + assert!( + !received_once("second login"), + "an acknowledged message must not be delivered again" + ); +} + +#[test] +fn a_wishlist_search_is_answered_like_any_other() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_wishlist_talisman.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_wish_sharer", + "e2e_wish_seeker", + ); + + // A wish returns at once; its results accumulate under the query. + let query = "talisman"; + searcher + .start_wishlist_search(query) + .expect("start a wishlist search"); + + let reply = reply_from(&searcher, query, "e2e_wish_sharer") + .expect("a wishlist search should be answered like a plain one"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_wishlist_talisman")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + // The server also announces how often it will accept one. + assert!( + searcher.wishlist_interval() > Duration::ZERO, + "the wishlist interval should be a real wait" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// --------------------------------------------------------------------------- +// The other half of the tree: serving children. +// +// A client that accepts children takes `D` connections from peers, tells each +// where our branch sits, and passes every search it receives down to them. +// --------------------------------------------------------------------------- + +/// Read distributed frames from a child link until `pick` matches or time runs +/// out. +fn read_distributed_until( + stream: &mut TcpStream, + timeout: Duration, + mut pick: impl FnMut(&distributed::Distributed) -> Option, +) -> Option { + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + // read_framed already returns the frame with its length prefix, + // which is exactly what the parser expects. + let Ok(mut frame) = read_framed(stream) else { + continue; + }; + if let Some(parsed) = distributed::parse(&mut frame) + && let Some(found) = pick(&parsed) + { + return Some(found); + } + } + None +} + +/// Dial `port` as a distributed child called `username`. +fn dial_as_child(port: u16, username: &str) -> std::io::Result { + let mut child = + connect_retry(&format!("127.0.0.1:{port}"), Duration::from_secs(5))?; + child.write_all(&peer_init_bytes(username, "D", 0))?; + child.flush()?; + Ok(child) +} + +#[test] +fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut parent = Client::with_settings(ClientSettings { + accept_children: true, + ..server.listening_settings("e2e_par_parent", "pw", listen_port) + }); + parent.connect().expect("parent connect"); + assert!(parent.login().expect("parent login")); + + // A client takes children only once something feeds it the search + // stream — here the server, which relays every search to us. Until then + // a child would hang from a branch that receives nothing, which is why + // Nicotine+ refuses one too. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let search = |searcher: &mut TcpStream, token: u32| { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + }; + search(&mut searcher, 515_150); + std::thread::sleep(Duration::from_secs(2)); + + let mut child = + dial_as_child(listen_port, "e2e_par_child").expect("child dials"); + + // The child is told our branch before anything else: without it, it has + // nothing to report to the server as its own place. + let root = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchRoot(root) => Some(root.clone()), + _ => None, + }, + ) + .expect("a child should be told our branch root"); + assert_eq!( + root, "e2e_par_parent", + "a parentless client is its own root" + ); + let level = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchLevel(level) => Some(*level), + _ => None, + }, + ) + .expect("a child should be told our branch level"); + assert_eq!(level, 0); + + let deadline = Instant::now() + Duration::from_secs(5); + while parent.children().is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + + // A search the server hands us must reach the child, whether or not we + // can answer it ourselves — carrying the stream is the parent's duty. + let token = 515_151_u32; + search(&mut searcher, token); + + let relayed = read_distributed_until( + &mut child, + Duration::from_secs(20), + |f| match f { + distributed::Distributed::Search { + username, + token: got, + query, + } if *got == token => Some((username.clone(), query.clone())), + _ => None, + }, + ) + .expect("the search should be passed down to the child"); + assert_eq!( + relayed, + ( + "e2e_par_searcher".to_string(), + "e2e_child_relay_probe".to_string() + ) + ); + + // A second link from the same child is refused: the one we hold is live. + let mut duplicate = + dial_as_child(listen_port, "e2e_par_child").expect("child dials again"); + let told_again = + read_distributed_until(&mut duplicate, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told_again.is_none(), "a child we carry is not taken twice"); + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + drop(duplicate); + + // A child that hangs up loses its place. + drop(child); + let deadline = Instant::now() + Duration::from_secs(20); + let mut next_token = token + 1; + while !parent.children().is_empty() && Instant::now() < deadline { + search(&mut searcher, next_token); + next_token += 1; + std::thread::sleep(Duration::from_millis(500)); + } + assert!( + parent.children().is_empty(), + "a child that went away should not keep its slot" + ); +} + +#[test] +fn a_client_that_does_not_serve_children_turns_one_away() { + let server = server_or_skip!(); + + // The default: a leaf. A peer that dials with a D connection is dropped, + // and never told a branch it could hang from. + let listen_port = free_port().expect("free listen port"); + let mut leaf = Client::with_settings(server.listening_settings( + "e2e_leafonly", + "pw", + listen_port, + )); + leaf.connect().expect("leaf connect"); + assert!(leaf.login().expect("leaf login")); + + let mut child = + dial_as_child(listen_port, "e2e_leafonly_child").expect("child dials"); + + let told = + read_distributed_until(&mut child, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told.is_none(), "a leaf must not take on a child"); + assert!(leaf.children().is_empty()); +} + +#[test] +fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { + // "Download folder" in other clients asks for one folder (peer code 36) + // rather than pulling the peer's whole listing. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("wanted")).unwrap(); + std::fs::create_dir_all(share_dir.join("unwanted")).unwrap(); + std::fs::write(share_dir.join("wanted").join("keep.bin"), b"abcd").unwrap(); + std::fs::write(share_dir.join("unwanted").join("skip.bin"), b"efgh") + .unwrap(); + + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_folder_sharer", + "e2e_folder_asker", + ); + + // The folder is named the way the sharer advertises it: the share root's + // basename, then the subfolder, backslash-separated. + let root = share_dir.file_name().unwrap().to_string_lossy().to_string(); + let folder = format!("{root}\\wanted"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline && listing.is_none() { + asker + .request_folder_contents("e2e_folder_sharer", &folder) + .expect("ask for one folder"); + std::thread::sleep(Duration::from_millis(500)); + listing = asker.take_folder_contents("e2e_folder_sharer", &folder); + } + let listing = listing.expect("the peer should answer with that folder"); + + assert!( + listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("keep.bin"))), + "the folder we asked for should be in the answer, got {listing:?}" + ); + assert!( + !listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("skip.bin"))), + "no other folder should come with it, got {listing:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn our_interests_are_sent_again_after_a_new_login() { + // The server keeps interests only for the session that set them, so a + // client that does not send them again comes back with none — which is + // why Nicotine+ re-sends its list at every login, from its config. Ours + // holds the list itself: an interest set before there is a connection is + // still on the server once one is made. + let server = server_or_skip!(); + + let item = "e2e_relike_shoegaze"; + let mut alice = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + // Set before connecting: there is nothing to send it over yet, and the + // login is what puts it on the wire. + let _ = alice.add_interest(item); + assert_eq!(alice.own_interests().likes, [item]); + + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + std::thread::sleep(Duration::from_millis(500)); + + let mut watcher = + Client::with_settings(server.settings("e2e_relike_watch", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + let likes_of_alice = |watcher: &Client| -> Vec { + let deadline = Instant::now() + Duration::from_secs(5); + let mut likes = Vec::new(); + while Instant::now() < deadline && !likes.iter().any(|i| i == item) { + watcher + .request_user_interests("e2e_relike_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + likes = watcher + .user_interests("e2e_relike_alice") + .map(|i| i.likes) + .unwrap_or_default(); + } + likes + }; + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "the login should have carried the interest with it, got {likes:?}" + ); + + // The same client, in a new session: its list goes back up by itself. + drop(alice); + std::thread::sleep(Duration::from_secs(1)); + let mut again = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + let _ = again.add_interest(item); + again.connect().expect("reconnect"); + assert!(again.login().expect("re-login")); + std::thread::sleep(Duration::from_millis(500)); + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "a new session should carry the interests too, got {likes:?}" + ); +} + +#[test] +fn a_finished_upload_teaches_us_our_own_recorded_speed() { + // The server records a client's upload speed and pushes it to whoever + // watches that user. We watch ourselves at login, which is how our own + // figure — the one the distributed child limit is derived from — reaches + // us without anyone asking on our behalf. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..8192u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_ownspeed_probe.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let (sharer, leecher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_ownspeed_sharer", + "e2e_ownspeed_leecher", + ); + assert_eq!( + sharer.own_average_speed(), + Some(0), + "a client that has never uploaded is recorded at zero" + ); + + let query = "ownspeed"; + let _ = leecher.search(query, Duration::from_secs(3)); + let (path, size) = reply_from(&leecher, query, "e2e_ownspeed_sharer") + .and_then(|reply| reply.files.into_iter().next()) + .map(|file| (file.name, file.size)) + .expect("the leecher should find the file"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + path, + "e2e_ownspeed_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + assert!( + wait_for_completion(&leecher, &status_rx, Duration::from_secs(25)), + "the download should complete" + ); + + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline + && sharer.own_average_speed().unwrap_or(0) == 0 + { + std::thread::sleep(Duration::from_millis(250)); + } + assert!( + sharer.own_average_speed().unwrap_or(0) > 0, + "the speed the server recorded for us should reach us unasked, got {:?} (info: {:?})", + sharer.own_average_speed(), + sharer.user_info("e2e_ownspeed_sharer"), + ); + + let _ = std::fs::remove_dir_all(share_dir); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_peer_tells_us_about_itself_when_asked() { + // The profile another client shows for a user: their description, upload + // slots and queue length, straight from the peer (code 15 and its reply). + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("e2e_peerinfo.bin"), b"data").unwrap(); + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_peerinfo_sharer", + "e2e_peerinfo_asker", + ); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut info = None; + while Instant::now() < deadline && info.is_none() { + asker + .request_peer_info("e2e_peerinfo_sharer") + .expect("ask the peer about itself"); + std::thread::sleep(Duration::from_millis(500)); + info = asker.peer_info("e2e_peerinfo_sharer"); + } + let info = info.expect("the peer should answer for itself"); + + assert!( + info.slots_free, + "a peer with nothing queued has a free slot, got {info:?}" + ); + assert_eq!(info.queue_size, 0); + assert_eq!(info.picture_bytes, None); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_reports_members_arriving_and_leaving() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_comings"; + let mut alice = + Client::with_settings(server.settings("e2e_comings_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = alice.take_room_events(); + + // Bob arrives after her: she is told (code 16), and her roster grows. + let mut bob = + Client::with_settings(server.settings("e2e_comings_bob", "pw")); + bob.connect().expect("bob connect"); + assert!(bob.login().expect("bob login")); + bob.join_room(room).expect("bob joins"); + + let joined = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserJoined { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(joined.is_some(), "alice should be told bob arrived"); + assert!( + alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should carry bob, got {:?}", + alice.room_members(room) + ); + + // And when he leaves (code 17) the roster loses him again. + bob.leave_room(room).expect("bob leaves"); + let left = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserLeft { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(left.is_some(), "alice should be told bob left"); + + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline + && alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + !alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should drop him, got {:?}", + alice.room_members(room) + ); +} + +#[test] +fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_leaving"; + let mut owner = + Client::with_settings(server.settings("e2e_leave_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_leave_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + fence(&guest, "e2e_leave_owner"); + owner + .add_room_member(room, "e2e_leave_guest") + .expect("owner invites the guest"); + + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!(granted.is_some(), "the guest should be a member first"); + + // The guest gives up their own membership (code 136): the owner's roster + // loses them. + guest.leave_private_room(room).expect("guest resigns"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest"), + "a member who resigned should leave the roster, got {:?}", + owner.private_room_members(room) + ); + + // The owner disbands the room (code 137), which frees the name: someone + // else asking for it is not refused as they would be for a room that + // still belongs to another user. + owner.disband_private_room(room).expect("owner disbands"); + std::thread::sleep(Duration::from_secs(1)); + + let mut newcomer = + Client::with_settings(server.settings("e2e_leave_newcomer", "pw")); + newcomer.connect().expect("newcomer connect"); + assert!(newcomer.login().expect("newcomer login")); + newcomer + .join_private_room(room) + .expect("newcomer asks for the name"); + + let refused = + await_room_event(&newcomer, Duration::from_secs(3), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_none(), + "a disbanded room's name should be free again" + ); +} + +#[test] +fn privileges_can_be_handed_to_another_user() { + let server = server_or_skip!(); + + // Only a privileged account can give privileges away, and privileges + // themselves are granted in soulfind's own database. + let giver = "e2e_gift_giver"; + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver connect"); + assert!(giving.login().expect("giver login")); + if !grant(&server, giver, "privileges") { + println!("e2e skipped: cannot grant privileges on this server"); + return; + } + drop(giving); + std::thread::sleep(Duration::from_secs(1)); + + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver reconnect"); + assert!(giving.login().expect("giver re-login")); + + let mut receiver = + Client::with_settings(server.settings("e2e_gift_receiver", "pw")); + receiver.connect().expect("receiver connect"); + assert!(receiver.login().expect("receiver login")); + + giving + .give_privileges("e2e_gift_receiver", 1) + .expect("hand over a day of privileges"); + + // The recipient now has privilege time of their own to check (code 92). + let deadline = Instant::now() + Duration::from_secs(10); + let mut left = 0; + while Instant::now() < deadline && left == 0 { + receiver + .check_privileges() + .expect("ask how much time we have"); + std::thread::sleep(Duration::from_millis(500)); + left = receiver.own_privilege_seconds().unwrap_or(0); + } + assert!( + left > 0, + "the gifted privileges should show up as time of our own" + ); +} + +#[test] +fn a_dropped_client_has_released_its_listener_port_when_drop_returns() { + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let port = free_port().expect("a port to hold"); + + for round in 0..25 { + let mut client = Client::with_settings(ClientSettings { + username: "e2e_port_release_now".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("bind the listener"); + assert_eq!(client.listen_port(), Some(port), "round {round}"); + drop(client); + + assert!( + std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(), + "round {round}: the port is still held once drop has returned" + ); + } +} + +#[test] +fn a_cancelled_client_never_binds_its_port() { + let _gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let port = free_port().expect("a port to hold"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_bind".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + + client.cancel_handle().cancel(); + + assert!(client.connect().is_err()); + assert_eq!(client.listen_port(), None); + assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()); +} + +#[test] +fn a_cancel_ends_a_login_waiting_for_its_verdict_and_nothing_follows_it() { + let server = + std::net::TcpListener::bind("127.0.0.1:0").expect("a silent server"); + let mut client = Client::with_settings(ClientSettings { + username: "e2e_cancelled_login".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + "127.0.0.1".to_string(), + server.local_addr().expect("server address").port(), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("start the session"); + let (mut conn, _) = server.accept().expect("the client dials the server"); + conn.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + let cancel = client.cancel_handle(); + + std::thread::scope(|scope| { + let login = scope.spawn(|| client.login()); + let frame = + read_framed(&mut conn).expect("the login reaches the server"); + assert_eq!(frame.get_message_code(), 1); + + let cancelled_at = Instant::now(); + cancel.cancel(); + assert!(login.join().expect("the login thread").is_err()); + assert!(cancelled_at.elapsed() < Duration::from_secs(5)); + }); + client + .connect_peer("e2e_nobody") + .expect("the actor takes a request"); + drop(client); + + let mut rest = Vec::new(); + assert_eq!( + conn.read_to_end(&mut rest) + .expect("the server sees the session end"), + 0 + ); +} + +#[test] +fn a_firewalled_downloader_still_receives_the_upload() { + // Issue #102: the leecher cannot be dialled (no listener, as behind a NAT), + // so the sharer must ask the server to broker the F connection and serve + // the file over the leecher's PierceFirewall connection. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_probe_fwleech.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings( + "e2e_fw_sharer", + "pw", + free_port().expect("sharer port"), + ) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let mut leecher = + Client::with_settings(server.settings("e2e_fw_leecher", "pw")); + leecher.connect().expect("leecher connect"); + assert!(leecher.login().expect("leecher login")); + std::thread::sleep(Duration::from_secs(1)); + + let query = "fwleech"; + let _ = leecher.search(query, Duration::from_secs(3)); + let (result_path, size) = reply_from(&leecher, query, "e2e_fw_sharer") + .and_then(|reply| reply.files.into_iter().next()) + .map(|file| (file.name, file.size)) + .expect("leecher should find the sharer's file"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + result_path.clone(), + "e2e_fw_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + + let completed = + wait_for_completion(&leecher, &status_rx, Duration::from_secs(40)); + assert!( + completed, + "the download to a firewalled leecher should complete" + ); + + let basename = result_path.rsplit(['\\', '/']).next().unwrap(); + assert_eq!( + std::fs::read(download_dir.join(basename)).expect("downloaded file"), + content + ); + let _ = std::fs::remove_dir_all(&download_dir); + let _ = std::fs::remove_dir_all(&share_dir); +} diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs new file mode 100644 index 0000000..89ef307 --- /dev/null +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -0,0 +1,412 @@ +//! 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 { + Message::new() + .write_int8(1) + .write_string(username) + .write_string(connection_type) + .write_int32(token) + .get_buffer() +} + +fn read_framed(stream: &mut TcpStream) -> std::io::Result { + let mut len = [0u8; 4]; + stream.read_exact(&mut len)?; + let mut payload = vec![0u8; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut payload)?; + let mut bytes = len.to_vec(); + bytes.extend_from_slice(&payload); + Ok(Message::new_with_data(bytes)) +} + +fn expect_code(stream: &mut TcpStream, expected: u32) -> Message { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + let message = read_framed(stream).unwrap_or_else(|error| { + panic!("read peer code {expected}: {error}") + }); + if message.get_message_code() == expected { + return message; + } + assert!( + Instant::now() < deadline, + "timed out waiting for peer code {expected}" + ); + } +} + +fn start_download( + subject: &Subject, + control: &mut ControlPeer, + filename: &str, + content_len: usize, + destination: &Path, +) -> (Receiver, TcpStream, u64) { + let (_download, statuses) = subject + .client + .download( + filename.to_string(), + control.username.clone(), + content_len as u64, + destination.display().to_string(), + ) + .expect("queue download"); + let token = control.accept_queue_and_offer(filename, content_len as u64); + let (file_stream, offset) = control.open_file_transfer(token); + (statuses, file_stream, offset) +} + +fn wait_for_status( + statuses: &Receiver, + description: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) -> DownloadStatus { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + if let Ok(status) = statuses.recv_timeout(Duration::from_millis(100)) + && wanted(&status) + { + return status; + } + assert!( + Instant::now() < deadline, + "status channel did not report {description}" + ); + } +} + +fn assert_stored_status( + subject: &Subject, + filename: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) { + let status = subject + .client + .get_all_downloads() + .into_iter() + .find(|download| download.filename == filename) + .unwrap_or_else(|| panic!("download {filename} is missing from store")) + .status; + assert!(wanted(&status), "unexpected stored status: {status:?}"); +} + +fn expect_downloader_close(stream: &mut TcpStream) { + let mut unexpected = [0u8; 1]; + assert_eq!( + stream + .read(&mut unexpected) + .expect("wait for downloader to close the F socket"), + 0, + "the downloader sends no bytes after its offset" + ); +} + +fn binary_payload(len: usize) -> Vec { + (0..len).map(|index| index as u8).collect() +} + +#[test] +fn an_actual_short_transfer_resumes_opaque_bytes() { + let subject = Subject::new("resume_binary_subject"); + let mut control = ControlPeer::register(&subject, "resume_binary_peer"); + let destination = ScratchDir::new("binary-tracer"); + let content = binary_payload(3 * 64 * 1024 + 257); + let filename = "opaque.bin"; + let interrupt_at = 64 * 1024 + 1; + let part_path = destination.path().join(format!("{filename}.part")); + let final_path = destination.path().join(filename); + + assert!(content.contains(&0)); + assert!(content.contains(&u8::MAX)); + assert!(std::str::from_utf8(&content).is_err()); + + let (first_statuses, mut first, first_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(first_offset, 0); + first + .write_all(&content[..interrupt_at]) + .expect("write short first transfer"); + first.flush().expect("flush short first transfer"); + first + .shutdown(Shutdown::Write) + .expect("interrupt first transfer"); + drop(first); + + let _ = wait_for_status(&first_statuses, "Failed", |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert!(!final_path.exists()); + assert_eq!( + fs::read(&part_path).expect("read retained partial file"), + content[..interrupt_at] + ); + + let (retry_statuses, mut retry, retry_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(retry_offset, interrupt_at as u64); + retry + .write_all(&content[interrupt_at..]) + .expect("write retry tail"); + retry.flush().expect("flush retry tail"); + expect_downloader_close(&mut retry); + drop(retry); + + let _ = wait_for_status(&retry_statuses, "Completed", |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_eq!(fs::read(&final_path).unwrap(), content); + assert!(!part_path.exists()); +} + +#[test] +fn a_live_download_pauses_and_resumes_on_the_same_connection() { + let subject = Subject::new("resume_pause_subject"); + let mut control = ControlPeer::register(&subject, "resume_pause_peer"); + let destination = ScratchDir::new("pause-resume"); + let filename = "paused.bin"; + let content = binary_payload(5 * 64 * 1024 + 257); + let part_path = destination.path().join(format!("{filename}.part")); + + let (statuses, mut peer, offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(offset, 0); + + let first_chunk = 2 * 64 * 1024; + peer.write_all(&content[..first_chunk]) + .expect("send first chunk"); + peer.flush().expect("flush first chunk"); + let _ = wait_for_status(&statuses, "initial progress", |status| { + matches!( + status, + DownloadStatus::InProgress { + bytes_downloaded, + .. + } if *bytes_downloaded >= first_chunk as u64 + ) + }); + + assert!(subject.client.pause_download(&control.username, filename)); + let _ = wait_for_status(&statuses, "Paused", |status| { + matches!(status, DownloadStatus::Paused { .. }) + }); + + // The pause is checked before every read, so once it lands only the read + // already in flight can finish: one 64 KiB buffer at most. Queue more + // than that, which a transfer ignoring the pause would take all of. + let stable_len = fs::metadata(&part_path).expect("paused partial").len(); + let in_flight = 64 * 1024; + let queued_while_paused = in_flight + 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)); + let paused_len = fs::metadata(&part_path).expect("paused partial").len(); + assert!( + paused_len <= stable_len + in_flight as u64, + "a paused transfer must stop consuming its socket: {paused_len} \ + bytes after pausing at {stable_len}" + ); + + 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 + ); +} diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml new file mode 100644 index 0000000..3c2e581 --- /dev/null +++ b/soulseek-rs/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "soulseek-rs" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Command-line client for Soulseek protocol" +keywords = ["soulseek", "p2p", "client", "file-sharing"] +categories = ["command-line-utilities"] + +[[bin]] +name = "soulseek-rs" +path = "src/main.rs" + +# Small library target so integration tests can exercise the persistence +# pipeline; the dependency soulseek-rs-lib already owns the `soulseek_rs` +# crate name, hence the distinct one. +[lib] +name = "soulseek_rs_tui" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +soulseek-rs-lib = { version = "19.1.0", path = "../soulseek-rs-lib" } + +clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } +clap_complete = "4.6.7" +clap_mangen = "0.3.3" +ratatui = "0.30.2" +unicode-width = "0.2" +color-eyre = "0.6.5" +dotenv = "0.15.0" +igd-next = "0.17" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" +directories = "6" +# Linux uses the pure-Rust zbus Secret Service backend (async-secret-service +# + async-io + crypto-rust) instead of sync-secret-service, which links the +# system libdbus and breaks builds without libdbus-1-dev. +keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } +getrandom = "0.4.3" + +[dev-dependencies] +schemars = "1.2.1" +tempfile = "3" + +[target."cfg(unix)".dependencies] +libc = "0.2.189" diff --git a/soulseek-rs/README.md b/soulseek-rs/README.md new file mode 100644 index 0000000..3cac306 --- /dev/null +++ b/soulseek-rs/README.md @@ -0,0 +1,71 @@ +# soulseek-rs + +A Soulseek client for the terminal: a full-screen TUI, and a one-shot command +surface built for scripts and agents. + +Website: + +## Installation + +```bash +brew install michel/tap/soulseek-rs # macOS and Linux, prebuilt +cargo install soulseek-rs # anywhere with a Rust toolchain +``` + +## Usage + +Run it with no subcommand for the interactive TUI: + +```bash +soulseek-rs +``` + +Anything else is a one-shot command that runs headless. stdout carries records +only, stderr carries progress, and the exit code carries the verdict: + +```bash +soulseek-rs search # print matching files +soulseek-rs get # search, pick the best, download +soulseek-rs download # fetch one known file +soulseek-rs download --stdin # fetch files listed on stdin +soulseek-rs browse # list a user's shared files +soulseek-rs serve [--follow] # stay online sharing, stream uploads +soulseek-rs whoami # confirm credentials and connection +soulseek-rs user # a peer's status and share counts +soulseek-rs room list|say|users|listen +soulseek-rs message send|read +soulseek-rs shares list|add|remove|status|reindex +soulseek-rs config path|list|get|set +soulseek-rs portmap # test automatic port mapping +soulseek-rs completions install # tab completion for bash/zsh/fish +``` + +Add `--json` for newline-delimited JSON instead of tab-separated text, so +commands compose: + +```bash +soulseek-rs search "rjd2" --json \ + | jq -c 'select(.size > 5000000 and .free_slot)' \ + | soulseek-rs download --stdin +``` + +Exit codes: `0` success, `2` usage or config, `3` connect or login, `4` nothing +found, `5` timed out, `6` transfer failed, `7` session lost mid-command, `1` +unexpected error. + +The [full README](https://github.com/michel/soulseek-rs#readme) documents every +command, record shape, flag, environment variable and `config.toml` key. + +## Building from Source + +```bash +git clone https://github.com/michel/soulseek-rs.git +cd soulseek-rs +cargo build --release +``` + +## Library + +To build your own Soulseek client, use the +[soulseek-rs-lib](https://crates.io/crates/soulseek-rs-lib) crate: the protocol +implementation this client is built on. diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json new file mode 100644 index 0000000..cbf59a6 --- /dev/null +++ b/soulseek-rs/docs/openrpc.json @@ -0,0 +1,2554 @@ +{ + "components": { + "schemas": { + "Ack": { + "description": "A void result. JSON-RPC allows a null result, but an object leaves room to\nadd a field later without changing the shape a client already parses.", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "AuthParams": { + "properties": { + "protocol": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "token": { + "description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AuthResult": { + "properties": { + "daemon_version": { + "type": "string" + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "description": "The Soulseek account the daemon is logged in as.", + "type": "string" + } + }, + "required": [ + "protocol", + "daemon_version", + "username" + ], + "type": "object" + }, + "BrowseEvent": { + "description": "A peer answered a browse request. Carried whole rather than as a \"go ask\nfor it\" nudge, because the daemon's own copy is consumed when it drains.", + "properties": { + "directories": { + "items": { + "$ref": "#/components/schemas/SharedDirectoryDto" + }, + "type": "array" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "directories" + ], + "type": "object" + }, + "ChatMessageDto": { + "description": "One line of chat history. Unlike a live [`UserMessageDto`] this carries a\ndirection, because a conversation the daemon collected includes what this\naccount sent as well as what it received.", + "properties": { + "at": { + "description": "Unix seconds.", + "format": "int64", + "type": "integer" + }, + "outgoing": { + "type": "boolean" + }, + "peer": { + "description": "The other party, whichever way the message went.", + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "peer", + "outgoing", + "text", + "at" + ], + "type": "object" + }, + "DaemonStatus": { + "description": "What the daemon is and what its session is doing, from `daemon.status`.", + "properties": { + "clients": { + "description": "Control connections currently attached.", + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "daemon_version": { + "type": "string" + }, + "download_dir": { + "type": "string" + }, + "listen_port": { + "default": null, + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "server": { + "type": "string" + }, + "session_loss": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLossDto" + }, + { + "type": "null" + } + ], + "default": null + }, + "shared_files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "uptime_secs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "server", + "daemon_version", + "protocol", + "shared_folders", + "shared_files", + "download_dir", + "clients", + "uptime_secs" + ], + "type": "object" + }, + "DirectoriesParams": { + "properties": { + "directories": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "directories" + ], + "type": "object" + }, + "DirectoryParams": { + "description": "Where the daemon should land transfers from now on, on *its* filesystem.\nThe same trust as `shares.set`: an authenticated caller is the owner.", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "type": "object" + }, + "DownloadDto": { + "properties": { + "download_directory": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } + }, + "queue_position": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/DownloadStatusDto" + }, + "token": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "token", + "size", + "download_directory", + "status" + ], + "type": "object" + }, + "DownloadMetadataDto": { + "properties": { + "bitrate": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "length_seconds": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "peer_free_slots": { + "default": null, + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "peer_upload_speed": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "DownloadStartParams": { + "properties": { + "filename": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } + }, + "size": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename" + ], + "type": "object" + }, + "DownloadStarted": { + "properties": { + "download": { + "$ref": "#/components/schemas/DownloadDto" + } + }, + "required": [ + "download" + ], + "type": "object" + }, + "DownloadStatusDto": { + "oneOf": [ + { + "properties": { + "state": { + "const": "queued", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "bytes_downloaded": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "speed_bytes_per_sec": { + "format": "double", + "type": "number" + }, + "state": { + "const": "in_progress", + "type": "string" + }, + "total_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "state", + "bytes_downloaded", + "total_bytes", + "speed_bytes_per_sec" + ], + "type": "object" + }, + { + "properties": { + "bytes_downloaded": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state": { + "const": "paused", + "type": "string" + }, + "total_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "state", + "bytes_downloaded", + "total_bytes" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "completed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "cancelled", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "state": { + "const": "failed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "timed_out", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + } + ] + }, + "DownloadStatusEvent": { + "description": "One step in a transfer this client started.", + "properties": { + "filename": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/DownloadStatusDto" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "status" + ], + "type": "object" + }, + "Downloads": { + "properties": { + "downloads": { + "items": { + "$ref": "#/components/schemas/DownloadDto" + }, + "type": "array" + } + }, + "required": [ + "downloads" + ], + "type": "object" + }, + "FileDto": { + "properties": { + "attribs": { + "additionalProperties": false, + "description": "Soulseek attribute codes (0 = bitrate, 1 = duration in seconds).", + "patternProperties": { + "^\\d+$": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "size", + "attribs" + ], + "type": "object" + }, + "IntervalSeconds": { + "properties": { + "seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "seconds" + ], + "type": "object" + }, + "MemberStats": { + "description": "What the server said about each member of a room when it was joined.", + "properties": { + "members": { + "items": { + "$ref": "#/components/schemas/RoomUserStatsDto" + }, + "type": "array" + } + }, + "required": [ + "members" + ], + "type": "object" + }, + "Members": { + "properties": { + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + }, + "MessageParams": { + "properties": { + "message": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "message" + ], + "type": "object" + }, + "Messages": { + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/ChatMessageDto" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "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": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "RoomEventDto": { + "oneOf": [ + { + "properties": { + "event": { + "const": "list", + "type": "string" + }, + "rooms": { + "items": { + "$ref": "#/components/schemas/RoomInfoDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "rooms" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "joined", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "left", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "user_joined", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "user_left", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "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" + } + ] + }, + "RoomInfoDto": { + "properties": { + "name": { + "type": "string" + }, + "user_count": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "user_count" + ], + "type": "object" + }, + "RoomRef": { + "properties": { + "room": { + "type": "string" + } + }, + "required": [ + "room" + ], + "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": { + "average_speed": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "shared_files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "slots_full": { + "description": "True when the member's upload slots are all occupied.", + "type": "boolean" + }, + "status": { + "description": "`online`, `away`, or `offline`.", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "status", + "average_speed", + "shared_files", + "shared_folders", + "slots_full" + ], + "type": "object" + }, + "SayParams": { + "properties": { + "message": { + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "room", + "message" + ], + "type": "object" + }, + "SearchResultDto": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/FileDto" + }, + "type": "array" + }, + "slots": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "speed": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "token": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "token", + "username", + "slots", + "speed", + "files" + ], + "type": "object" + }, + "SearchResults": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/SearchResultDto" + }, + "type": "array" + } + }, + "required": [ + "results" + ], + "type": "object" + }, + "SearchSummary": { + "description": "One search as the daemon sees it, for a client that did not run it.", + "properties": { + "files": { + "description": "Files found so far, across every peer that has answered.", + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "query": { + "type": "string" + }, + "started_secs_ago": { + "description": "How long ago the query went out. A client compares this with its own\nsearch window to decide whether the search is still collecting.", + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "query", + "files", + "started_secs_ago" + ], + "type": "object" + }, + "Searches": { + "description": "Every search this session has run, newest last. Shared by all clients, so\na second one attaching sees what the first has been looking for.", + "properties": { + "searches": { + "items": { + "$ref": "#/components/schemas/SearchSummary" + }, + "type": "array" + } + }, + "required": [ + "searches" + ], + "type": "object" + }, + "Seconds": { + "properties": { + "seconds": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SessionLossDto": { + "enum": [ + "displaced", + "disconnected" + ], + "type": "string" + }, + "SessionLossEvent": { + "properties": { + "loss": { + "$ref": "#/components/schemas/SessionLossDto" + } + }, + "required": [ + "loss" + ], + "type": "object" + }, + "SharedDirectoryDto": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/SharedFileEntryDto" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "files" + ], + "type": "object" + }, + "SharedFileEntryDto": { + "properties": { + "attributes": { + "description": "`(code, value)` audio attributes the owner advertises.", + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + ], + "type": "array" + }, + "type": "array" + }, + "name": { + "description": "Basename; join with the directory to get a downloadable path.", + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "size", + "attributes" + ], + "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": { + "directories": { + "items": { + "type": "string" + }, + "type": "array" + }, + "files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "folders", + "files", + "directories" + ], + "type": "object" + }, + "SlotsParams": { + "properties": { + "slots": { + "format": "uint", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "slots" + ], + "type": "object" + }, + "TransferRef": { + "description": "Names one transfer, which is how both directions are addressed.", + "properties": { + "filename": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename" + ], + "type": "object" + }, + "UploadInfoDto": { + "properties": { + "bytes_sent": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "filename": { + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "speed_bytes_per_sec": { + "format": "double", + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/UploadStatusDto" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "size", + "bytes_sent", + "status", + "speed_bytes_per_sec" + ], + "type": "object" + }, + "UploadStatusDto": { + "oneOf": [ + { + "description": "Waiting for a slot, at this 1-based place in the queue.", + "properties": { + "place": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "state": { + "const": "queued", + "type": "string" + } + }, + "required": [ + "state", + "place" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "in_progress", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "completed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "cancelled", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "reason": { + "type": "string" + }, + "state": { + "const": "failed", + "type": "string" + } + }, + "required": [ + "state", + "reason" + ], + "type": "object" + } + ] + }, + "Uploads": { + "properties": { + "uploads": { + "items": { + "$ref": "#/components/schemas/UploadInfoDto" + }, + "type": "array" + } + }, + "required": [ + "uploads" + ], + "type": "object" + }, + "UserInfoDto": { + "description": "Each half is `None` until its reply lands, so a consumer can tell \"the\nserver said offline\" from \"the server has not answered yet\".", + "properties": { + "presence": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserPresenceDto" + }, + { + "type": "null" + } + ], + "default": null + }, + "stats": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserStatsDto" + }, + { + "type": "null" + } + ], + "default": null + }, + "username": { + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "UserMessageDto": { + "properties": { + "id": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "message": { + "type": "string" + }, + "new_message": { + "type": "boolean" + }, + "timestamp": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "timestamp", + "username", + "message", + "new_message" + ], + "type": "object" + }, + "UserPresenceDto": { + "properties": { + "privileged": { + "type": "boolean" + }, + "status": { + "description": "`online`, `away`, or `offline`.", + "type": "string" + } + }, + "required": [ + "status", + "privileged" + ], + "type": "object" + }, + "UserRef": { + "properties": { + "username": { + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "UserResult": { + "properties": { + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserInfoDto" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "UserStatsDto": { + "properties": { + "average_speed": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "average_speed", + "shared_files", + "shared_folders" + ], + "type": "object" + }, + "Watched": { + "description": "Everyone this session is watching, sorted by name.", + "properties": { + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + } + } + }, + "info": { + "description": "Control a running soulseek-rs daemon. Newline-delimited JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or TCP (remote, authenticated by the token in /daemon.token). `auth` must be the first request on a connection. Errors carry the CLI exit status in `error.data.exit`. Entries marked `x-notification` are pushed by the daemon and are never requested.", + "license": { + "name": "MIT" + }, + "title": "soulseek-rs daemon", + "version": "2" + }, + "methods": [ + { + "name": "auth", + "paramStructure": "by-name", + "params": [ + { + "name": "protocol", + "required": false, + "schema": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + { + "name": "token", + "required": false, + "schema": { + "description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.", + "type": [ + "string", + "null" + ] + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AuthResult" + } + }, + "summary": "Authenticate. Must be the first request on a connection." + }, + { + "name": "daemon.status", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DaemonStatus" + } + }, + "summary": "Who the daemon is logged in as and what its session is doing." + }, + { + "name": "daemon.stop", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the daemon to shut down." + }, + { + "name": "rpc.discover", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "type": "object" + } + }, + "summary": "This document." + }, + { + "name": "search.start", + "paramStructure": "by-name", + "params": [ + { + "name": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Put a search on the wire and return at once; results accumulate." + }, + { + "name": "search.results", + "paramStructure": "by-name", + "params": [ + { + "name": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SearchResults" + } + }, + "summary": "Everything that has answered a search so far." + }, + { + "name": "search.list", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Searches" + } + }, + "summary": "Every search this session has run, shared by all clients." + }, + { + "name": "search.forget", + "paramStructure": "by-name", + "params": [ + { + "name": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Drop a search and its results, for every client at once." + }, + { + "name": "search.wishlist", + "paramStructure": "by-name", + "params": [ + { + "name": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Send a query as a wishlist search (server code 103)." + }, + { + "name": "search.wishlist_interval", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/IntervalSeconds" + } + }, + "summary": "How long the server wants between wishlist searches." + }, + { + "name": "download.start", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "metadata", + "required": false, + "schema": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } + } + }, + { + "name": "size", + "required": false, + "schema": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DownloadStarted" + } + }, + "summary": "Queue a transfer. Progress arrives as event.download_status." + }, + { + "name": "download.list", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Downloads" + } + }, + "summary": "Every transfer the daemon knows about." + }, + { + "name": "download.pause", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Pause a transfer in progress." + }, + { + "name": "download.resume", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Resume a paused transfer." + }, + { + "name": "download.remove", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Forget a transfer entirely." + }, + { + "name": "download.remove_queued", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Drop a transfer that has not started." + }, + { + "name": "download.cancel", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Stop a download, queued or in progress; its partial file is deleted." + }, + { + "name": "download.set_dir", + "paramStructure": "by-name", + "params": [ + { + "name": "directory", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Land future transfers in a different folder on the daemon's host." + }, + { + "name": "upload.list", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Uploads" + } + }, + "summary": "Uploads being served, active first." + }, + { + "name": "upload.cancel", + "paramStructure": "by-name", + "params": [ + { + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Stop serving an upload in progress." + }, + { + "name": "upload.slots", + "paramStructure": "by-name", + "params": [ + { + "name": "slots", + "required": true, + "schema": { + "format": "uint", + "minimum": 0, + "type": "integer" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "How many uploads may run at once." + }, + { + "name": "privileges.check", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server for this account's privilege time." + }, + { + "name": "privileges.own", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Seconds" + } + }, + "summary": "Privilege seconds left, or null if the server has not answered." + }, + { + "name": "room.list_request", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server for the public room list; it arrives as event.room." + }, + { + "name": "room.join", + "paramStructure": "by-name", + "params": [ + { + "name": "room", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Join a chat room." + }, + { + "name": "room.leave", + "paramStructure": "by-name", + "params": [ + { + "name": "room", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Leave a chat room." + }, + { + "name": "room.say", + "paramStructure": "by-name", + "params": [ + { + "name": "message", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "room", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Post a message to a room." + }, + { + "name": "room.members", + "paramStructure": "by-name", + "params": [ + { + "name": "room", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Members" + } + }, + "summary": "Who is currently in a room." + }, + { + "name": "room.member_stats", + "paramStructure": "by-name", + "params": [ + { + "name": "room", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MemberStats" + } + }, + "summary": "What the server said about each member of a joined room." + }, + { + "name": "message.send", + "paramStructure": "by-name", + "params": [ + { + "name": "message", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Send a private message." + }, + { + "name": "message.history", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Messages" + } + }, + "summary": "Private messages the daemon collected, including while nobody was attached." + }, + { + "name": "browse.user", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask a peer for its share listing; it arrives as event.browse." + }, + { + "name": "user.request", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server about a user; poll user.info for the answer." + }, + { + "name": "user.info", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/UserResult" + } + }, + "summary": "What the server has said about a user so far." + }, + { + "name": "user.watch", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Watch a user, so the server keeps pushing their status." + }, + { + "name": "user.unwatch", + "paramStructure": "by-name", + "params": [ + { + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Stop watching a user." + }, + { + "name": "user.watched", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Watched" + } + }, + "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", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "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", + "params": [ + { + "name": "directories", + "required": true, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "summary": "Replace the shared folders and re-index." + }, + { + "name": "shares.reindex", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "summary": "Re-scan the configured folders." + }, + { + "name": "event.room", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/RoomEventDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "Something happened in a room this session has joined.", + "x-notification": true + }, + { + "name": "event.message", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserMessageDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A private message arrived.", + "x-notification": true + }, + { + "name": "event.upload", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/UploadInfoDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "An upload changed state.", + "x-notification": true + }, + { + "name": "event.download_status", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/DownloadStatusEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A transfer moved.", + "x-notification": true + }, + { + "name": "event.browse", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/BrowseEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A peer answered a browse request.", + "x-notification": true + }, + { + "name": "event.session_loss", + "paramStructure": "by-name", + "params": [ + { + "name": "payload", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionLossEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "The server session was lost. A disconnected session is reconnected on its own and events resume; a displaced one is terminal. Read daemon.status for the live state.", + "x-notification": true + } + ], + "openrpc": "1.3.2" +} diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md new file mode 100644 index 0000000..387db6d --- /dev/null +++ b/soulseek-rs/src/SKILL.md @@ -0,0 +1,560 @@ +--- +name: soulseek-rs +description: >- + Search for, download, and share files on the Soulseek peer-to-peer network + using the soulseek-rs command-line client. Use when the user wants to find or + fetch music or other files from Soulseek, list what a peer shares, check + whether a peer is online, read or post in Soulseek chat rooms, send private + messages, stay online serving uploads, or run soulseek-rs as a background + service that several commands share. +--- + +# soulseek-rs + +A Soulseek client whose subcommands are built to be driven by a program. Run +`soulseek-rs` with no subcommand and it launches an interactive terminal UI — +never do that; it needs a real terminal and never returns. + +## Start a daemon first + +Before anything else, make sure one is running: + +```bash +soulseek-rs daemon status --json >/dev/null 2>&1 || { + soulseek-rs daemon >/dev/null 2>&1 & + for _ in $(seq 30); do + soulseek-rs daemon status --json >/dev/null 2>&1 && break + sleep 1 + done +} +``` + +Wait for it, do not just launch it. A daemon takes a second or two to log in, +and `soulseek-rs daemon status … || soulseek-rs daemon &` does **not** do this. +`&` backgrounds the whole list, so it returns instantly and the next command +races the login it is supposed to be using. A command that arrives too early +finds no daemon and quietly opens a session of its own, which is the outcome +this whole section exists to avoid. + +Then use every other command exactly as documented below. They find the daemon +by themselves. It listens on a Unix socket only your user can open, so there is +nothing to configure, no address to pass, and no token to handle. + +**Why this matters, and why it is not optional for you.** Soulseek is a real +network of other people's computers, and you arrive on it under the user's one +account. That name is their standing: peers see what it shares, add it to their +lists, and queue it again later. The server allows **one session per account**, +so without a daemon every invocation is its own login and two at once silently +cut each other off (exit 7). Never work around that by inventing usernames — an +account per command is churn on infrastructure other people maintain, and it +throws away the standing the user built. + +A daemon is one login for as long as you work, however many commands you run +against it. It also keeps the user's shared folders indexed and reachable +instead of re-scanning them on every invocation, and downloads it starts +outlive the command that asked for them, so a transfer survives the process +that queued it. + +So: **one daemon, then as many commands as you like.** Reach for `--no-daemon` +only when you deliberately want an isolated session, and expect to justify it. + +Two things change while a daemon is running: files land in the *daemon's* +download directory (`daemon status` reports which), and `shares add` or +`config set download_dir` update it straight away rather than at its next +start. + +## The contract + +Always pass `--json`. It is a global flag, so it works on every subcommand and +in any position. + +- **stdout is data.** One JSON object per line, and nothing else ever. +- **stderr is progress.** Narration and warnings. Never parse it. `--quiet` + silences it. +- **The exit code is the verdict.** Branch on it, not on the text. + +Without `--json` the same records print as tab-separated fields. Do not parse +that form; the columns are a subset of the JSON keys. + +## Exit codes + +| Code | Meaning | What to do | +| ---- | ------- | ---------- | +| 0 | Success | — | +| 1 | Unexpected failure | Report it; do not retry blindly | +| 2 | Bad arguments, missing credentials, unusable config | Fix the invocation; run `whoami` to check credentials | +| 3 | Could not reach the server, or the login was rejected | Network or password problem, not a query problem | +| 4 | It worked and found nothing | Widen or correct the query — retrying it verbatim will not help | +| 5 | Gave up waiting | Raise `--timeout`, or `--search-timeout` for searches | +| 6 | A transfer started and did not finish | The peer stalled; try another result | +| 7 | The session ended mid-command (usually another login took the username) | Nothing this run saw is reliable; start a daemon so concurrent runs share the one login, then retry — never invent a second username | + +## Commands + +Each entry lists the keys of the objects it prints. + +**`search `** — one object per matching file: +`user`, `path`, `size`, `bitrate`, `duration`, `free_slot`, `slots`, `speed`. +Filters: `--min-bitrate `, `--free-slots`, `--exclude ` (repeatable), +`--extension ` (repeatable, dot optional), `--min-size `, +`--max-size `. Also `-n/--limit` (0 means all) and +`--sort best|size|bitrate|speed`. Every filter given has to be satisfied. +Collects results for the whole `--search-timeout` window before printing +anything. + +**`download [--size N]`** or **`download --stdin`** — one object +per finished transfer: `user`, `path`, `size`, `file` (the local path written). +`--stdin` consumes `search --json` output as-is. A transfer that fails leaves a +`.part` behind; re-running the same command resumes from it rather than +starting over, so retrying a failed download is cheap. + +**`transfer cancel `** — stop a download (queued or in progress; +its `.part` is deleted) or an upload being served. Prints nothing; exit 0 +is the confirmation, exit 4 means nothing by that name was in flight. Only +useful against a daemon, since a one-shot `download` ends with its own run. + +**`get `** — search, pick, and download in one step. `--pick +best|first|all`, `-n/--limit`, plus every filter `search` accepts. Use it when +any copy will do. It ranks on free slot, bitrate and peer speed, so it cannot +tell a remix from its original: for a named track or version, search and choose +yourself. + +**`wish list`** — one object per standing search: `query`. +**`wish add `** and **`wish remove `** — change the wishlist in +the config file. `add` prints the stored `query`; `remove` prints nothing. A +repeat of an existing wish is not an error. +**`wish run`** — search every stored wish once and print `search`-shaped +objects, so the output pipes straight into `download --stdin`. `--only ` +runs one stored wish (it must already be on the list), and every `search` filter +applies. Exit 4 when no wish matched anything. Use this when the user wants +something that is not on the network right now: add it as a wish, then run the +wishlist later rather than repeating the same search. + +**`browse `** — one object per file the peer shares: `user`, `directory`, +`path`, `size`, `bitrate`, `duration`. The last two are `null` when the peer +advertises no audio attributes for the file. + +**`user `** — one object: `user`, `status` (`online`/`away`/`offline`), +`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may +be `null` when the server has not answered that half — `null` means unknown, +not offline. + +**`watch add `** / **`watch remove `** — prints nothing; exit 0 is +the confirmation. **`watch list`** — one object per watched user: `user`, +`status`. A watch lasts only as long as the session holding it, so these are +worth using against a running daemon (`--daemon`), where the connection stays +up and keeps receiving the server's status pushes. A one-shot run's watch ends +with the run. + +**`whoami`** — one object: `user`, `server`, `listening`, `listen_port`, +`shared_folders`, `shared_files`, `download_dir`, `privilege_seconds`. The +cheapest way to prove the credentials and connection work. +`privilege_seconds` is 0 for an ordinary account and `null` only when the +server did not answer — those are different facts, so do not treat null as 0. + +**`account password --new-password `** (or +`--new-password-stdin`, which reads the first line of stdin and keeps the +password out of `ps`) — one object: `user`, `action` (`password-changed`). +The server never confirms a password change, so exit 0 means the request went +out. The new password is stored in this machine's keychain, or the daemon's +when the run is routed to one. + +**`account logout`** — one object: `user`, `action` (`logged-out`). Forgets +the password stored on this machine; the account itself is untouched, and the +next start asks for it again. + +**`room list`** — `room`, `users`. +**`room users `** — `room`, `user`, `status`, `average_speed`, +`shared_files`, `shared_folders`, `slots_full`, `country`. Everything but +`room` and `user` is `null` when the server sent no statistics for that +member — `null` means unknown, not zero. `slots_full` is true when the peer's +upload slots are all occupied, so a download from them will queue. +**`room listen `** — streams `type`, `room`, `user`, `message`. +**`room say `** — prints nothing; exit 0 is the confirmation. + +**`message read`** — streams `timestamp`, `user`, `message`. +**`message send `** — prints nothing; exit 0 is the confirmation. + +**`serve`** — stay online sharing files, one object per upload change: `user`, +`path`, `size`, `bytes_sent`, `speed`, `status` +(`queued`/`uploading`/`completed`/`cancelled`/`failed`), `reason`. A `queued` +record's `reason` is its place in the queue. `--upload-slots N` sets how many +uploads run at once (2 by default); the rest wait, with users the server lists +as privileged served first. With `--wishlist` it also re-runs the wishlist on +the interval the server sets and prints `search`-shaped objects for what it +finds, so one long-running process covers both halves of being a good +participant. + +**`daemon`** — stay logged in as a service other commands share. Prints +progress on stderr and nothing on stdout; it does not return until stopped. +While it runs, every other command on this machine uses its session +automatically, with no flags, no credentials, and no second login. +`--bind ADDR` also accepts connections from other machines, which need the +token. +State lives in the daemon, so every client reads the same thing: `search` sees +searches any client ran, the transfer queue is shared, and `message read` +starts from the conversation the daemon already collected rather than only +what arrives while it runs. + +**`daemon status`** — one object: `user`, `server`, `version`, `protocol`, +`listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, +`session_loss`, `clients`, `uptime_secs`. Exit 3 when nothing is running. +**`daemon stop`** — ask it to shut down; prints nothing. +**`daemon token`** — the secret a client on another machine needs. Local +clients never need it. + +The daemon can also be on a *different* machine, such as a home server or NAS +doing the downloading. Point at it once with `config set daemon host:5030` and +`config set daemon_token `, and every command afterwards drives that +machine; files land there, not here. The interface is an open JSON-RPC schema +(`rpc.discover` serves it), so a purpose-built client is a reasonable thing to +write. + +**`shares list`** — one object per configured folder: `directory`, `usable`. +**`shares add `** and **`shares remove `** — change the +config file; they print nothing. With a daemon running they also update it in +place, so the folder is on the network at once rather than after a restart. +**`shares status`** and **`shares reindex`** — `folders`, `files`, +`directories`, counted by logging in and scanning. +**`shares files [--filter ]`** — the index file by file, in the same +records `browse` emits for a peer (`user`, `directory`, `path`, `size`, +`bitrate`, `duration`), so what the network sees of this account can be diffed +against what it sees of anyone else. Use it to check that a folder is really +being served before blaming the network. + +**`config path`**, **`config list`**, **`config get `**, and +**`config set `** — `key`, `value`. + +**`portmap`** — `ok`, `backend`, `external`, `port`. Exit 4 when no router +answered, which is a negative answer rather than an error. + +**`skills install`**, **`skills uninstall`**, and **`skills list`** — write +this skill file into the local agent directories, or remove it: `agent`, +`path`, `action`. + +**`completions install`** and **`completions uninstall`** — write the tab +completion script where bash, zsh, or fish looks for it, or take it back +again: `shell`, `path`, `action`. For a human at a prompt, not for you. + +**`completions print `** and **`man`** — the completion script or the +man page on stdout, raw rather than records. For whoever builds a package. + +## Idioms + +```bash +# One shot, when any copy will do: find it, pick the best, fetch it. +soulseek-rs get 'aphex twin xtal' --json + +# Filter precisely, then fetch what survived. +soulseek-rs search 'rjd2' --json \ + | jq -c 'select(.bitrate >= 320 and .free_slot)' \ + | soulseek-rs download --stdin --json + +# Take one folder from a peer. +soulseek-rs browse someuser --json \ + | jq -c 'select(.path | endswith(".flac"))' \ + | soulseek-rs download --stdin --json + +# Is this peer worth queueing behind? +soulseek-rs user someuser --json +``` + +`download --stdin` reads whole records, so filtering with `jq` and piping back +is the normal way to be selective. + +## Searching: widen by rungs, then match back + +Soulseek matches your query against the filenames peers happen to have on +disk. Nobody agrees on how to write a track, so the name the user gives you is +usually *more* specific than any filename: it carries a mix suffix that is not +in the file, a diacritic the uploader typed as ASCII, an ampersand where the +file has a space. Search that verbatim and you get exit 4 for a track dozens of +people are sharing. + +So climb down a ladder, one rung at a time, and **stop at the first rung that +returns anything usable**: + +1. **As written.** `bicep glue original mix` +2. **Without the version suffix**, meaning anything in trailing brackets or + after a dash: `(Original Mix)`, `[Radio Edit]`, `- Remastered 2011`, `(feat. X)`. + `bicep glue` +3. **Without featured artists and punctuation**, and with diacritics folded to + ASCII: `sigur rós hoppípolla` becomes `sigur ros hoppipolla`, `Tyler, The + Creator - EARFQUAKE` becomes `tyler the creator earfquake`. +4. **The two or three rarest words**, usually a surname and one distinctive + title word: `hoppipolla`. + +Each rung is a *different, wider* query, which is why this does not break the +rule against re-running a search: never repeat a rung, and do not go past the +fourth. Four searches is the most any one track should ever cost, and usually +it is fewer, because two shortcuts apply: + +- **A rung that produces the same string as the one before it is not a rung.** + A title with no diacritics and no punctuation makes rung 3 identical to rung + 2. Skip it rather than spending a search on it. +- **No results at all is different from results that do not match.** An empty + answer means the tokens are wrong, not merely too many, so go straight to the + rarest-word rung instead of stepping. Results that came back but matched + nothing mean you are in the right area: look further down them before + widening. + +**Then match back.** Widening finds candidates; it does not choose. Compare each +result against what the user actually asked for, folded the same way (lowercase, +diacritics stripped, punctuation to spaces), and require the distinctive words +to appear in the path. A search for `hoppipolla` will also return live versions, +covers, and a DJ set that mentions it. + +Where the user named a specific version, prefer a filename that carries it and +treat one that does not as second best rather than a match: + +```bash +# "Bicep - Glue (Original Mix)" -> rung 2 finds it +soulseek-rs search 'bicep glue' --json --min-bitrate 320 +# then, among the results, prefer paths containing "glue" and not "live", +# "remix" or "edit" unless that is what was asked for. +``` + +A track that survives all four rungs with nothing plausible is genuinely not +there *today*. Say so and move on rather than inventing more spellings, and +offer to put it on the wishlist: `wish add ` keeps looking on the +server's own schedule, and `serve --wishlist` or `wish run` reports what turns +up later. That is the right home for the one white label in a set that nobody +is sharing this afternoon. + +## Downloading a list of tracks + +Soulseek is people's home computers, and they can see what you are doing. The +fast way and the safe way are mostly the same way, but where they differ, take +the safe one: a peer who blocks you is gone for good. + +The other cost is your own. A list of thirty tracks can be done for a handful +of commands or for millions of tokens, and the difference is how much of each +result set you read. Narrow first, then read what is left. + +### Before you start: are you sharing anything? + +```bash +soulseek-rs shares status --json +``` + +If `files` is 0, **stop and tell the user before downloading anything**. Sharing +nothing is the single most common reason to get banned on Soulseek: plenty of +users block non-sharers on sight, and some clients do it automatically. One +line is enough ("you're not sharing anything, which gets you blocked; point +`shares add` at a folder first"), then let them decide. + +### Search, narrow, then judge + +For a named tracklist, always look at the candidates. **Do not use `get`.** It +ranks on free slot, bitrate and peer speed, which are facts about the transfer +and say nothing about whether the file is the track that was asked for. On a +setlist full of remixes, edits and white labels it does not miss, it *succeeds +with the wrong file*: exit 0, a file on disk, and nobody finds out until they +listen. A miss you can retry; a confident wrong answer you cannot. + +`get` is for when the user has not named anything in particular ("find me some +deep house"). A tracklist is the opposite of that. + +The cost of looking is not the looking, it is reading three hundred results to +choose one. So narrow before you read: + +```bash +# 1. search, and keep the raw records in a file +soulseek-rs search 'kalabrese rumpelzirkus' --json --search-timeout 25 \ + >> raw.ndjson + +# 2. cut it to a handful without reading it +jq -c 'select(.path | ascii_downcase | test("kalabrese"))' raw.ndjson \ + | jq -c 'select(.size > 20000000)' | head -8 +``` + +The search flags do the same job before the records are ever written: +`--extension flac`, `--min-bitrate 320`, `--exclude live`, `--exclude snippet`, +`--free-slots`, `--min-size`. Use them freely; a filter is free and a result +set is not. + +Then read the handful that survives and choose. Eight lines is a judgement you +can make; three hundred is a bill. + +### Fuzzy matching: what "the right one" means + +Fold both sides before comparing: lowercase, diacritics to ASCII, punctuation +and underscores to spaces, runs of spaces collapsed. Then: + +- **Every distinctive word of the artist and title must appear** in the folded + path. Ignore word order; peers name files every possible way round. +- **The version is what you match on, not what you dropped.** The ladder drops + `(Kalabrese Remix)` in order to *find* anything at all; the match must then + require `kalabrese`, or you will take the original and think you succeeded. + Same for `dub`, `extended`, `instrumental`, a year, a label catalogue number. +- **Reject what you were not asked for**: `live`, `radio edit`, `snippet`, + `preview`, `continuous`, `dj mix`, `mixed by`. A DJ set's tracklist will + otherwise match the recording of the set itself. +- **Prefer lossless** for anything a DJ will play: `flac`, `aiff`, `wav`. Fall + back to 320 kbps only when there is no lossless copy. +- **Prefer a free slot** among candidates that are equally right, never over + one that is more right. + +When two candidates are both plausible and differ in ways you cannot resolve +from the filename, say so and pick the larger lossless one. Do not download +both. + +### When an artist repeats, browse one peer + +If any artist appears more than once in the list, search for them once and +`browse` whoever turns up with the most: + +```bash +soulseek-rs search 'artist' --json +soulseek-rs browse someuser --json # everything they share, as paths +``` + +One search and one browse replaces a search per track, and the listing shows +which of them that peer does not have. Four tracks by one artist is enough to +be worth it; a whole album certainly is. Taking several files from one person +is also what their queue is for, as long as you keep to three at a time. + +### Never retype a remote path + +Remote paths belong to the peer. They are backslash-separated and may contain +anything, and they will not survive being read out of one process, put through +a second, and typed into a third. **So do not move the path. Move a line +number.** + +```bash +soulseek-rs search 'bicep glue' --json --search-timeout 25 >> raw.ndjson +sed -n '7p' raw.ndjson > pick.ndjson # choose by line, never by retyping +soulseek-rs download --stdin --json < pick.ndjson +``` + +`download --stdin` reads exactly the records `search --json` writes, so the +bytes go from the peer to the downloader without any hand touching them. Append +every rung to the same file and the line numbers stay stable. + +This is also how a subagent hands a pick back: it reports *which line of which +file*, plus its reasoning. It must never quote the path itself. + +### A subagent per track + +Give each track its own subagent, **at most twenty at a time**. A subagent +works the search ladder, appends every rung to its own file, narrows, and +judges what is left. This is where the judgement belongs: a subagent reading +eight candidates for one track costs little, and it is the only thing that +tells a remix from its original. + +Fan them out properly. Twenty subagents run one after another is slower than +doing it yourself; use whatever your host gives you for running agents in +parallel and collecting their results. + +Each subagent reports its pick as a file and a line number and **waits for one +word back**. Clear each pick the moment it arrives; do not wait for the rest. A +batch that holds every transfer until the last search finishes moves no bytes +for the first minute, and one track nobody is sharing holds up the ones people +are. Answering a pick needs two running counts, not the whole set: + +- **Transfers that peer already has.** Three is the cap, and it is the one that + gets you banned. At three, hold the pick or tell the subagent to take its + second choice elsewhere. +- **Transfers running overall.** Twenty is the cap. + +Drop a pick naming a file already cleared. **Report as you go**: each subagent +says when its search found candidates and again when its download finished, so +a slow batch is distinguishable from a stuck one. + +### How much to have in flight + +| | At once | +|---|---| +| Subagents | 20 | +| Searches on the wire | 10 | +| Transfers running | 20 | +| Transfers from any one peer | 3 | +| Search rungs spent on one track | 4 | + +Twenty transfers is the client's own default, and the reasoning is in the code: +a Soulseek transfer is paced by the peer sending it, usually a few hundred +KiB/s, so a fast link is filled by many transfers at once rather than by faster +ones. + +The two numbers that are *not* just "as many as the machine can take": + +- **Three from one peer.** This is the one that gets you banned. A client + serves ten upload slots by default, so three is a visible minority of one + person's capacity rather than a monopoly on it. Twenty transfers is fine; + twenty transfers from two people is not. +- **Ten searches.** The server rate-limits searches, and it is the daemon's + single connection making all of them. + +### Handing a whole batch to one command + +Where the picks need no arguing, put them in one file and let the tool manage +the queue: + +```bash +soulseek-rs download --stdin --json -c 20 < picks.ndjson +``` + +It takes the list in order, so **order the lines to alternate between peers**. +Consecutive lines naming the same user are what put several simultaneous +transfers on one person. + +### What not to do + +- **Do not read a full result set.** Narrow it with search flags or `jq` + first: thirty unfiltered result sets is millions of tokens, and you needed + eight lines from each. +- **Do not let `get` choose a version for you.** It ranks on transfer facts, + not on whether the file is the right track, so on a tracklist it returns the + wrong one with exit 0. +- **Do not search the same query twice.** The server rate-limits searches, and + a re-run returns what you already have. Widening it is a different query; + repeating it is not. +- **Do not spend a rung that changes nothing.** If folding diacritics and + punctuation leaves the same string, that rung is not a rung. Skip it. +- **Do not retry a failed transfer against the same peer.** Exit 6 or 5 means + they went offline or are not serving you. Take the next candidate from the + results you already have. +- **Do not fetch what you were not asked for.** Confirm before `get --pick all` + or any fetch whose size you do not know. + +Twenty at once works only because the daemon holds the session: twenty commands +sharing one login. Without a daemon they displace each other (exit 7). Do not +reach for `--username` to get around it: that registers an account per track on +someone else's server. Start the daemon first. + +Keep the listener on: a peer that cannot connect back to you cannot send you +the file. + +## Rules + +- **Remote paths belong to the peer.** They are backslash-separated and may + contain anything. Pass them back byte-identical — never normalise, unescape, + or let a shell expand them. +- **`--follow` never returns.** For `room listen`, `message read`, and `serve`, + always pass `--duration ` instead. +- **`daemon` never returns either.** It is a service: background it + (`soulseek-rs daemon &`) and check it with `daemon status`. Running it in the + foreground will hang you until the timeout. +- **Every wait is bounded.** `--timeout` per command, `--search-timeout` for + searches. There is no unbounded form. +- **Exit 4 is not a failure.** A search that finds nothing usually means the + query was more specific than anyone's filename. Take the next rung of the + search ladder; use `whoami` to rule out the connection. +- **Exit 7 is not an answer.** The session ended mid-command, so nothing it saw + counts. Something else logged in as you, almost always a second run of your + own. Start a daemon and route through it rather than hunting for + an unused name. +- **Never put a password in argv** — `ps` shows it to every user on the box. + Use `--password-stdin`, the OS keychain, or `SOULSEEK_PASSWORD_CMD`. +- **Downloading pulls a file off a stranger's machine.** Confirm with the user + before `get --pick all` or any fetch whose size is not known up front. +- **Sharing nothing gets the user banned.** Check `shares status` before a + batch; if `files` is 0, say so before downloading. Users block leechers, and + some clients do it without being asked. +- **At most three files at a time from any one peer.** Their queue is per-user + and they can see it. A pile of simultaneous transfers from one person reads + as abuse and is answered with a ban. Twenty transfers is fine; twenty + transfers from two people is not. +- **Exit 6 means pick someone else.** The transfer died because that peer went + away or is not serving you. Take the next candidate from the results you + already have; retrying the same peer hammers their queue. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs new file mode 100644 index 0000000..e3a10ba --- /dev/null +++ b/soulseek-rs/src/api.rs @@ -0,0 +1,438 @@ +//! The seam between "what this program does" and "where the session lives". +//! +//! Commands and the TUI hold an `Arc` rather than an +//! `Arc`. Locally that *is* the client — the trait is implemented on +//! `Client` itself, so an `Arc` coerces straight to the trait object +//! and the local path keeps calling the same methods it always did. In daemon +//! mode the same handle is a [`crate::remote::RemoteSession`] that answers over +//! a socket. +//! +//! The signatures deliberately mirror `Client`'s, because every one of them is +//! already load-bearing somewhere in the UI or the command layer. The one +//! change is [`SessionApi::username`], which returns an owned `String`: a +//! remote session has no borrowed client to hand a `&str` out of. + +use crate::daemon::proto::ChatMessageDto; +use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats}; +use soulseek_rs::{ + Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, + SharedDirectory, UploadInfo, UserInfo, UserMessage, +}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::Receiver; +use std::time::Duration; + +/// What a session knows about one search it has run. +/// +/// `started_secs_ago` is what lets a second window tell a search that is still +/// collecting from one that has finished: the collecting window is the +/// client's, so without the daemon saying when a search began, every window +/// but the one that started it would show the result as final immediately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSearch { + pub query: String, + /// Files found so far, across every peer that has answered. + pub files: usize, + /// How long ago the search went out, or `None` when the session cannot + /// say — which is the local case, where the window that ran it already + /// knows. + pub started_secs_ago: Option, +} + +/// Everything the command layer and the TUI ask of a logged-in session. +/// +/// Two rules keep the remote implementation honest: +/// +/// * Non-destructive getters (`uploads`, `room_members`, `user_info`, …) are +/// plain request/response — a caller may poll them as often as it likes. +/// * The `take_*` drains are destructive, so with more than one client +/// attached they cannot be served by asking the daemon to drain on demand: +/// whoever asked first would steal the events. The daemon drains once and +/// fans the events out, and a remote session's `take_*` empties its own +/// local mirror. +pub trait SessionApi: Send + Sync { + fn username(&self) -> String; + /// The daemon this session is borrowed from, for the UI to name. `None` + /// when the session belongs to this process. + fn daemon_endpoint(&self) -> Option { + None + } + fn listen_port(&self) -> Option; + fn session_loss(&self) -> Option; + fn relogin(&self) -> Result { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + + /// Search and wait out the window. Every implementation is + /// `search_with_cancel` with nothing to cancel on. + fn search( + &self, + query: &str, + timeout: Duration, + ) -> Result> { + self.search_with_cancel(query, timeout, None) + } + + fn search_with_cancel( + &self, + query: &str, + timeout: Duration, + cancel: Option>, + ) -> Result>; + fn get_search_results(&self, key: &str) -> Vec; + /// Every search the session knows about. + /// + /// Daemon-side this is shared, so a second client sees what the first has + /// been searching for rather than starting from a blank list. + fn all_searches(&self) -> Vec; + /// Drop a search from the session, so every window loses it rather than + /// only the one that dismissed it. + fn forget_search(&self, query: &str) -> bool; + /// How many files a search has collected so far — a count cheap enough + /// to poll, never a copy of the results. + fn get_search_results_count(&self, key: &str) -> usize; + fn try_get_search_results(&self, key: &str) -> Option>; + fn start_wishlist_search(&self, query: &str) -> Result<()>; + fn wishlist_interval(&self) -> Duration; + + fn download( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + ) -> Result<(Download, Receiver)>; + fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)>; + fn get_all_downloads(&self) -> Vec; + fn pause_download(&self, username: &str, filename: &str) -> bool; + fn resume_download(&self, username: &str, filename: &str) -> bool; + fn remove_queued_download(&self, username: &str, filename: &str) -> bool; + fn remove_download(&self, username: &str, filename: &str) -> bool; + fn cancel_download(&self, username: &str, filename: &str) -> bool; + + fn uploads(&self) -> Vec; + fn take_upload_events(&self) -> Vec; + fn cancel_upload(&self, username: &str, filename: &str) -> bool; + fn set_upload_slots(&self, slots: usize); + fn check_privileges(&self) -> Result<()>; + fn own_privilege_seconds(&self) -> Option; + + fn request_room_list(&self) -> Result<()>; + fn join_room(&self, room: &str) -> Result<()>; + fn leave_room(&self, room: &str) -> Result<()>; + fn say_in_room(&self, room: &str, message: &str) -> Result<()>; + fn room_members(&self, room: &str) -> Vec; + /// What the server said about each member when the room was joined: + /// status, share statistics, free upload slots and country. + fn room_member_stats(&self, room: &str) -> Vec; + fn take_room_events(&self) -> Vec; + + fn send_private_message(&self, username: &str, message: &str) + -> Result<()>; + fn take_private_messages(&self) -> Vec; + + fn browse_user(&self, username: &str) -> Result<()>; + fn take_browse_result( + &self, + username: &str, + ) -> Option>; + fn request_user_info(&self, username: &str) -> Result<()>; + fn user_info(&self, username: &str) -> Option; + + /// Ask the server to keep pushing `username`'s status until unwatched. + fn watch_user(&self, username: &str) -> Result<()>; + fn unwatch_user(&self, username: &str) -> Result<()>; + /// Everyone this session is watching, sorted by name. + fn watched_users(&self) -> Vec; + + /// The conversation this session already knows about. + /// + /// Empty locally, where the TUI's own snapshot on disk is the record. A + /// daemon has been collecting private messages the whole time it has been + /// running, including while nothing was attached, so an attaching client + /// asks for the backlog rather than starting a conversation half-way + /// through. + fn message_history(&self) -> Vec { + Vec::new() + } + + // aislop-ignore-next-line complexity/function-too-long -- scanner bug: a bodyless trait signature followed only by more signatures has no closing brace, so the counter runs to EOF. One line. + fn shared_counts(&self) -> (u32, u32); + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn shared_directories(&self) -> Vec; + /// The share index in the shape a peer receives it, for showing this + /// session's own files exactly as the network sees them. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn shared_listing(&self) -> Vec; + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn set_shared_directories(&self, directories: Vec) -> Result<()>; + + /// Point future transfers at `directory`. + /// + /// Only a daemon holds a download folder of its own — locally every + /// download call names its directory, so the local session has nothing to + /// store and accepts silently. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn set_download_directory(&self, directory: String) -> Result<()>; + + /// Make `password` this account's password on the server. + /// + /// `Ok(())` means the request reached the server, not that it took it: + /// the protocol's own reply to a password change is not read back. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn change_password(&self, password: &str) -> Result<()>; +} + +/// The local session: the library client itself, so `Arc` is already an +/// `Arc` and the in-process path costs nothing but a vtable. +impl SessionApi for Client { + fn username(&self) -> String { + Self::username(self).to_string() + } + + fn listen_port(&self) -> Option { + Self::listen_port(self) + } + + fn session_loss(&self) -> Option { + Self::session_loss(self) + } + + fn relogin(&self) -> Result { + Self::login(self) + } + + fn search_with_cancel( + &self, + query: &str, + timeout: Duration, + cancel: Option>, + ) -> Result> { + Self::search_with_cancel(self, query, timeout, cancel) + } + + fn get_search_results(&self, key: &str) -> Vec { + Self::get_search_results(self, key) + } + + fn forget_search(&self, query: &str) -> bool { + Self::forget_search(self, query) + } + + fn all_searches(&self) -> Vec { + // Counts, not copies: the daemon answers this for every attached + // client many times a second, and cloning every result set to sum + // its lengths held the context lock against the thread storing new + // results. + Self::search_file_counts(self) + .into_iter() + .map(|(query, files)| SessionSearch { + query, + files, + // A local window started every search it can see, so it + // already knows which are still running. + started_secs_ago: None, + }) + .collect() + } + + fn get_search_results_count(&self, key: &str) -> usize { + // Files, agreeing with the remote session — the library's own + // accessor of this name counts per-peer responses instead. + Self::search_file_counts(self) + .into_iter() + .find(|(query, _)| query == key) + .map_or(0, |(_, files)| files) + } + + fn try_get_search_results(&self, key: &str) -> Option> { + Self::try_get_search_results(self, key) + } + + fn start_wishlist_search(&self, query: &str) -> Result<()> { + Self::start_wishlist_search(self, query) + } + + fn wishlist_interval(&self) -> Duration { + Self::wishlist_interval(self) + } + + fn download( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + ) -> Result<(Download, Receiver)> { + Self::download(self, filename, username, size, download_directory) + } + + fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)> { + Self::download_with_metadata( + self, + filename, + username, + size, + download_directory, + metadata, + ) + } + + fn get_all_downloads(&self) -> Vec { + Self::get_all_downloads(self) + } + + fn pause_download(&self, username: &str, filename: &str) -> bool { + Self::pause_download(self, username, filename) + } + + fn resume_download(&self, username: &str, filename: &str) -> bool { + Self::resume_download(self, username, filename) + } + + fn remove_queued_download(&self, username: &str, filename: &str) -> bool { + Self::remove_queued_download(self, username, filename) + } + + fn remove_download(&self, username: &str, filename: &str) -> bool { + Self::remove_download(self, username, filename) + } + + fn cancel_download(&self, username: &str, filename: &str) -> bool { + Self::cancel_download(self, username, filename) + } + + fn uploads(&self) -> Vec { + Self::uploads(self) + } + + fn take_upload_events(&self) -> Vec { + Self::take_upload_events(self) + } + + fn cancel_upload(&self, username: &str, filename: &str) -> bool { + Self::cancel_upload(self, username, filename) + } + + fn set_upload_slots(&self, slots: usize) { + Self::set_upload_slots(self, slots); + } + + fn check_privileges(&self) -> Result<()> { + Self::check_privileges(self) + } + + fn own_privilege_seconds(&self) -> Option { + Self::own_privilege_seconds(self) + } + + fn request_room_list(&self) -> Result<()> { + Self::request_room_list(self) + } + + fn join_room(&self, room: &str) -> Result<()> { + Self::join_room(self, room) + } + + fn leave_room(&self, room: &str) -> Result<()> { + Self::leave_room(self, room) + } + + fn say_in_room(&self, room: &str, message: &str) -> Result<()> { + Self::say_in_room(self, room, message) + } + + fn room_members(&self, room: &str) -> Vec { + Self::room_members(self, room) + } + + fn room_member_stats(&self, room: &str) -> Vec { + Self::room_member_stats(self, room) + } + + fn take_room_events(&self) -> Vec { + Self::take_room_events(self) + } + + fn send_private_message( + &self, + username: &str, + message: &str, + ) -> Result<()> { + Self::send_private_message(self, username, message) + } + + fn take_private_messages(&self) -> Vec { + Self::take_private_messages(self) + } + + fn browse_user(&self, username: &str) -> Result<()> { + Self::browse_user(self, username) + } + + fn take_browse_result( + &self, + username: &str, + ) -> Option> { + Self::take_browse_result(self, username) + } + + fn request_user_info(&self, username: &str) -> Result<()> { + Self::request_user_info(self, username) + } + + fn user_info(&self, username: &str) -> Option { + Self::user_info(self, username) + } + + fn watch_user(&self, username: &str) -> Result<()> { + Self::watch_user(self, username) + } + + fn unwatch_user(&self, username: &str) -> Result<()> { + Self::unwatch_user(self, username) + } + + fn watched_users(&self) -> Vec { + Self::watched_users(self) + } + + fn shared_counts(&self) -> (u32, u32) { + Self::shared_counts(self) + } + + fn shared_directories(&self) -> Vec { + Self::shared_directories(self) + } + + fn shared_listing(&self) -> Vec { + Self::shared_listing(self) + } + + fn set_shared_directories(&self, directories: Vec) -> Result<()> { + Self::set_shared_directories(self, directories) + } + + fn set_download_directory(&self, _directory: String) -> Result<()> { + Ok(()) + } + + fn change_password(&self, password: &str) -> Result<()> { + Self::change_password(self, password) + } +} diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs new file mode 100644 index 0000000..37b3270 --- /dev/null +++ b/soulseek-rs/src/cli.rs @@ -0,0 +1,936 @@ +//! Command-line surface. +//! +//! Two modes share one binary: no subcommand launches the interactive TUI, +//! any subcommand runs a one-shot operation designed for scripts and agents. +//! The one-shot half follows three rules — stdout carries data only, stderr +//! carries progress, and the exit code carries the verdict (see +//! [`crate::output::Exit`]). + +use clap::builder::FalseyValueParser; +use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +/// Upper bound for every wait expressed in seconds. A one-shot command that +/// runs longer than a day is a mistyped flag, and rejecting it here keeps the +/// deadline arithmetic in the command layer from overflowing. +const MAX_WAIT_SECS: u64 = 24 * 60 * 60; + +const EXAMPLES: &str = "\ +Examples: + soulseek-rs launch the interactive TUI + soulseek-rs search 'aphex twin xtal' print matching files + soulseek-rs get 'aphex twin xtal' search, pick the best, download + soulseek-rs search 'rjd2' --json | jq -c 'select(.bitrate >= 320)' \\ + | soulseek-rs download --stdin filter, then fetch + soulseek-rs browse someuser --json list a peer's shares + soulseek-rs room listen lobby --follow stream room chat + soulseek-rs serve --follow --json share files, stream upload events + soulseek-rs daemon run as a service; later commands use it + soulseek-rs user someuser is this peer online and worth it + soulseek-rs skills install teach your coding agent this CLI + soulseek-rs completions install tab completion for your shell + +Exit codes: + 0 success 2 usage/config 3 connect or login 4 no results + 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; + +pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +#[derive(Parser, Debug, Default)] +#[command( + name = "soulseek-rs", + author, + version = VERSION, + about = "Soulseek client in Rust 🦀", + long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ + interactive TUI, or use a subcommand for a scriptable \ + one-shot operation: stdout is data, stderr is progress, and \ + the exit code says what happened.", + after_help = EXAMPLES +)] +// A flag is a bool; a command line with several switches is not a struct that +// wants splitting up. +#[allow(clippy::struct_excessive_bools)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Soulseek username + #[arg(short, long, global = true, env = "SOULSEEK_USERNAME")] + pub username: Option, + + /// Password (visible in `ps`; prefer --password-stdin or the OS keychain) + #[arg( + short, + long, + global = true, + env = "SOULSEEK_PASSWORD", + hide_env_values = true + )] + pub password: Option, + + /// Read the password from the first line of stdin (takes precedence + /// over --password) + #[arg(long, global = true)] + pub password_stdin: bool, + + /// Shell command whose stdout is the password, used when nothing else + /// supplies one + #[arg( + long, + global = true, + env = "SOULSEEK_PASSWORD_CMD", + hide_env_values = true + )] + pub password_cmd: Option, + + /// Server as host:port + #[arg(long, global = true, env = "SOULSEEK_SERVER")] + pub server: Option, + + /// Do not accept incoming peer connections + #[arg( + long, + global = true, + env = "SOULSEEK_NO_LISTENER", + action = ArgAction::SetTrue, + value_parser = FalseyValueParser::new() + )] + pub no_listener: bool, + + /// Accept incoming peer connections, overriding the config file + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub listener: bool, + + /// Port to accept peer connections on + #[arg(long, global = true, env = "SOULSEEK_LISTENER_PORT")] + pub listener_port: Option, + + /// Directory downloads are saved to + #[arg(short, long, global = true, env = "SOULSEEK_DOWNLOAD_DIR")] + pub download_dir: Option, + + /// Directory shared with other peers; repeat for several + #[arg(long, global = true, env = "SOULSEEK_SHARED_DIR", action = ArgAction::Append)] + pub shared_dir: Vec, + + /// Maximum simultaneous downloads + #[arg( + short = 'c', + long, + global = true, + env = "SOULSEEK_MAX_CONCURRENT_DOWNLOADS" + )] + pub max_concurrent_downloads: Option, + + /// Seconds a search collects results before reporting + #[arg( + long, + global = true, + env = "SOULSEEK_SEARCH_TIMEOUT", + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub search_timeout: Option, + + /// Control a daemon at this address (host:port) or Unix socket path + #[arg(long, global = true, env = "SOULSEEK_DAEMON", value_name = "ADDR")] + pub daemon: Option, + + /// Token for a remote daemon; a local socket needs none + #[arg( + long, + global = true, + env = "SOULSEEK_DAEMON_TOKEN", + hide_env_values = true + )] + pub daemon_token: Option, + + /// Ignore any running daemon and open this run's own session + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub no_daemon: bool, + + /// Config file to read instead of the default location + #[arg(long, global = true, env = "SOULSEEK_CONFIG", value_name = "FILE")] + pub config: Option, + + /// Ignore the config file entirely + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub no_config: bool, + + /// Emit newline-delimited JSON instead of tab-separated text + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub json: bool, + + /// Suppress progress and status output on stderr + #[arg(short, long, global = true, action = ArgAction::SetTrue)] + pub quiet: bool, + + /// Increase log verbosity (repeat up to -vvvv) + #[arg(short, long, global = true, action = ArgAction::Count)] + pub verbose: u8, + + /// Write logs to a file instead of stderr + #[arg(long, global = true, env = "SOULSEEK_LOG_FILE")] + pub log_file: Option, +} + +/// Non-exhaustive: this enum is public through the crate's library target so +/// the integration tests can reach it, and a new subcommand must not read as a +/// breaking change of that library. +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum Commands { + /// Search the network and print matching files + Search(SearchArgs), + + /// Download files by user and remote path + Download(DownloadArgs), + + /// Transfers in flight + #[command(subcommand)] + Transfer(TransferCommand), + + /// Search, pick automatically, and download in one step + Get(GetArgs), + + /// List the files a user shares + Browse(BrowseArgs), + + /// Standing searches the server re-runs on its own schedule + #[command(subcommand)] + Wish(WishCommand), + + /// Chat rooms + #[command(subcommand)] + Room(RoomCommand), + + /// Private messages + #[command(subcommand)] + Message(MessageCommand), + + /// Stay online sharing files, reporting each upload as it happens + Serve(ServeArgs), + + /// Run as a service other clients connect to, or control one that is + Daemon(DaemonArgs), + + /// What the server knows about another user + User(UserArgs), + + /// Keep a daemon updated about users you care about + #[command(subcommand)] + Watch(WatchCommand), + + /// 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), + + /// Read and write the config file + #[command(subcommand)] + Config(ConfigCommand), + + /// Check whether the router opens the listen port (UPnP/NAT-PMP) + Portmap, + + /// Teach a local coding agent this command surface + #[command(subcommand)] + Skills(SkillsCommand), + + /// Tab completion for bash, zsh, and fish + #[command(subcommand)] + Completions(CompletionsCommand), + + /// Print the man page to stdout, for packaging + Man, +} + +#[derive(Args, Debug, Default)] +pub struct DaemonArgs { + #[command(subcommand)] + pub command: Option, + + /// Also accept control connections on this address, for clients on other + /// machines. They need the token; the local socket does not. + #[arg(long, value_name = "ADDR")] + pub bind: Option, + + /// How many uploads to serve at once + #[arg(long, value_parser = clap::value_parser!(u16).range(1..=1000))] + pub upload_slots: Option, +} + +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum DaemonCommand { + /// Print the token remote clients authenticate with + Token, + + /// What a running daemon is doing + Status, + + /// Ask a running daemon to shut down + Stop, +} + +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum AccountCommand { + /// Change the account's password on the server + Password(PasswordArgs), + + /// Forget the password stored on this machine, so the next start asks + /// for it again + Logout, +} + +#[derive(Args, Debug)] +pub struct PasswordArgs { + /// The new password (visible in `ps`; prefer --new-password-stdin) + #[arg( + long, + value_name = "PASSWORD", + env = "SOULSEEK_NEW_PASSWORD", + hide_env_values = true + )] + pub new_password: Option, + + /// Read the new password from the first line of stdin (takes precedence + /// over --new-password) + #[arg(long, action = ArgAction::SetTrue)] + pub new_password_stdin: bool, +} + +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum CompletionsCommand { + /// Write the completion script where the shell looks for it + Install(CompletionsArgs), + + /// Remove the completion script again + Uninstall(CompletionsArgs), + + /// Print the completion script to stdout, for packaging + Print { + #[arg(value_enum)] + shell: Shell, + }, +} + +#[derive(Args, Debug)] +pub struct CompletionsArgs { + /// Shell to act on instead of the ones found on this machine; repeat for + /// several + #[arg(long, value_enum, action = ArgAction::Append)] + pub shell: Vec, +} + +/// The shells with a conventional per-user completions location. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum Shell { + Bash, + Zsh, + Fish, +} + +#[derive(Subcommand, Debug)] +pub enum SkillsCommand { + /// Write the skill into every agent found on this machine, replacing an + /// older copy + Install(SkillsArgs), + + /// Remove the skill again + Uninstall(SkillsArgs), + + /// Show where the skill goes and what is there now + List(SkillsArgs), +} + +#[derive(Args, Debug)] +pub struct SkillsArgs { + /// Skills directory to use instead of the agents found on this machine; + /// repeat for several. Use `--dir .claude/skills` to commit the skill + /// alongside a project + #[arg(long, value_name = "PATH", action = ArgAction::Append)] + pub dir: Vec, +} + +#[derive(Args, Debug)] +pub struct ServeArgs { + /// Seconds to stay online before exiting + #[arg( + long, + default_value_t = 3600, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub duration: u64, + + /// Stay online until interrupted, ignoring --duration + #[arg(long, action = ArgAction::SetTrue)] + pub follow: bool, + + /// Also re-run the wishlist on the interval the server sets, reporting + /// matches as they arrive + #[arg(long, action = ArgAction::SetTrue)] + pub wishlist: bool, + + /// How many uploads to run at once [default: 10]; the rest queue, + /// privileged users first + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=64))] + pub upload_slots: Option, +} + +#[derive(Args, Debug)] +pub struct UserArgs { + /// The user to ask the server about + pub user: String, + + /// Seconds to wait for the server's answer + #[arg( + short, + long, + default_value_t = 10, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub timeout: u64, +} + +#[derive(Subcommand, Debug)] +pub enum SharesCommand { + /// List the configured share folders + List, + + /// Add a folder to the shares and save it to the config file + Add { + /// Folder to start sharing + directory: String, + }, + + /// Stop sharing a folder and save that to the config file + Remove { + /// Folder to stop sharing + directory: String, + }, + + /// List every indexed file, the way `browse` lists a peer's + Files { + /// Only paths containing this text, case-insensitively + #[arg(long)] + filter: Option, + }, + + /// Re-index the shares and report what the network will see + Reindex, + + /// Report how many folders and files are indexed + Status, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommand { + /// Print the path of the config file in use + Path, + + /// Print every setting and its effective value + List, + + /// Print one setting + Get { + /// Setting name, as shown by `config list` + key: String, + }, + + /// Change one setting in the config file + Set { + /// Setting name, as shown by `config list` + key: String, + /// New value; an empty string clears it + value: String, + }, +} + +/// How to order search results. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum SortKey { + /// Free slot first, then bitrate, peer speed, and size + #[default] + Best, + /// Largest file first + Size, + /// Highest bitrate first + Bitrate, + /// Fastest peer first + Speed, +} + +/// Which of the ranked results `get` should download. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum Pick { + /// The single best-ranked result + Best, + /// The first result that arrives, without waiting out the search + First, + /// Every result, subject to --limit + All, +} + +/// The result restrictions every command that searches accepts, so `search`, +/// `get` and `wish run` all narrow a result set the same way. +#[derive(Args, Debug, Default, Clone)] +pub struct FilterArgs { + /// Drop results below this bitrate in kbps + #[arg(long, value_name = "KBPS")] + pub min_bitrate: Option, + + /// Keep only peers advertising a free upload slot + #[arg(long, action = ArgAction::SetTrue)] + pub free_slots: bool, + + /// Drop results whose path contains this term; repeat for several + #[arg(long, value_name = "TERM", action = ArgAction::Append)] + pub exclude: Vec, + + /// Keep only this file extension, with or without a dot; repeat for several + #[arg(long, value_name = "EXT", action = ArgAction::Append)] + pub extension: Vec, + + /// Drop results smaller than this many bytes + #[arg(long, value_name = "BYTES")] + pub min_size: Option, + + /// Drop results larger than this many bytes + #[arg(long, value_name = "BYTES")] + pub max_size: Option, +} + +#[derive(Args, Debug)] +pub struct SearchArgs { + /// What to search for + pub query: String, + + #[command(flatten)] + pub filter: FilterArgs, + + /// Print at most this many results (0 means all) + #[arg(short = 'n', long, default_value_t = 0)] + pub limit: usize, + + /// Result order + #[arg(long, value_enum, default_value_t = SortKey::Best)] + pub sort: SortKey, +} + +#[derive(Args, Debug)] +pub struct DownloadArgs { + /// Peer holding the file + #[arg(required_unless_present = "stdin")] + pub user: Option, + + /// Remote path exactly as printed by `search` or `browse` + #[arg(required_unless_present = "stdin")] + pub path: Option, + + /// File size in bytes, as reported by `search` or `browse` + #[arg(long)] + pub size: Option, + + /// Read records from stdin: `search --json` output, or TAB-separated + /// `search` text lines + #[arg(long, action = ArgAction::SetTrue, conflicts_with_all = ["user", "path", "size"])] + pub stdin: bool, + + /// Seconds to wait for each transfer before giving up + #[arg( + short, + long, + default_value_t = 300, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub timeout: u64, +} + +#[derive(Args, Debug)] +pub struct GetArgs { + /// What to search for + pub query: String, + + /// Which results to download + #[arg(long, value_enum, default_value_t = Pick::Best)] + pub pick: Pick, + + /// With `--pick all`, download at most this many (0 means all) + #[arg(short = 'n', long, default_value_t = 0)] + pub limit: usize, + + #[command(flatten)] + pub filter: FilterArgs, + + /// Seconds to wait for each transfer before giving up + #[arg( + short, + long, + default_value_t = 300, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub timeout: u64, +} + +#[derive(Args, Debug)] +pub struct BrowseArgs { + /// User whose shares to list + pub user: String, + + /// Seconds to wait for the listing + #[arg( + short, + long, + default_value_t = 20, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + pub timeout: u64, +} + +#[derive(Subcommand, Debug)] +pub enum WishCommand { + /// Store a query and search for it from now on + Add { + /// What to keep looking for + query: String, + }, + + /// Stop looking for a query + Remove { + /// The wish to drop, as shown by `wish list` + query: String, + }, + + /// Print the stored queries + List, + + /// Search every stored query once and print what came back + Run(WishRunArgs), +} + +#[derive(Args, Debug)] +pub struct WishRunArgs { + /// Only run this wish instead of all of them + #[arg(long, value_name = "QUERY")] + pub only: Option, + + #[command(flatten)] + pub filter: FilterArgs, + + /// Print at most this many results per wish (0 means all) + #[arg(short = 'n', long, default_value_t = 0)] + pub limit: usize, + + /// Result order + #[arg(long, value_enum, default_value_t = SortKey::Best)] + pub sort: SortKey, +} + +#[derive(Subcommand, Debug)] +pub enum TransferCommand { + /// Stop a download or upload; a cancelled download's partial file is + /// deleted + Cancel { + /// The other side of the transfer + user: String, + /// Remote path exactly as printed by `search`, `browse`, or `serve` + path: String, + }, +} + +/// Watching only lasts as long as the session doing it, so these are aimed at +/// a running daemon: it stays connected and keeps receiving the status pushes. +#[derive(Subcommand, Debug)] +pub enum WatchCommand { + /// Start watching a user + Add { + /// The user to watch + user: String, + }, + + /// Stop watching a user + Remove { + /// The user to stop watching + user: String, + }, + + /// List the users being watched + List, +} + +#[derive(Subcommand, Debug)] +pub enum RoomCommand { + /// List public rooms and their user counts + List { + /// Seconds to wait for the list + #[arg( + short, + long, + default_value_t = 5, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + timeout: u64, + }, + + /// Post a message to a room + Say { + /// Room to post in + room: String, + /// Message text + message: String, + }, + + /// List who is currently in a room + Users { + /// Room whose members to list + room: String, + /// Seconds to wait for the membership list + #[arg( + short, + long, + default_value_t = 10, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + timeout: u64, + }, + + /// Stream messages and joins/leaves from a room + Listen { + /// Room to listen to + room: String, + /// Seconds to listen before exiting + #[arg( + long, + default_value_t = 30, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + duration: u64, + /// Listen until interrupted, ignoring --duration + #[arg(long, action = ArgAction::SetTrue)] + follow: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub enum MessageCommand { + /// Send a private message + Send { + /// Recipient username + user: String, + /// Message text + message: String, + }, + + /// Stream incoming private messages + Read { + /// Seconds to listen before exiting + #[arg( + long, + default_value_t = 30, + value_parser = clap::value_parser!(u64).range(1..=MAX_WAIT_SECS) + )] + duration: u64, + /// Listen until interrupted, ignoring --duration + #[arg(long, action = ArgAction::SetTrue)] + follow: bool, + }, +} + +/// Split `host:port`, the only shape the server setting accepts. +pub fn parse_server_address(server: &str) -> Result<(String, u16), String> { + let (host, port) = server.rsplit_once(':').ok_or_else(|| { + format!("Invalid server '{server}': expected host:port") + })?; + if host.is_empty() { + return Err(format!("Invalid server '{server}': missing host")); + } + let port = port + .parse::() + .map_err(|_| format!("Invalid server '{server}': bad port '{port}'"))?; + Ok((host.to_string(), port)) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("should parse") + } + + #[test] + fn clap_definitions_are_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn no_subcommand_means_tui() { + assert!(parse(&["soulseek-rs"]).command.is_none()); + } + + #[test] + fn global_flags_parse_after_the_subcommand() { + let cli = parse(&[ + "soulseek-rs", + "search", + "query", + "--username", + "alice", + "--json", + "--search-timeout", + "30", + ]); + assert_eq!(cli.username.as_deref(), Some("alice")); + assert!(cli.json); + assert_eq!(cli.search_timeout, Some(30)); + } + + #[test] + fn global_flags_parse_before_the_subcommand() { + let cli = + parse(&["soulseek-rs", "--username", "alice", "browse", "bob"]); + assert_eq!(cli.username.as_deref(), Some("alice")); + assert!(matches!(cli.command, Some(Commands::Browse(_)))); + } + + #[test] + fn shared_dir_repeats_into_a_list() { + let cli = + parse(&["soulseek-rs", "--shared-dir", "/a", "--shared-dir", "/b"]); + assert_eq!(cli.shared_dir, vec!["/a".to_string(), "/b".to_string()]); + } + + #[test] + fn download_requires_user_and_path_without_stdin() { + assert!(Cli::try_parse_from(["soulseek-rs", "download"]).is_err()); + assert!( + Cli::try_parse_from(["soulseek-rs", "download", "bob"]).is_err() + ); + assert!( + Cli::try_parse_from(["soulseek-rs", "download", "bob", "a\\b.mp3"]) + .is_ok() + ); + } + + #[test] + fn download_stdin_needs_no_positionals() { + let cli = parse(&["soulseek-rs", "download", "--stdin"]); + let Some(Commands::Download(args)) = cli.command else { + panic!("expected download"); + }; + assert!(args.stdin); + assert!(args.user.is_none()); + } + + #[test] + fn download_stdin_conflicts_with_positionals() { + assert!( + Cli::try_parse_from([ + "soulseek-rs", + "download", + "--stdin", + "bob", + "a.mp3" + ]) + .is_err() + ); + } + + #[test] + fn room_and_message_groups_parse() { + assert!(matches!( + parse(&["soulseek-rs", "room", "list"]).command, + Some(Commands::Room(RoomCommand::List { .. })) + )); + assert!(matches!( + parse(&["soulseek-rs", "room", "say", "lobby", "hi"]).command, + Some(Commands::Room(RoomCommand::Say { .. })) + )); + assert!(matches!( + parse(&["soulseek-rs", "message", "read", "--follow"]).command, + Some(Commands::Message(MessageCommand::Read { follow: true, .. })) + )); + } + + #[test] + fn skills_dir_repeats_into_a_list() { + let cli = parse(&[ + "soulseek-rs", + "skills", + "install", + "--dir", + "/a", + "--dir", + "/b", + ]); + let Some(Commands::Skills(SkillsCommand::Install(args))) = cli.command + else { + panic!("expected skills install"); + }; + assert_eq!(args.dir, [PathBuf::from("/a"), PathBuf::from("/b")]); + } + + #[test] + fn get_defaults_to_picking_the_best_result() { + let cli = parse(&["soulseek-rs", "get", "query"]); + let Some(Commands::Get(args)) = cli.command else { + panic!("expected get"); + }; + assert_eq!(args.pick, Pick::Best); + assert_eq!(args.limit, 0); + assert_eq!(args.timeout, 300); + } + + #[test] + fn an_absurd_wait_is_rejected_rather_than_overflowing_a_deadline() { + assert!( + Cli::try_parse_from([ + "soulseek-rs", + "download", + "bob", + "a.mp3", + "--timeout", + "99999999999999" + ]) + .is_err() + ); + assert!( + Cli::try_parse_from([ + "soulseek-rs", + "--search-timeout", + "0", + "search", + "q" + ]) + .is_err() + ); + assert!( + Cli::try_parse_from(["soulseek-rs", "browse", "bob", "-t", "20"]) + .is_ok() + ); + } + + #[test] + fn server_address_splits_host_and_port() { + assert_eq!( + parse_server_address("server.slsknet.org:2416"), + Ok(("server.slsknet.org".to_string(), 2416)) + ); + assert!(parse_server_address("noport").is_err()); + assert!(parse_server_address("host:70000").is_err()); + assert!(parse_server_address(":2416").is_err()); + } +} diff --git a/soulseek-rs/src/commands/account.rs b/soulseek-rs/src/commands/account.rs new file mode 100644 index 0000000..56e1521 --- /dev/null +++ b/soulseek-rs/src/commands/account.rs @@ -0,0 +1,59 @@ +//! The account itself: its password on the server, and the credentials this +//! machine keeps for it. + +use super::{Ctx, Session}; +use crate::cli::PasswordArgs; +use crate::output::{AccountRecord, CliError, CliResult, Out}; +use crate::persist::secret::{KeyringStore, SecretStore}; + +/// Change the account's password and store the new one, so the next start +/// still logs in. +pub fn password(ctx: &Ctx, args: &PasswordArgs) -> CliResult { + let new = new_password(args)?; + let session = Session::open(ctx)?; + let user = session.client.username(); + let stored = crate::persist::secret::change_password( + session.client.as_ref(), + &KeyringStore, + &new, + ) + .map_err(|e| { + CliError::connection(format!("cannot change the password: {e}")) + })?; + if let Some(e) = stored { + ctx.out + .warn(&format!("password changed, but not stored here: {e}")); + } + ctx.out.emit(&AccountRecord { + user, + action: "password-changed".to_string(), + }); + Ok(()) +} + +/// Forget the stored password. The account is untouched on the server; what +/// goes is this machine's copy of the way in. +pub fn logout(out: &Out, username: Option<&str>) -> CliResult { + let user = username.ok_or_else(crate::run::missing_username)?; + KeyringStore.delete(user).map_err(|e| { + CliError::usage(format!("cannot clear the stored password: {e}")) + })?; + out.emit(&AccountRecord { + user: user.to_string(), + action: "logged-out".to_string(), + }); + Ok(()) +} + +/// Stdin beats the flag, the way `--password-stdin` beats `--password`. +fn new_password(args: &PasswordArgs) -> CliResult { + if args.new_password_stdin { + return crate::run::password_from_stdin(); + } + match args.new_password.as_deref() { + Some(password) if !password.is_empty() => Ok(password.to_string()), + _ => Err(CliError::usage( + "no new password: pass --new-password or --new-password-stdin", + )), + } +} diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs new file mode 100644 index 0000000..cd5582c --- /dev/null +++ b/soulseek-rs/src/commands/completions.rs @@ -0,0 +1,407 @@ +//! Tab completion for the shell: `completions`. +//! +//! The script is generated from the same `clap` definition the binary parses +//! with, so it never drifts. Installing means putting it where the shell +//! already looks — which is enough on its own only for fish; bash and zsh each +//! get one `source` line appended to their rc file, marked so `uninstall` can +//! take back exactly what was added and nothing else. + +use super::{unwritable, xdg}; +use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; +use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out}; +use clap::CommandFactory; +use directories::UserDirs; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; + +/// The command being completed, and the file name the scripts are written as. +const BIN: &str = "soulseek-rs"; + +/// What every line this command writes into an rc file ends with, and the only +/// thing `uninstall` will delete from one. +const MARKER: &str = "# soulseek-rs completions"; + +/// The directories the install locations hang off, resolved once so the rest +/// of the module is a pure function of them. +struct Dirs { + home: PathBuf, + data: PathBuf, + config: PathBuf, +} + +/// One shell's completion script, and the rc file that has to load it. +struct Target { + shell: Shell, + script: PathBuf, + /// `None` for fish, whose completions directory is scanned automatically. + rc: Option, +} + +impl Target { + fn record(&self, action: &str) -> CompletionRecord { + CompletionRecord { + shell: self.shell.generator().to_string(), + path: self.script.display().to_string(), + action: action.to_string(), + } + } + + fn source_line(&self) -> String { + format!("source \"{}\" {MARKER}", self.script.display()) + } +} + +impl Shell { + const fn generator(self) -> clap_complete::Shell { + match self { + Self::Bash => clap_complete::Shell::Bash, + Self::Zsh => clap_complete::Shell::Zsh, + Self::Fish => clap_complete::Shell::Fish, + } + } +} + +pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult { + match command { + CompletionsCommand::Install(args) => install(out, &targets(args)?), + CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + CompletionsCommand::Print { shell } => print(&script(*shell)), + } +} + +pub fn man() -> CliResult { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust")) + .render(&mut page) + .expect("a Vec accepts every write"); + print(&page) +} + +fn print(bytes: &[u8]) -> CliResult { + match std::io::stdout().write_all(bytes) { + Err(error) if error.kind() != ErrorKind::BrokenPipe => { + Err(CliError::new( + Exit::Failure, + format!("cannot write stdout: {error}"), + )) + } + _ => Ok(()), + } +} + +fn install(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let mut changed = write_script(&target.script, &script(target.shell))?; + if let Some(rc) = &target.rc { + changed |= add_source_line(rc, &target.source_line())?; + } + out.emit(&target.record(if changed { + "installed" + } else { + "unchanged" + })); + } + out.status("open a new shell to pick it up"); + Ok(()) +} + +fn uninstall(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let mut changed = remove_script(&target.script)?; + if let Some(rc) = &target.rc { + changed |= remove_source_lines(rc)?; + } + out.emit(&target.record(if changed { "removed" } else { "absent" })); + } + Ok(()) +} + +/// The completion script as this binary's own argument parser defines it. +fn script(shell: Shell) -> Vec { + let mut buffer = Vec::new(); + clap_complete::generate( + shell.generator(), + &mut Cli::command(), + BIN, + &mut buffer, + ); + buffer +} + +/// Which shells to act on. Naming shells aims the command at exactly those; +/// detection is what answers the question when nothing does. +fn targets(args: &CompletionsArgs) -> CliResult> { + let dirs = Dirs::detect()?; + let shells = if args.shell.is_empty() { + let found = detected(&dirs, std::env::var("SHELL").ok().as_deref()); + if found.is_empty() { + return Err(CliError::no_results( + "no bash, zsh, or fish setup found in your home directory; \ + name one with --shell", + )); + } + found + } else { + args.shell.clone() + }; + Ok(shells + .into_iter() + .map(|shell| target(shell, &dirs)) + .collect()) +} + +/// Where each shell looks. bash and zsh both read a per-user directory that +/// nothing puts on the search path by default, hence the rc line; fish scans +/// its own completions directory unprompted. +fn target(shell: Shell, dirs: &Dirs) -> Target { + let (script, rc) = match shell { + Shell::Bash => ( + dirs.data.join("bash-completion/completions").join(BIN), + Some(bash_rc(&dirs.home)), + ), + Shell::Zsh => ( + dirs.data.join("zsh/site-functions").join(format!("_{BIN}")), + Some(dirs.home.join(".zshrc")), + ), + Shell::Fish => ( + dirs.config + .join("fish/completions") + .join(format!("{BIN}.fish")), + None, + ), + }; + Target { shell, script, rc } +} + +/// A shell counts as present when it has left configuration behind, or when it +/// is the one the user is logged into — a fresh macOS zsh account has no +/// `.zshrc` at all until something writes one. +fn detected(dirs: &Dirs, login_shell: Option<&str>) -> Vec { + let running = + |name: &str| login_shell.is_some_and(|path| path.ends_with(name)); + [ + ( + Shell::Bash, + dirs.home.join(".bashrc").exists() + || dirs.home.join(".bash_profile").exists() + || running("bash"), + ), + ( + Shell::Zsh, + dirs.home.join(".zshrc").exists() || running("zsh"), + ), + ( + Shell::Fish, + dirs.config.join("fish").is_dir() || running("fish"), + ), + ] + .into_iter() + .filter_map(|(shell, present)| present.then_some(shell)) + .collect() +} + +/// Interactive bash reads `.bashrc`, except on machines where only the login +/// file exists — which is the common macOS shape. +fn bash_rc(home: &Path) -> PathBuf { + let profile = home.join(".bash_profile"); + let rc = home.join(".bashrc"); + if !rc.exists() && profile.exists() { + profile + } else { + rc + } +} + +impl Dirs { + fn detect() -> CliResult { + let home = UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or_else(|| { + CliError::usage("cannot find your home directory") + })?; + Ok(Self { + data: xdg("XDG_DATA_HOME", &home, ".local/share"), + config: xdg("XDG_CONFIG_HOME", &home, ".config"), + home, + }) + } +} + +fn write_script(path: &Path, script: &[u8]) -> CliResult { + if std::fs::read(path).is_ok_and(|found| found == script) { + return Ok(false); + } + create_parent(path)?; + std::fs::write(path, script).map_err(|e| unwritable(path, &e))?; + Ok(true) +} + +fn remove_script(path: &Path) -> CliResult { + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(unwritable(path, &e)), + } +} + +fn add_source_line(rc: &Path, line: &str) -> CliResult { + let existing = std::fs::read_to_string(rc).unwrap_or_default(); + if existing.lines().any(|found| found == line) { + return Ok(false); + } + create_parent(rc)?; + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(rc) + .map_err(|e| unwritable(rc, &e))?; + writeln!(file, "{separator}{line}").map_err(|e| unwritable(rc, &e))?; + Ok(true) +} + +/// Take back only the marked lines. Somebody else's rc file is a bad thing to +/// rewrite from memory, so every other line is preserved verbatim. +fn remove_source_lines(rc: &Path) -> CliResult { + let Ok(existing) = std::fs::read_to_string(rc) else { + return Ok(false); + }; + let kept: Vec<&str> = existing + .lines() + .filter(|line| !line.ends_with(MARKER)) + .collect(); + if kept.len() == existing.lines().count() { + return Ok(false); + } + let mut rewritten = kept.join("\n"); + if !rewritten.is_empty() { + rewritten.push('\n'); + } + std::fs::write(rc, rewritten).map_err(|e| unwritable(rc, &e))?; + Ok(true) +} + +fn create_parent(path: &Path) -> CliResult { + match path.parent() { + Some(parent) => { + std::fs::create_dir_all(parent).map_err(|e| unwritable(parent, &e)) + } + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirs(home: &Path) -> Dirs { + Dirs { + home: home.to_path_buf(), + data: home.join(".local/share"), + config: home.join(".config"), + } + } + + /// The rc line only works because sourcing the script registers the + /// completion rather than just defining a function. zsh is the one that + /// could plausibly go the other way: an autoload-style `#compdef` file does + /// nothing on its own, so the install rests on the footer clap emits. + #[test] + fn a_sourced_script_registers_the_command() { + let zsh = String::from_utf8(script(Shell::Zsh)).expect("utf-8"); + assert!(zsh.contains("compdef _soulseek-rs soulseek-rs")); + let bash = String::from_utf8(script(Shell::Bash)).expect("utf-8"); + assert!(bash.contains("complete -F _soulseek-rs")); + } + + #[test] + fn each_shell_lands_where_it_already_looks() { + let home = PathBuf::from("/home/me"); + let dirs = dirs(&home); + for (shell, script, rc) in [ + ( + Shell::Bash, + ".local/share/bash-completion/completions/soulseek-rs", + Some(".bashrc"), + ), + ( + Shell::Zsh, + ".local/share/zsh/site-functions/_soulseek-rs", + Some(".zshrc"), + ), + // fish scans its own completions directory, so it needs no rc line. + ( + Shell::Fish, + ".config/fish/completions/soulseek-rs.fish", + None, + ), + ] { + let target = target(shell, &dirs); + assert_eq!(target.script, home.join(script)); + assert_eq!(target.rc, rc.map(|file| home.join(file))); + } + } + + #[test] + fn only_shells_this_machine_uses_become_targets() { + let home = tempfile::tempdir().expect("tempdir"); + let dirs = dirs(home.path()); + assert!(detected(&dirs, None).is_empty()); + assert_eq!(detected(&dirs, Some("/bin/zsh")), [Shell::Zsh]); + + std::fs::write(home.path().join(".bashrc"), "").expect("bashrc"); + std::fs::create_dir_all(dirs.config.join("fish")).expect("fish"); + assert_eq!(detected(&dirs, None), [Shell::Bash, Shell::Fish]); + } + + #[test] + fn bash_falls_back_to_the_login_file_when_that_is_all_there_is() { + let home = tempfile::tempdir().expect("tempdir"); + assert_eq!(bash_rc(home.path()), home.path().join(".bashrc")); + + std::fs::write(home.path().join(".bash_profile"), "").expect("write"); + assert_eq!(bash_rc(home.path()), home.path().join(".bash_profile")); + + std::fs::write(home.path().join(".bashrc"), "").expect("write"); + assert_eq!(bash_rc(home.path()), home.path().join(".bashrc")); + } + + #[test] + fn installing_twice_adds_one_line_and_uninstalling_takes_only_that() { + let home = tempfile::tempdir().expect("tempdir"); + let rc = home.path().join(".zshrc"); + std::fs::write(&rc, "export EDITOR=vi").expect("rc"); + let target = target(Shell::Zsh, &dirs(home.path())); + let line = target.source_line(); + + assert!(add_source_line(&rc, &line).expect("add")); + assert!(!add_source_line(&rc, &line).expect("add again")); + let after = std::fs::read_to_string(&rc).expect("read"); + assert_eq!(after, format!("export EDITOR=vi\n{line}\n")); + + assert!(remove_source_lines(&rc).expect("remove")); + assert!(!remove_source_lines(&rc).expect("remove again")); + assert_eq!( + std::fs::read_to_string(&rc).expect("read"), + "export EDITOR=vi\n" + ); + } + + #[test] + fn a_script_is_written_once_and_removed_once() { + let home = tempfile::tempdir().expect("tempdir"); + let path = target(Shell::Fish, &dirs(home.path())).script; + let fish = script(Shell::Fish); + + assert!(write_script(&path, &fish).expect("write")); + assert!(!write_script(&path, &fish).expect("write again")); + assert_eq!(std::fs::read(&path).expect("read"), fish); + + assert!(remove_script(&path).expect("remove")); + assert!(!remove_script(&path).expect("remove again")); + } +} diff --git a/soulseek-rs/src/commands/daemon.rs b/soulseek-rs/src/commands/daemon.rs new file mode 100644 index 0000000..8f41fd4 --- /dev/null +++ b/soulseek-rs/src/commands/daemon.rs @@ -0,0 +1,88 @@ +//! Controlling the service: `daemon token`, `daemon status`, `daemon stop`. +//! +//! Running the daemon itself lives in [`crate::daemon`]; these are the +//! one-shot commands that talk *to* one. None of them needs a Soulseek +//! account — the daemon already has one — so they are dispatched before +//! credentials are demanded. + +use crate::daemon::proto::{DaemonStatus, Method}; +use crate::output::{ + CliError, CliResult, DaemonStatusRecord, Out, TokenRecord, +}; +use crate::remote::{Endpoint, RemoteSession}; + +/// Print the token a client on another machine needs. +/// +/// Local clients never need this — the socket's permissions already answer the +/// question — so it exists for the moment you point a second machine at this +/// one. +pub fn token(out: &Out) -> CliResult { + let path = crate::daemon::token_path().ok_or_else(|| { + CliError::usage("no state directory: set SOULSEEK_STATE_DIR") + })?; + let token = crate::daemon::load_or_create_token(&path).map_err(|e| { + CliError::usage(format!("cannot read {}: {e}", path.display())) + })?; + out.emit(&TokenRecord { token }); + Ok(()) +} + +/// Ask a running daemon what it is doing. +pub fn status( + out: &Out, + endpoint: Option<&Endpoint>, + token: Option, +) -> CliResult { + let status: DaemonStatus = ask(endpoint, token, Method::DaemonStatus)?; + out.emit(&DaemonStatusRecord { + user: status.username, + server: status.server, + version: status.daemon_version, + protocol: status.protocol, + listening: status.listen_port.is_some(), + listen_port: status.listen_port.unwrap_or(0), + shared_folders: status.shared_folders, + shared_files: status.shared_files, + download_dir: status.download_dir, + session_loss: status + .session_loss + .map(|loss| format!("{loss:?}").to_lowercase()), + clients: status.clients, + uptime_secs: status.uptime_secs, + }); + Ok(()) +} + +/// Ask a running daemon to shut down. +pub fn stop( + out: &Out, + endpoint: Option<&Endpoint>, + token: Option, +) -> CliResult { + let _: serde_json::Value = ask(endpoint, token, Method::DaemonStop)?; + out.status("the daemon is stopping"); + Ok(()) +} + +/// One request to whichever daemon this run is pointed at. +fn ask( + endpoint: Option<&Endpoint>, + token: Option, + method: Method, +) -> CliResult { + let endpoint = endpoint.ok_or_else(no_daemon)?; + let token = token.or_else(crate::daemon::stored_token); + let session = RemoteSession::connect(endpoint, token).map_err(|e| { + CliError::connection(format!("cannot reach the daemon: {e}")) + })?; + session + .ask_for(method) + .map_err(|e| CliError::connection(e.to_string())) +} + +fn no_daemon() -> CliError { + CliError::connection( + "no daemon is running; start one with `soulseek-rs daemon`, or point \ + at a remote one with --daemon", + ) +} diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs new file mode 100644 index 0000000..dbafa5c --- /dev/null +++ b/soulseek-rs/src/commands/mod.rs @@ -0,0 +1,404 @@ +//! One-shot commands: everything the binary can do without a terminal. +//! +//! Each command does one job, writes records to stdout and progress to stderr, +//! and returns an exit code through [`CliError`]. Most open a [`Session`] +//! 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; +pub mod settings; +pub mod skills; +pub mod social; +pub mod transfer; +pub mod wish; + +use crate::api::SessionApi; +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}; +use soulseek_rs::types::{RoomEvent, RoomInfo}; +use soulseek_rs::{Client, ClientSettings, SessionLoss}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +/// Everything a one-shot command needs that does not come from its own flags. +pub struct Ctx { + pub out: Out, + pub settings: ClientSettings, + pub download_dir: String, + /// Save each peer's files under `download_dir/`. + pub use_username_dirs: bool, + pub max_concurrent_downloads: usize, + pub search_timeout: Duration, + /// Where to find a daemon, when this run should use one rather than open + /// its own session. Resolved once in `main`, so every command agrees. + pub daemon: Option, + /// Token for a remote daemon; a Unix socket needs none. + pub daemon_token: Option, + /// The client of the session this command opened, once it has one, so the + /// verdict can be checked against the session that produced it. + pub session: OnceLock>, +} + +impl Ctx { + /// Where a download from `username` lands. + #[must_use] + pub fn dir_for(&self, username: &str) -> String { + crate::directories::download_dir_for( + &self.download_dir, + username, + self.use_username_dirs, + ) + } + + /// Re-read a "found nothing", "timed out" or "transfer failed" verdict + /// against the session that produced it. + /// + /// A session the server cut off — most often because another process + /// logged in under the same name — sees nothing at all from that moment + /// on. Reported as exit 4 it reads as "this file is not on the network", + /// and a caller acting on that gives up on files that were there. + fn checked(&self, result: CliResult) -> CliResult { + let Err(error) = result else { + return Ok(()); + }; + if !matches!( + error.exit, + Exit::NoResults | Exit::Timeout | Exit::Transfer + ) { + return Err(error); + } + match self.session.get().and_then(|client| client.session_loss()) { + Some(loss) => Err(session_lost(loss)), + None => Err(error), + } + } +} + +/// What to tell a caller whose session ended under it. +fn session_lost(loss: SessionLoss) -> CliError { + let fix = match loss { + SessionLoss::Displaced => { + "; give each concurrent run its own --username" + } + SessionLoss::Disconnected => "", + }; + CliError::session_lost(format!( + "the session ended before this command finished: {loss}{fix}" + )) +} + +/// A logged-in client plus, while the listener is enabled, the port mapping +/// that makes us reachable. Both live until the command ends. +pub struct Session { + pub client: Arc, + _mapper: Option, +} + +/// How long to wait for the TCP handshake with the server. +const REACH_TIMEOUT: Duration = Duration::from_secs(10); +/// How long to wait for the login exchange once the socket is open. +const LOGIN_TIMEOUT: Duration = Duration::from_secs(30); + +impl Session { + /// Get a session: the daemon's, if this run is routed to one, or a fresh + /// login of its own. + pub fn open(ctx: &Ctx) -> CliResult { + match &ctx.daemon { + Some(endpoint) => Self::attach(ctx, endpoint), + None => Self::login(ctx), + } + } + + /// Borrow the daemon's session instead of logging in again. + /// + /// The server allows one login per account, so a second login would + /// displace the daemon — attaching is not just cheaper, it is the only way + /// two things can be on the network as the same user at once. + fn attach(ctx: &Ctx, endpoint: &Endpoint) -> CliResult { + ctx.out.status(&format!("using the daemon at {endpoint}")); + let token = ctx + .daemon_token + .clone() + .or_else(crate::daemon::stored_token); + let remote = RemoteSession::connect(endpoint, token).map_err(|e| { + CliError::connection(format!("cannot use the daemon: {e}")) + })?; + + let client: Arc = Arc::new(remote); + ctx.out + .status(&format!("logged in as {}", client.username())); + let _ = ctx.session.set(Arc::clone(&client)); + Ok(Self { + client, + // The daemon holds the listener, so it owns the port mapping too. + _mapper: None, + }) + } + + fn login(ctx: &Ctx) -> CliResult { + let address = ctx.settings.server_address.to_string(); + ctx.out.status(&format!("connecting to {address}…")); + reachable(&address, REACH_TIMEOUT)?; + + let client: Arc = + Arc::new(login(ctx.settings.clone(), &address)?); + ctx.out + .status(&format!("logged in as {}", ctx.settings.username)); + let _ = ctx.session.set(Arc::clone(&client)); + + let bound = client.listen_port(); + if let Some(bound) = bound + && ctx.settings.listen_port != 0 + && bound != ctx.settings.listen_port + { + ctx.out.warn(&format!( + "listener port {} is in use; listening on {bound} instead", + ctx.settings.listen_port + )); + } + + // Ask the router for the port the listener actually holds: when the + // configured one was taken, mapping it would open a hole to whoever + // did take it. + let mapper = bound.map(PortMapper::spawn); + + Ok(Self { + client, + _mapper: mapper, + }) + } +} + +/// Check the server answers at all before handing the socket to the client. +/// +/// The connect and login the library performs happen on its own threads and +/// report a refused connection only indirectly, so an unattended run would sit +/// there. One handshake up front turns that into an immediate, accurate error. +fn reachable(address: &str, timeout: Duration) -> CliResult { + let resolved: Vec = address + .to_socket_addrs() + .map_err(|e| { + CliError::connection(format!("cannot resolve {address}: {e}")) + })? + .collect(); + + let mut last = None; + for candidate in &resolved { + match TcpStream::connect_timeout(candidate, timeout) { + Ok(_) => return Ok(()), + Err(e) => last = Some(e), + } + } + Err(CliError::connection(match last { + Some(e) => format!("cannot reach {address}: {e}"), + None => format!("cannot reach {address}: no address resolved"), + })) +} + +/// Connect and log in under a deadline. +/// +/// `Client::login` blocks on a channel the library only answers once its actor +/// has a working connection, so it is run on its own thread: a server that +/// accepts the socket and then says nothing must still end the command. +fn login(settings: ClientSettings, address: &str) -> CliResult { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut client = Client::with_settings(settings); + let outcome = match client.connect() { + Ok(()) => client.login(), + Err(e) => Err(e), + }; + let _ = sender.send((client, outcome)); + }); + + let (client, outcome) = + receiver.recv_timeout(LOGIN_TIMEOUT).map_err(|_| { + CliError::connection(format!( + "{address} accepted the connection but did not answer the \ + login within {}s", + LOGIN_TIMEOUT.as_secs() + )) + })?; + + match outcome { + Ok(true) => Ok(client), + Ok(false) => Err(CliError::connection( + "login rejected by the server (wrong password, or the name is \ + taken)", + )), + Err(e) => Err(CliError::connection(format!("login failed: {e}"))), + } +} + +/// Ask `f` every `poll` until it answers or `timeout` runs out. +/// +/// The library reports what the network sent by handing it over on the next +/// call, so a command that wants one answer has to keep asking. Checking first +/// and sleeping after means an answer already waiting costs nothing. +fn poll_until( + timeout: Duration, + poll: Duration, + mut f: impl FnMut() -> Option, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(answer) = f() { + return Some(answer); + } + std::thread::sleep(poll); + } + None +} + +/// Ask for the room list and wait for the server's reply. +/// +/// Events already queued are discarded first: servers push a room list at +/// login, and counting that one would answer a question we had not asked yet. +pub fn await_room_list( + client: &dyn SessionApi, + timeout: Duration, + poll: Duration, +) -> Option> { + let _ = client.take_room_events(); + client.request_room_list().ok()?; + + poll_until(timeout, poll, || { + client + .take_room_events() + .into_iter() + .find_map(|event| match event { + RoomEvent::List(rooms) => Some(rooms), + _ => None, + }) + }) +} + +/// Wait until the server has answered something we asked for after `send`. +/// +/// The server connection is a single ordered stream served by one actor, so a +/// reply to a request queued *after* an earlier message proves that earlier +/// message reached the socket. The room list is the cheapest such request and +/// every server answers it, which lets `message send` and `room say` report +/// delivery honestly instead of sleeping and hoping. +pub fn confirm_flush(client: &dyn SessionApi, timeout: Duration) -> bool { + await_room_list(client, timeout, Duration::from_millis(50)).is_some() +} + +pub fn run(ctx: &Ctx, command: Commands) -> CliResult { + ctx.checked(dispatch(ctx, command)) +} + +fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { + match command { + Commands::Search(args) => transfer::search(ctx, &args), + Commands::Download(args) => transfer::download(ctx, &args), + Commands::Transfer(TransferCommand::Cancel { user, path }) => { + transfer::cancel(ctx, &user, &path) + } + Commands::Get(args) => transfer::get(ctx, &args), + Commands::Browse(args) => social::browse(ctx, &args), + Commands::Room(RoomCommand::List { timeout }) => { + social::room_list(ctx, Duration::from_secs(timeout)) + } + Commands::Room(RoomCommand::Say { room, message }) => { + social::room_say(ctx, &room, &message) + } + Commands::Room(RoomCommand::Users { room, timeout }) => { + social::room_users(ctx, &room, Duration::from_secs(timeout)) + } + Commands::Room(RoomCommand::Listen { + room, + duration, + follow, + }) => social::room_listen(ctx, &room, listen_span(duration, follow)), + Commands::Message(MessageCommand::Send { user, message }) => { + social::message_send(ctx, &user, &message) + } + Commands::Message(MessageCommand::Read { duration, follow }) => { + social::message_read(ctx, listen_span(duration, follow)) + } + 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::Account(AccountCommand::Logout) + | Commands::Portmap + | Commands::Serve(_) + | Commands::Daemon(_) + | Commands::Wish(_) + | Commands::Config(_) + | Commands::Shares(_) + | Commands::Skills(_) + | Commands::Completions(_) + | Commands::Man => { + Err(CliError::usage("handled without a session; see main::run")) + } + } +} + +/// `--follow` means "until interrupted": a span nothing will outlive. +fn listen_span(duration: u64, follow: bool) -> Option { + (!follow).then(|| Duration::from_secs(duration)) +} + +/// Where shells and coding agents keep their configuration: the XDG layout on +/// every platform, unlike this client's own config, which follows each +/// platform's convention (see `persist::paths`). +fn xdg(variable: &str, home: &Path, fallback: &str) -> PathBuf { + std::env::var_os(variable) + .filter(|dir| !dir.is_empty()) + .map_or_else(|| home.join(fallback), PathBuf::from) +} + +fn unwritable(path: &Path, error: &std::io::Error) -> CliError { + CliError::usage(format!("cannot write {}: {error}", path.display())) +} + +/// Probe automatic port mapping. A router that does not answer is a negative +/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match. +pub fn portmap(out: &Out, port: u16) -> CliResult { + out.status(&format!("probing automatic port mapping for TCP {port}…")); + let probe = port_mapping::probe(port); + out.emit(&PortmapRecord { + ok: probe.ok, + backend: probe.backend.to_string(), + external: probe.external.clone(), + port, + }); + out.status(&probe.advice); + if probe.ok { + Ok(()) + } else { + // The advice is already on stderr; the error line only has to say + // what the exit code means. + Err(CliError::new( + Exit::NoResults, + format!("no router mapped TCP {port}"), + )) + } +} + +#[cfg(test)] +mod tests { + use super::listen_span; + use std::time::Duration; + + #[test] + fn follow_listens_without_a_deadline() { + assert_eq!(listen_span(30, true), None); + assert_eq!(listen_span(30, false), Some(Duration::from_secs(30))); + } +} diff --git a/soulseek-rs/src/commands/peer.rs b/soulseek-rs/src/commands/peer.rs new file mode 100644 index 0000000..ba6218c --- /dev/null +++ b/soulseek-rs/src/commands/peer.rs @@ -0,0 +1,331 @@ +//! Being a peer on the network: `serve`, `whoami`, and `user`. + +use super::{Ctx, Session}; +use crate::cli::{ServeArgs, UserArgs, WatchCommand}; +use crate::output::{ + CliError, CliResult, UploadRecord, UserRecord, WatchedUserRecord, + WhoamiRecord, +}; +use soulseek_rs::types::{UploadInfo, UploadStatus, UserInfo}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// How often the serve loop samples the upload table. +const POLL: Duration = Duration::from_millis(500); + +/// How long `whoami` waits for the server's privilege answer. It is one small +/// message on an already-open connection, so a server that has not replied by +/// now is not going to. +const PRIVILEGE_TIMEOUT: Duration = Duration::from_secs(5); + +pub fn whoami(ctx: &Ctx) -> CliResult { + let session = Session::open(ctx)?; + let (folders, files) = session.client.shared_counts(); + // The bound port, not the configured one: a caller checking whether it is + // reachable needs what is true, not what was asked for. + let bound = session.client.listen_port(); + ctx.out.emit(&WhoamiRecord { + // The session's own name, not this run's configuration: routed to a + // daemon they are different, and the session is the one that is + // actually on the network. + user: session.client.username(), + server: ctx.settings.server_address.to_string(), + listening: bound.is_some(), + listen_port: bound.unwrap_or(0), + shared_folders: folders, + shared_files: files, + download_dir: ctx.download_dir.clone(), + privilege_seconds: own_privileges(session.client.as_ref()), + }); + Ok(()) +} + +/// Ask the server how much privilege time this account has left. +/// +/// A server that does not answer leaves the field null rather than reporting a +/// zero, because "no privileges" and "did not say" are different facts. +fn own_privileges(client: &dyn crate::api::SessionApi) -> Option { + if client.check_privileges().is_err() { + return None; + } + super::poll_until(PRIVILEGE_TIMEOUT, Duration::from_millis(50), || { + client.own_privilege_seconds() + }) +} + +pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult { + let session = Session::open(ctx)?; + session.client.request_user_info(&args.user).map_err(|e| { + CliError::connection(format!("cannot ask about {}: {e}", args.user)) + })?; + ctx.out + .status(&format!("asking the server about {}…", args.user)); + + // Presence and statistics are two replies; wait for both, but report + // whichever arrived rather than defaulting the other to something that + // reads like an answer. + let deadline = Instant::now() + Duration::from_secs(args.timeout); + let mut latest = None; + while Instant::now() < deadline { + latest = session.client.user_info(&args.user); + if latest.as_ref().is_some_and(UserInfo::is_complete) { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let Some(info) = latest else { + return Err(CliError::timeout(format!( + "the server said nothing about {} within {}s", + args.user, args.timeout + ))); + }; + if !info.is_complete() { + ctx.out + .warn("the server answered only partly; unknown fields are null"); + } + ctx.out.emit(&UserRecord { + user: info.username, + status: info.presence.map(|p| p.status.to_string()), + privileged: info.presence.map(|p| p.privileged), + average_speed: info.stats.map(|s| s.average_speed), + shared_files: info.stats.map(|s| s.shared_files), + shared_folders: info.stats.map(|s| s.shared_folders), + }); + Ok(()) +} + +/// Start or stop watching a user, or list who is being watched. +/// +/// A watch lives in the session that made it, so `add` against a one-shot CLI +/// run ends with that run. Pointed at a daemon it persists for that daemon's +/// lifetime, which is the point of the command. +pub fn watch(ctx: &Ctx, command: &WatchCommand) -> CliResult { + let session = Session::open(ctx)?; + match command { + WatchCommand::Add { user } => { + session.client.watch_user(user).map_err(|e| { + CliError::connection(format!("cannot watch {user}: {e}")) + })?; + ctx.out.status(&format!("watching {user}")); + } + WatchCommand::Remove { user } => { + session.client.unwatch_user(user).map_err(|e| { + CliError::connection(format!("cannot unwatch {user}: {e}")) + })?; + ctx.out.status(&format!("no longer watching {user}")); + } + WatchCommand::List => { + for user in session.client.watched_users() { + let status = session + .client + .user_info(&user) + .and_then(|info| info.presence) + .map(|p| p.status.to_string()); + ctx.out.emit(&WatchedUserRecord { user, status }); + } + } + } + Ok(()) +} + +/// Stay online sharing files, and — with `--wishlist` — re-run the standing +/// searches on the interval the server sets while doing so. +pub fn serve( + ctx: &Ctx, + args: &ServeArgs, + mut sweeper: Option, +) -> CliResult { + // Cheap check first, so a misconfigured local run is rejected before it + // bothers the server with a login. + if ctx.daemon.is_none() { + if ctx.settings.shared_directories.is_empty() { + return Err(nothing_to_share()); + } + if !ctx.settings.enable_listen { + return Err(needs_a_listener()); + } + } + + let session = Session::open(ctx)?; + // What the session actually offers is what decides. Routed to a daemon + // that is what the daemon shares, which is the only thing peers can see — + // this run's own configuration has no bearing on it. + if session.client.shared_directories().is_empty() { + return Err(nothing_to_share()); + } + if session.client.listen_port().is_none() { + return Err(needs_a_listener()); + } + if let Some(slots) = args.upload_slots { + session.client.set_upload_slots(usize::from(slots)); + ctx.out.status(&format!("{slots} upload slots")); + } + let (folders, files) = session.client.shared_counts(); + ctx.out.status(&format!( + "serving {files} files in {folders} folders on port {}", + session.client.listen_port().unwrap_or_default() + )); + if files == 0 { + ctx.out + .warn("the share index is empty — peers will find nothing"); + } + + let span = super::listen_span(args.duration, args.follow); + ctx.out.status(&match span { + Some(span) => format!("online for {}s…", span.as_secs()), + None => "online until interrupted…".to_string(), + }); + + let deadline = span.map(|span| Instant::now() + span); + let mut reported: HashMap<(String, String), UploadRecord> = HashMap::new(); + while deadline.is_none_or(|deadline| Instant::now() < deadline) { + // Queued states first, and from the client's own record rather than + // this snapshot: with few slots and small files a peer can queue and be + // served well inside one poll, and "it had to wait" is precisely what a + // transfer log exists to report. + for queued in session.client.take_upload_events() { + emit_changed(&mut reported, upload_record(&queued), &ctx.out); + } + for upload in session.client.uploads() { + emit_changed(&mut reported, upload_record(&upload), &ctx.out); + } + if let Some(sweeper) = sweeper.as_mut() { + sweeper.tick(ctx, session.client.as_ref()); + } + std::thread::sleep(POLL); + } + Ok(()) +} + +fn nothing_to_share() -> CliError { + CliError::usage( + "nothing to share: pass --shared-dir, or add one with `shares add`", + ) +} + +fn needs_a_listener() -> CliError { + CliError::usage("serving needs the listener: drop --no-listener") +} + +/// Flatten a live upload into the record `serve` reports. +fn upload_record(upload: &UploadInfo) -> UploadRecord { + let (status, reason) = match &upload.status { + UploadStatus::Queued(place) => { + ("queued", Some(format!("place {place}"))) + } + UploadStatus::InProgress => ("uploading", None), + UploadStatus::Completed => ("completed", None), + UploadStatus::Cancelled => ("cancelled", None), + UploadStatus::Failed(why) => ("failed", Some(why.clone())), + }; + UploadRecord { + user: upload.username.clone(), + path: upload.filename.clone(), + size: upload.size, + bytes_sent: upload.bytes_sent, + speed: upload.speed_bytes_per_sec, + status: status.to_string(), + reason, + } +} + +/// Emit `record` only when it differs from the last one reported for that +/// (user, file). +fn emit_changed( + reported: &mut HashMap<(String, String), UploadRecord>, + record: UploadRecord, + out: &crate::output::Out, +) -> bool { + let key = (record.user.clone(), record.path.clone()); + if reported.get(&key) == Some(&record) { + return false; + } + out.emit(&record); + reported.insert(key, record); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{Out, Record}; + + fn upload(status: UploadStatus, bytes_sent: u64) -> UploadInfo { + UploadInfo { + username: "bob".to_string(), + filename: "@@x\\track.mp3".to_string(), + size: 100, + bytes_sent, + status, + speed_bytes_per_sec: 1.0, + } + } + + #[test] + fn an_upload_maps_to_its_wire_status() { + assert_eq!( + upload_record(&upload(UploadStatus::InProgress, 5)).status, + "uploading" + ); + assert_eq!( + upload_record(&upload(UploadStatus::Completed, 100)).status, + "completed" + ); + assert_eq!( + upload_record(&upload(UploadStatus::Cancelled, 5)).status, + "cancelled" + ); + + let failed = + upload_record(&upload(UploadStatus::Failed("gone".into()), 5)); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.reason.as_deref(), Some("gone")); + } + + #[test] + fn the_upload_line_leads_with_status_and_progress() { + let record = upload_record(&upload(UploadStatus::InProgress, 42)); + assert_eq!(record.text(), "uploading\tbob\t42/100\t@@x\\track.mp3"); + } + + #[test] + fn an_unchanged_upload_is_reported_once() { + let out = Out::new(false, true); + let mut reported = HashMap::new(); + let record = upload_record(&upload(UploadStatus::InProgress, 42)); + + assert!(emit_changed(&mut reported, record.clone(), &out)); + assert!( + !emit_changed(&mut reported, record, &out), + "polling must not re-report an upload that has not moved" + ); + } + + #[test] + fn progress_and_status_changes_are_both_reported() { + let out = Out::new(false, true); + let mut reported = HashMap::new(); + emit_changed( + &mut reported, + upload_record(&upload(UploadStatus::InProgress, 10)), + &out, + ); + assert!( + emit_changed( + &mut reported, + upload_record(&upload(UploadStatus::InProgress, 60)), + &out + ), + "more bytes sent is news" + ); + assert!( + emit_changed( + &mut reported, + upload_record(&upload(UploadStatus::Completed, 100)), + &out + ), + "finishing is news" + ); + } +} diff --git a/soulseek-rs/src/commands/settings.rs b/soulseek-rs/src/commands/settings.rs new file mode 100644 index 0000000..c85970e --- /dev/null +++ b/soulseek-rs/src/commands/settings.rs @@ -0,0 +1,310 @@ +//! Managing what this client shares and what it remembers: `shares` and +//! `config`. +//! +//! Both write to the same `config.toml` the TUI uses, so a change made here is +//! what the next run — interactive or scripted — starts from. + +use super::{Ctx, Session}; +use crate::api::SessionApi; +use crate::directories; +use crate::output::{ + CliError, CliResult, ConfigRecord, Out, ShareRecord, ShareStatusRecord, +}; +use crate::persist::config::FileConfig; +use crate::remote::{Endpoint, RemoteSession}; +use std::path::PathBuf; + +/// Where a `shares`/`config` change is written, and what it starts from. +pub struct Store { + pub path: Option, + pub config: FileConfig, +} + +impl Store { + pub fn save(&self, config: &FileConfig) -> CliResult { + let Some(path) = &self.path else { + return Err(CliError::usage( + "no config file to write to (running with --no-config)", + )); + }; + config + .save(path) + .map_err(|e| CliError::usage(format!("cannot save config: {e}"))) + } +} + +pub fn shares_list(out: &Out, store: &Store, resolved: &[String]) -> CliResult { + let directories = configured_shares(store, resolved); + if directories.is_empty() { + return Err(CliError::no_results("no folders are shared")); + } + for directory in directories { + out.emit(&ShareRecord { + usable: directories::resolve_shared_directory(Some(&directory)) + .is_ok_and(|resolved| resolved.is_some()), + directory, + }); + } + Ok(()) +} + +/// Where a running daemon can be reached, so a share change takes effect now +/// rather than at its next restart. +pub struct LiveDaemon { + pub endpoint: Option, + pub token: Option, +} + +/// Tell a running daemon about a change it should apply now. +/// +/// Locally a config change means "from the next run on", because there is no +/// session yet. A daemon *is* that next run and it is already going, so +/// leaving it on its startup settings would make the command look like it +/// had done nothing — the folder configured, and the network none the wiser. +fn push_to_daemon( + out: &Out, + daemon: &LiveDaemon, + applied: &str, + kept: &str, + apply: impl FnOnce(&RemoteSession) -> soulseek_rs::Result<()>, +) { + let Some(endpoint) = daemon.endpoint.as_ref() else { + return; + }; + let token = daemon.token.clone().or_else(crate::daemon::stored_token); + let outcome = RemoteSession::connect(endpoint, token) + .and_then(|session| apply(&session)); + match outcome { + Ok(()) => out.status(applied), + // Not fatal: the config file is written either way, so the change + // still takes effect when the daemon next starts. + Err(e) => out.warn(&format!( + "the daemon kept {kept} ({e}); restart it to pick this up" + )), + } +} + +fn push_shares(out: &Out, daemon: &LiveDaemon, directories: Vec) { + push_to_daemon( + out, + daemon, + "the running daemon is re-indexing", + "its own shares", + move |session| session.set_shared_directories(directories), + ); +} + +pub fn shares_add( + out: &Out, + store: &Store, + directory: &str, + daemon: &LiveDaemon, +) -> CliResult { + // Refuse a folder that is not there: sharing it would silently index + // nothing, and the mistake would only surface as "no one can find my + // files" much later. + match directories::resolve_shared_directory(Some(directory)) { + Ok(Some(_)) => {} + Ok(None) => return Err(CliError::usage("no folder given")), + Err(e) => return Err(CliError::usage(e)), + } + + let mut updated = store.config.clone(); + let mut dirs = share_list(&store.config); + if dirs.iter().any(|existing| existing == directory) { + out.status(&format!("{directory} is already shared")); + return Ok(()); + } + dirs.push(directory.to_string()); + updated.shared_dirs = Some(dirs.clone()); + updated.shared_dir = None; + store.save(&updated)?; + out.status(&format!("sharing {directory}")); + push_shares(out, daemon, dirs); + Ok(()) +} + +pub fn shares_remove( + out: &Out, + store: &Store, + directory: &str, + daemon: &LiveDaemon, +) -> CliResult { + let mut dirs = share_list(&store.config); + let before = dirs.len(); + dirs.retain(|existing| existing != directory); + if dirs.len() == before { + return Err(CliError::no_results(format!( + "{directory} is not in the shared folders" + ))); + } + + let mut updated = store.config.clone(); + updated.shared_dirs = Some(dirs.clone()); + updated.shared_dir = None; + store.save(&updated)?; + out.status(&format!("no longer sharing {directory}")); + push_shares(out, daemon, dirs); + Ok(()) +} + +/// Connect with the configured shares so the library scans them, then report +/// what the network will actually see. +pub fn shares_status(ctx: &Ctx) -> CliResult { + let session = Session::open(ctx)?; + let (folders, files) = session.client.shared_counts(); + ctx.out.emit(&ShareStatusRecord { + folders, + files, + // What the session shares, which locally is what this run configured + // and against a daemon is what the daemon configured. + directories: session.client.shared_directories(), + }); + 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 { + let session = Session::open(ctx)?; + ctx.out.status("re-indexing the shared folders…"); + // Re-scan what the session already shares rather than imposing this run's + // configuration on it: against a daemon that would silently replace the + // daemon's shares with the client machine's. + let directories = session.client.shared_directories(); + session + .client + .set_shared_directories(directories.clone()) + .map_err(|e| CliError::usage(format!("cannot re-index: {e}")))?; + + let (folders, files) = session.client.shared_counts(); + ctx.out.emit(&ShareStatusRecord { + folders, + files, + directories, + }); + Ok(()) +} + +/// The folders the config file asks for, falling back to what this run +/// resolved (which includes `--shared-dir` and the download-folder default). +fn configured_shares(store: &Store, resolved: &[String]) -> Vec { + let configured = share_list(&store.config); + if configured.is_empty() { + return resolved.to_vec(); + } + configured +} + +/// Both config spellings, flattened: `shared_dir` is the single-folder form. +fn share_list(config: &FileConfig) -> Vec { + let mut dirs = config.shared_dirs.clone().unwrap_or_default(); + if let Some(single) = &config.shared_dir + && !single.trim().is_empty() + && !dirs.iter().any(|existing| existing == single) + { + dirs.insert(0, single.clone()); + } + dirs +} + +pub fn config_path(out: &Out, store: &Store) -> CliResult { + let Some(path) = &store.path else { + return Err(CliError::no_results( + "no config file in use (running with --no-config)", + )); + }; + out.emit(&ConfigRecord { + key: "config".to_string(), + value: Some(path.display().to_string()), + }); + Ok(()) +} + +pub fn config_list(out: &Out, store: &Store) { + for key in FileConfig::KEYS { + out.emit(&ConfigRecord { + key: (*key).to_string(), + value: store.config.get(key), + }); + } +} + +pub fn config_get(out: &Out, store: &Store, key: &str) -> CliResult { + let value = store.config.get(key).ok_or_else(|| unknown_or_unset(key))?; + out.emit(&ConfigRecord { + key: key.to_string(), + value: Some(value), + }); + Ok(()) +} + +pub fn config_set( + out: &Out, + store: &Store, + key: &str, + value: &str, + daemon: impl FnOnce() -> LiveDaemon, +) -> CliResult { + let mut updated = store.config.clone(); + updated.set(key, value).map_err(CliError::usage)?; + store.save(&updated)?; + let current = updated.get(key); + out.emit(&ConfigRecord { + key: key.to_string(), + value: current.clone(), + }); + // A running daemon holds the live settings, so for the keys it owns a + // config write alone would look like it did nothing until its next + // restart — the same trap `shares add` closes by pushing to the session. + // `daemon` is resolved lazily: finding one means connecting to a socket, + // which the other keys should not pay for. + match key { + "download_dir" => { + if let Some(directory) = current { + push_to_daemon( + out, + &daemon(), + "the running daemon will download there now", + "its own download folder", + move |session| session.set_download_directory(directory), + ); + } + } + "shared_dirs" => push_shares(out, &daemon(), share_list(&updated)), + _ => {} + } + Ok(()) +} + +fn unknown_or_unset(key: &str) -> CliError { + if FileConfig::KEYS.contains(&key) { + return CliError::no_results(format!("{key} is not set")); + } + CliError::usage(format!( + "unknown setting '{key}' — try one of: {}", + FileConfig::KEYS.join(", ") + )) +} diff --git a/soulseek-rs/src/commands/skills.rs b/soulseek-rs/src/commands/skills.rs new file mode 100644 index 0000000..cef2b4c --- /dev/null +++ b/soulseek-rs/src/commands/skills.rs @@ -0,0 +1,241 @@ +//! Teaching a local coding agent this command surface: `skills`. +//! +//! A `cargo install` leaves a binary and nothing else, so the skill travels +//! inside it and is written out on demand. Installing over an older copy is +//! what updating means, which is why there is no separate verb for it. + +use super::{unwritable, xdg}; +use crate::cli::{SkillsArgs, SkillsCommand}; +use crate::output::{CliError, CliResult, Out, SkillRecord}; +use directories::UserDirs; +use std::path::{Path, PathBuf}; + +/// The skill as shipped, and what the drift test holds the CLI to. +const SKILL: &str = include_str!("../SKILL.md"); + +/// The line `uninstall` requires before deleting anything. +const FRONTMATTER_NAME: &str = "name: soulseek-rs"; + +/// A skills directory to write into, labelled by whose it is. +struct Target { + agent: &'static str, + skills: PathBuf, +} + +impl Target { + fn dir(&self) -> PathBuf { + self.skills.join("soulseek-rs") + } + + fn file(&self) -> PathBuf { + self.dir().join("SKILL.md") + } + + fn record(&self, action: &str) -> SkillRecord { + SkillRecord { + agent: self.agent.to_string(), + path: self.file().display().to_string(), + action: action.to_string(), + } + } +} + +pub fn run(out: &Out, command: &SkillsCommand) -> CliResult { + match command { + SkillsCommand::Install(args) => install(out, &targets(args)?), + SkillsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + SkillsCommand::List(args) => { + list(out, &targets(args)?); + Ok(()) + } + } +} + +fn install(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let file = target.file(); + let action = if current(&file) { + "unchanged" + } else { + let dir = target.dir(); + std::fs::create_dir_all(&dir).map_err(|e| unwritable(&dir, &e))?; + std::fs::write(&file, SKILL).map_err(|e| unwritable(&file, &e))?; + "installed" + }; + out.emit(&target.record(action)); + } + out.status("restart your agent, or start a new session, to pick it up"); + Ok(()) +} + +/// Delete only what this command wrote. A mistyped `--dir` names somebody +/// else's directory far more often than it names ours, so the skill file has +/// to identify itself first, and only that file goes: a folder holding +/// anything else survives the empty-directory sweep that follows. +fn uninstall(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let file = target.file(); + let action = if ours(&file) { + std::fs::remove_file(&file).map_err(|e| unwritable(&file, &e))?; + let _ = std::fs::remove_dir(target.dir()); + "removed" + } else { + "absent" + }; + out.emit(&target.record(action)); + } + Ok(()) +} + +fn list(out: &Out, targets: &[Target]) { + for target in targets { + let action = match std::fs::read_to_string(target.file()) { + Ok(found) if found == SKILL => "unchanged", + Ok(_) => "outdated", + Err(_) => "absent", + }; + out.emit(&target.record(action)); + } +} + +/// Where to write. Naming a directory aims the command at exactly that +/// directory: a run that says where to go should not also touch every agent it +/// happens to find. Detection is what answers the question when nothing does. +fn targets(args: &SkillsArgs) -> CliResult> { + let chosen: Vec = args + .dir + .iter() + .map(|dir| Target { + agent: "custom", + skills: dir.clone(), + }) + .collect(); + if !chosen.is_empty() { + return Ok(chosen); + } + + let home = UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or_else(|| { + CliError::usage( + "cannot find your home directory; name a skills directory \ + with --dir", + ) + })?; + + let found = detected(&home, &xdg("XDG_CONFIG_HOME", &home, ".config")); + if found.is_empty() { + return Err(CliError::no_results( + "no coding agent found in your home directory; name a skills \ + directory with --dir", + )); + } + Ok(found) +} + +/// The agents that read a plain `SKILL.md`, kept to the ones actually on this +/// machine: an agent counts as present when its own directory is, not when it +/// already has a skills folder. +fn detected(home: &Path, config_home: &Path) -> Vec { + [ + ("claude", home.join(".claude"), "skills"), + ("opencode", config_home.join("opencode"), "skill"), + ] + .into_iter() + .filter(|(_, agent_dir, _)| agent_dir.is_dir()) + .map(|(agent, agent_dir, skills)| Target { + agent, + skills: agent_dir.join(skills), + }) + .collect() +} + +fn current(file: &Path) -> bool { + std::fs::read_to_string(file).is_ok_and(|found| found == SKILL) +} + +fn ours(file: &Path) -> bool { + std::fs::read_to_string(file) + .is_ok_and(|found| found.contains(FRONTMATTER_NAME)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use clap::CommandFactory; + + /// Every command this binary answers, spelled the way a user types it. + /// Groups contribute their verbs rather than themselves — nobody runs a + /// bare `room`. + fn command_paths(command: &clap::Command, prefix: &str) -> Vec { + let mut paths = Vec::new(); + for sub in command.get_subcommands() { + if sub.get_name() == "help" { + continue; + } + let path = format!("{prefix}{}", sub.get_name()); + if sub.get_subcommands().next().is_some() { + paths.extend(command_paths(sub, &format!("{path} "))); + } else { + paths.push(path); + } + } + paths + } + + /// The skill is only worth shipping while it still describes the binary, + /// so a new command fails the build until it is documented. + /// + /// Looking for the name alone would pass on coincidence: half of these are + /// also JSON keys the document lists, and a command named in passing in the + /// prose would satisfy it too. What counts is an entry in the command + /// section, which is the only place written as a bold quoted invocation. + #[test] + fn the_skill_documents_every_command() { + let paths = command_paths(&Cli::command(), ""); + assert!(paths.len() > 10, "expected the full command surface"); + for path in paths { + assert!( + SKILL.contains(&format!("**`{path}`")) + || SKILL.contains(&format!("**`{path} ")), + "SKILL.md has no `{path}` entry (expected **`{path} …`**)" + ); + } + } + + #[test] + fn the_skill_tells_agents_to_ask_for_json() { + assert!(SKILL.contains("--json")); + assert!(SKILL.contains(FRONTMATTER_NAME)); + } + + #[test] + fn only_agents_that_are_installed_become_targets() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join(".config"); + assert!(detected(home.path(), &config).is_empty()); + + std::fs::create_dir_all(home.path().join(".claude")).expect("claude"); + std::fs::create_dir_all(config.join("opencode")).expect("opencode"); + let found = detected(home.path(), &config); + + assert_eq!(found.len(), 2); + assert_eq!(found[0].skills, home.path().join(".claude/skills")); + assert_eq!(found[1].skills, config.join("opencode/skill")); + } + + #[test] + fn a_file_we_did_not_write_is_never_ours_to_delete() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("SKILL.md"); + std::fs::write(&file, "---\nname: something-else\n---\n").expect("w"); + + assert!(!ours(&file)); + assert!(!ours(&dir.path().join("missing.md"))); + + std::fs::write(&file, SKILL).expect("write"); + assert!(ours(&file)); + assert!(current(&file)); + } +} diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs new file mode 100644 index 0000000..e9f543c --- /dev/null +++ b/soulseek-rs/src/commands/social.rs @@ -0,0 +1,375 @@ +//! Talking to people and looking at what they share: `browse`, `room`, and +//! `message`. + +use super::{Ctx, Session, confirm_flush}; +use crate::api::SessionApi; +use crate::cli::BrowseArgs; +use crate::output::{ + BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord, + RoomEventRecord, RoomMemberRecord, RoomRecord, +}; +use soulseek_rs::SharedDirectory; +use soulseek_rs::types::RoomEvent; +use std::time::{Duration, Instant}; + +/// How long to wait for the server to acknowledge something we sent. +const FLUSH_TIMEOUT: Duration = Duration::from_secs(10); +/// Polling cadence for the request/poll parts of the client API. +const POLL: Duration = Duration::from_millis(100); + +/// Join a listing's directory and file name into the remote path that +/// `download` expects — the protocol reports the two separately. +#[must_use] +pub fn full_path(directory: &str, basename: &str) -> String { + let directory = directory.trim_end_matches('\\'); + if directory.is_empty() { + basename.to_string() + } else { + format!("{directory}\\{basename}") + } +} + +/// Ask a peer for its share listing and wait for the answer. +pub fn fetch_listing( + client: &dyn SessionApi, + user: &str, + timeout: Duration, +) -> CliResult> { + client.browse_user(user).map_err(|e| { + CliError::connection(format!("cannot ask {user} for a listing: {e}")) + })?; + + super::poll_until(timeout, POLL, || client.take_browse_result(user)) + .ok_or_else(|| { + CliError::timeout(format!( + "{user} did not send a file list within {}s", + timeout.as_secs() + )) + }) +} + +/// 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 + .status(&format!("requesting {}'s shared files…", args.user)); + + let directories = fetch_listing( + session.client.as_ref(), + &args.user, + Duration::from_secs(args.timeout), + )?; + + if emit_listing(ctx, &args.user, &directories, None) == 0 { + return Err(CliError::no_results(format!( + "{} shares no files", + args.user + ))); + } + Ok(()) +} + +pub fn room_list(ctx: &Ctx, timeout: Duration) -> CliResult { + let session = Session::open(ctx)?; + ctx.out.status("fetching the room list…"); + + let Some(mut rooms) = + super::await_room_list(session.client.as_ref(), timeout, POLL) + else { + return Err(CliError::timeout(format!( + "the server sent no room list within {}s", + timeout.as_secs() + ))); + }; + if rooms.is_empty() { + return Err(CliError::no_results("the server lists no public rooms")); + } + + rooms.sort_by(|a, b| { + b.user_count + .cmp(&a.user_count) + .then_with(|| a.name.cmp(&b.name)) + }); + for room in rooms { + ctx.out.emit(&RoomRecord { + room: room.name, + users: room.user_count, + }); + } + Ok(()) +} + +pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult { + let session = Session::open(ctx)?; + session.client.join_room(room).map_err(|e| { + CliError::connection(format!("cannot join {room}: {e}")) + })?; + session.client.say_in_room(room, message).map_err(|e| { + CliError::connection(format!("cannot post to {room}: {e}")) + })?; + + if confirm_flush(session.client.as_ref(), FLUSH_TIMEOUT) { + ctx.out.status(&format!("posted to {room}")); + return Ok(()); + } + Err(CliError::timeout(format!( + "the server did not acknowledge the message to {room}" + ))) +} + +/// List a room's membership. Joining is what makes the server send it, so we +/// join, wait for the roster, and leave again. +pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult { + let session = Session::open(ctx)?; + session.client.join_room(room).map_err(|e| { + CliError::connection(format!("cannot join {room}: {e}")) + })?; + ctx.out + .status(&format!("waiting for {room}'s member list…")); + + let listed = super::poll_until(timeout, POLL, || { + let members = session.client.room_members(room); + (!members.is_empty()).then_some(members) + }); + let _ = session.client.leave_room(room); + + let Some(members) = listed else { + return Err(CliError::timeout(format!( + "{room} sent no member list within {}s", + timeout.as_secs() + ))); + }; + // The stats ride along with the membership the server sent, so they are + // already here; a server that sent none leaves every field null rather + // than reporting a zero that reads like an answer. + let stats = session.client.room_member_stats(room); + for user in members { + let stat = stats.iter().find(|s| s.username == user); + ctx.out.emit(&RoomMemberRecord { + room: room.to_string(), + user, + status: stat.map(|s| s.status.to_string()), + average_speed: stat.map(|s| s.average_speed), + shared_files: stat.map(|s| s.shared_files), + shared_folders: stat.map(|s| s.shared_folders), + slots_full: stat.map(|s| s.slots_full), + country: stat.and_then(|s| s.country.clone()), + }); + } + Ok(()) +} + +pub fn room_listen(ctx: &Ctx, room: &str, span: Option) -> CliResult { + let session = Session::open(ctx)?; + session.client.join_room(room).map_err(|e| { + CliError::connection(format!("cannot join {room}: {e}")) + })?; + + stream(&ctx.out, &format!("listening to {room}"), span, || { + for event in session.client.take_room_events() { + if let Some(record) = room_event_record(room, &event) { + ctx.out.emit(&record); + } + } + }); + let _ = session.client.leave_room(room); + Ok(()) +} + +/// Drain `next` until `span` elapses, or forever when it is `None` +/// (`--follow`, ended by the operator). +fn stream( + out: &Out, + what: &str, + span: Option, + mut next: impl FnMut(), +) { + out.status(&match span { + Some(span) => format!("{what} for {}s…", span.as_secs()), + None => format!("{what} until interrupted…"), + }); + let deadline = span.map(|span| Instant::now() + span); + while deadline.is_none_or(|deadline| Instant::now() < deadline) { + next(); + std::thread::sleep(POLL); + } +} + +/// Turn a room event into a record, ignoring events for other rooms and the +/// bookkeeping ones a listener has no use for. +fn room_event_record(room: &str, event: &RoomEvent) -> Option { + match event { + RoomEvent::Message { + room: name, + username, + message, + } if name == room => Some(RoomEventRecord { + kind: "message", + room: name.clone(), + user: username.clone(), + message: message.clone(), + }), + RoomEvent::UserJoined { + room: name, + username, + } if name == room => Some(RoomEventRecord { + kind: "join", + room: name.clone(), + user: username.clone(), + message: String::new(), + }), + RoomEvent::UserLeft { + room: name, + username, + } if name == room => Some(RoomEventRecord { + kind: "leave", + room: name.clone(), + user: username.clone(), + message: String::new(), + }), + _ => None, + } +} + +pub fn message_send(ctx: &Ctx, user: &str, message: &str) -> CliResult { + let session = Session::open(ctx)?; + session + .client + .send_private_message(user, message) + .map_err(|e| { + CliError::connection(format!("cannot send to {user}: {e}")) + })?; + + if confirm_flush(session.client.as_ref(), FLUSH_TIMEOUT) { + ctx.out.status(&format!("sent to {user}")); + return Ok(()); + } + Err(CliError::timeout(format!( + "the server did not acknowledge the message to {user}" + ))) +} + +pub fn message_read(ctx: &Ctx, span: Option) -> CliResult { + let session = Session::open(ctx)?; + + // What the session already holds comes first. A daemon has been collecting + // the whole time it has been up, including while nothing was reading, so + // starting from the next arrival would silently skip everything said so + // far. A session of our own knows nothing yet, so this is empty and the + // command behaves as it always did. + for message in session.client.message_history() { + if message.outgoing { + continue; + } + ctx.out.emit(&PrivateMessageRecord { + timestamp: u32::try_from(message.at).unwrap_or(0), + user: message.peer, + message: message.text, + }); + } + + stream(&ctx.out, "reading private messages", span, || { + for message in session.client.take_private_messages() { + ctx.out.emit(&PrivateMessageRecord { + timestamp: message.timestamp(), + user: message.username().to_string(), + message: message.message().to_string(), + }); + } + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_path_joins_a_directory_and_basename_with_a_backslash() { + assert_eq!(full_path("@@abc\\Music", "a.mp3"), "@@abc\\Music\\a.mp3"); + } + + #[test] + fn full_path_does_not_double_a_trailing_separator() { + assert_eq!(full_path("@@abc\\Music\\", "a.mp3"), "@@abc\\Music\\a.mp3"); + } + + #[test] + fn full_path_of_a_rootless_listing_is_the_basename() { + assert_eq!(full_path("", "a.mp3"), "a.mp3"); + } + + #[test] + fn only_events_for_the_listened_room_become_records() { + let mine = RoomEvent::Message { + room: "lobby".into(), + username: "bob".into(), + message: "hi".into(), + }; + let elsewhere = RoomEvent::Message { + room: "other".into(), + username: "bob".into(), + message: "hi".into(), + }; + assert!(room_event_record("lobby", &mine).is_some()); + assert!(room_event_record("lobby", &elsewhere).is_none()); + } + + #[test] + fn joins_and_leaves_are_labelled() { + let joined = RoomEvent::UserJoined { + room: "lobby".into(), + username: "sue".into(), + }; + let left = RoomEvent::UserLeft { + room: "lobby".into(), + username: "sue".into(), + }; + assert_eq!(room_event_record("lobby", &joined).unwrap().kind, "join"); + assert_eq!(room_event_record("lobby", &left).unwrap().kind, "leave"); + } + + #[test] + fn our_own_join_confirmation_is_not_a_record() { + let joined = RoomEvent::Joined { + room: "lobby".into(), + users: vec!["bob".into()], + }; + assert!(room_event_record("lobby", &joined).is_none()); + } +} diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs new file mode 100644 index 0000000..80bef8a --- /dev/null +++ b/soulseek-rs/src/commands/transfer.rs @@ -0,0 +1,1053 @@ +//! Finding files and fetching them: `search`, `download`, and `get`. + +use super::{Ctx, Session, social}; +use crate::api::SessionApi; +use crate::cli::{ + DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey, +}; +use crate::output::{ + CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord, +}; +use soulseek_rs::utils::path::expand_tilde; +use soulseek_rs::{DownloadStatus, SearchResult}; +use std::collections::{HashMap, VecDeque}; +use std::io::BufRead; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Soulseek file attribute codes the network agrees on. +pub const ATTR_BITRATE: u32 = 0; +pub const ATTR_DURATION: u32 = 1; + +/// What to keep out of the raw result set. Every field is a further +/// restriction: a candidate has to satisfy all of them. +/// +/// `exclude` and `extensions` are stored already lowercased and without leading +/// dots — see [`Filter::from`] — so matching a result costs no allocation. A +/// search returns thousands of files and every one of them is tested. +#[derive(Debug, Clone, Default)] +pub struct Filter { + pub min_bitrate: Option, + pub free_slots_only: bool, + /// Terms that must not appear anywhere in the remote path, lowercased. + pub exclude: Vec, + /// File extensions to keep, lowercased and dotless. + pub extensions: Vec, + pub min_size: Option, + pub max_size: Option, +} + +impl Filter { + fn keeps(&self, candidate: &SearchRecord) -> bool { + if self.free_slots_only && !candidate.free_slot { + return false; + } + if self.min_size.is_some_and(|min| candidate.size < min) { + return false; + } + if self.max_size.is_some_and(|max| candidate.size > max) { + return false; + } + // An unknown bitrate cannot satisfy a bitrate floor. + if self + .min_bitrate + .is_some_and(|min| candidate.bitrate.is_none_or(|have| have < min)) + { + return false; + } + if self.exclude.is_empty() && self.extensions.is_empty() { + return true; + } + let path = candidate.path.to_lowercase(); + !self.exclude.iter().any(|term| path.contains(term.as_str())) + && (self.extensions.is_empty() || self.matches_extension(&path)) + } + + fn matches_extension(&self, lowercase_path: &str) -> bool { + let name = lowercase_path + .rsplit(['/', '\\']) + .next() + .unwrap_or_default(); + let Some((_, actual)) = name.rsplit_once('.') else { + return false; + }; + self.extensions.iter().any(|wanted| wanted == actual) + } +} + +/// Lowercase and strip a leading dot, so the comparison in [`Filter::keeps`] is +/// a plain equality however the flag was spelled. +fn normalized(terms: &[String]) -> Vec { + terms + .iter() + .map(|term| term.trim_start_matches('.').to_lowercase()) + .collect() +} + +/// Flatten per-peer results into individual files, dropping what `filter` +/// rejects. +#[must_use] +pub fn candidates( + results: &[SearchResult], + filter: &Filter, +) -> Vec { + let mut out = Vec::new(); + for result in results { + for file in &result.files { + let candidate = SearchRecord { + user: result.username.clone(), + path: file.name.clone(), + size: file.size, + bitrate: file.attribs.get(&ATTR_BITRATE).copied(), + duration: file.attribs.get(&ATTR_DURATION).copied(), + free_slot: result.slots > 0, + slots: result.slots, + speed: result.speed, + }; + if filter.keeps(&candidate) { + out.push(candidate); + } + } + } + out +} + +/// Order results. Every mode ends with the same user/path tiebreak so the +/// output of two identical runs is identical. +pub fn rank(items: &mut [SearchRecord], sort: SortKey) { + items.sort_by(|a, b| { + let primary = match sort { + SortKey::Best => b + .free_slot + .cmp(&a.free_slot) + .then_with(|| { + b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0)) + }) + .then_with(|| b.speed.cmp(&a.speed)) + .then_with(|| b.size.cmp(&a.size)), + SortKey::Size => b.size.cmp(&a.size), + SortKey::Bitrate => { + b.bitrate.unwrap_or(0).cmp(&a.bitrate.unwrap_or(0)) + } + SortKey::Speed => b.speed.cmp(&a.speed), + }; + primary + .then_with(|| a.user.cmp(&b.user)) + .then_with(|| a.path.cmp(&b.path)) + }); +} + +/// Where a remote file lands locally — the same rule the library applies, so +/// the path we report is the path that gets written. +#[must_use] +pub fn local_path(download_dir: &str, remote_path: &str) -> PathBuf { + let name = remote_path + .split(['/', '\\']) + .next_back() + .unwrap_or(remote_path); + expand_tilde(download_dir).join(name) +} + +/// One file to fetch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub user: String, + pub path: String, + pub size: u64, +} + +impl From<&FilterArgs> for Filter { + fn from(args: &FilterArgs) -> Self { + Self { + min_bitrate: args.min_bitrate, + free_slots_only: args.free_slots, + exclude: normalized(&args.exclude), + extensions: normalized(&args.extension), + min_size: args.min_size, + max_size: args.max_size, + } + } +} + +impl From<&SearchRecord> for Request { + fn from(candidate: &SearchRecord) -> Self { + Self { + user: candidate.user.clone(), + path: candidate.path.clone(), + size: candidate.size, + } + } +} + +/// Filter, rank, and cap a raw result set, failing when nothing survives. +pub(super) fn ranked( + results: &[SearchResult], + filter: &Filter, + sort: SortKey, + limit: usize, + query: &str, +) -> CliResult> { + let mut found = candidates(results, filter); + rank(&mut found, sort); + if limit > 0 { + found.truncate(limit); + } + if found.is_empty() { + return Err(CliError::no_results(format!("no results for '{query}'"))); + } + Ok(found) +} + +pub fn search(ctx: &Ctx, args: &SearchArgs) -> CliResult { + let session = Session::open(ctx)?; + let results = collect(ctx, &session.client, &args.query, false)?; + let found = ranked( + &results, + &Filter::from(&args.filter), + args.sort, + args.limit, + &args.query, + )?; + + for candidate in &found { + ctx.out.emit(candidate); + } + Ok(()) +} + +pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult { + let requests = if args.stdin { + let stdin = std::io::stdin(); + read_requests(&mut stdin.lock(), &ctx.out)? + } else { + let (Some(user), Some(path)) = (args.user.clone(), args.path.clone()) + else { + return Err(CliError::usage( + "download needs USER and PATH, or --stdin", + )); + }; + vec![Request { + user, + path, + size: args.size.unwrap_or(0), + }] + }; + + if requests.is_empty() { + return Err(CliError::no_results("no files to download")); + } + + ensure_download_dir(ctx)?; + let session = Session::open(ctx)?; + let requests = resolve_sizes(ctx, session.client.as_ref(), requests)?; + download_all( + ctx, + &session.client, + requests, + Duration::from_secs(args.timeout), + ) +} + +pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult { + ensure_download_dir(ctx)?; + let session = Session::open(ctx)?; + let results = + collect(ctx, &session.client, &args.query, args.pick == Pick::First)?; + + let limit = match args.pick { + Pick::Best | Pick::First => 1, + Pick::All => args.limit, + }; + let found = ranked( + &results, + &Filter::from(&args.filter), + SortKey::Best, + limit, + &args.query, + )?; + + download_all( + ctx, + &session.client, + found.iter().map(Request::from).collect(), + Duration::from_secs(args.timeout), + ) +} + +/// Run a search and hand back the raw per-peer results. +/// +/// `stop_early` returns as soon as anything arrives, which is what +/// `get --pick first` wants; otherwise the search runs its full span because +/// late responders are often the better sources. +fn collect( + ctx: &Ctx, + client: &Arc, + query: &str, + stop_early: bool, +) -> CliResult> { + let timeout = ctx.search_timeout; + ctx.out.status(&format!( + "searching for '{query}' ({}s)…", + timeout.as_secs() + )); + + if !stop_early { + return client + .search(query, timeout) + .map_err(|e| CliError::connection(format!("search failed: {e}"))); + } + + let cancel = Arc::new(AtomicBool::new(false)); + let worker = { + let client = Arc::clone(client); + let query = query.to_string(); + let cancel = Arc::clone(&cancel); + std::thread::spawn(move || { + client.search_with_cancel(&query, timeout, Some(cancel)) + }) + }; + + super::poll_until(timeout, Duration::from_millis(100), || { + (client.get_search_results_count(query) > 0).then_some(()) + }); + cancel.store(true, Ordering::Relaxed); + let _ = worker.join(); + Ok(client.get_search_results(query)) +} + +/// Drop requests that would collide with one already queued. +/// +/// Two collisions are possible and both lose data silently: the library keys +/// downloads by a hash of the remote path, so the same path from two peers +/// shadows itself, and two different paths sharing a basename would be written +/// to the same local file. Keep the first (best-ranked) of each and say what +/// was dropped rather than transferring less than asked. +fn dedupe( + requests: impl IntoIterator, + download_dir: &str, + out: &Out, +) -> Vec { + let mut targets: HashMap = HashMap::new(); + let mut kept = Vec::new(); + for request in requests { + let target = local_path(download_dir, &request.path); + if let Some(queued) = targets.get(&target) { + let collision = if queued.path == request.path { + format!("same remote path already queued from {}", queued.user) + } else { + format!( + "would overwrite the file already queued from {}", + queued.user + ) + }; + out.warn(&format!( + "skipping {} from {}: {collision}", + request.path, request.user + )); + continue; + } + targets.insert(target, request.clone()); + kept.push(request); + } + kept +} + +/// A transfer whose size we do not know would be finalized against zero bytes +/// and saved empty, so look it up in the peer's share listing first. +fn resolve_sizes( + ctx: &Ctx, + client: &dyn SessionApi, + requests: Vec, +) -> CliResult> { + if requests.iter().all(|request| request.size > 0) { + return Ok(requests); + } + + let mut sizes: HashMap> = HashMap::new(); + let mut resolved = Vec::with_capacity(requests.len()); + for mut request in requests { + if request.size > 0 { + resolved.push(request); + continue; + } + if !sizes.contains_key(&request.user) { + ctx.out.status(&format!( + "looking up the size of {} in {}'s shares…", + request.path, request.user + )); + let listing = social::fetch_listing( + client, + &request.user, + Duration::from_secs(20), + )?; + sizes.insert( + request.user.clone(), + listing + .iter() + .flat_map(|directory| { + directory.files.iter().map(|file| { + ( + social::full_path(&directory.name, &file.name), + file.size, + ) + }) + }) + .collect(), + ); + } + request.size = sizes + .get(&request.user) + .and_then(|files| files.get(&request.path)) + .copied() + .ok_or_else(|| { + CliError::no_results(format!( + "{} does not share '{}' — pass --size to download it \ + anyway", + request.user, request.path + )) + })?; + resolved.push(request); + } + Ok(resolved) +} + +/// Make sure the bytes have somewhere to land. +/// +/// A daemon writes to its own filesystem and has already done this; creating +/// the same path here would leave a stray directory on the client machine and, +/// for a daemon on another host, could fail outright over a path that is none +/// of this machine's business. +fn ensure_download_dir(ctx: &Ctx) -> CliResult { + if ctx.daemon.is_some() { + return Ok(()); + } + let path = expand_tilde(&ctx.download_dir); + std::fs::create_dir_all(&path).map_err(|e| { + CliError::usage(format!( + "cannot use download directory {}: {e}", + path.display() + )) + }) +} + +/// Fetch every request, at most `max_concurrent_downloads` at a time, emitting +/// each result as it lands. Any failure fails the command. +fn download_all( + ctx: &Ctx, + client: &Arc, + requests: Vec, + timeout: Duration, +) -> CliResult { + let requests = dedupe(requests, &ctx.download_dir, &ctx.out); + let workers = ctx.max_concurrent_downloads.clamp(1, requests.len().max(1)); + let queue = Mutex::new(VecDeque::from(requests)); + let (sender, receiver) = channel(); + + let mut failures = std::thread::scope(|scope| { + for _ in 0..workers { + let sender = sender.clone(); + let queue = &queue; + scope.spawn(move || { + while let Some(request) = + queue.lock().ok().and_then(|mut q| q.pop_front()) + { + let outcome = download_one( + client.as_ref(), + &ctx.out, + &request, + &ctx.dir_for(&request.user), + timeout, + ); + if sender.send((request, outcome)).is_err() { + break; + } + } + }); + } + drop(sender); + + let mut failures = Vec::new(); + for (request, outcome) in receiver { + match outcome { + Ok(record) => ctx.out.emit(&record), + Err(error) => { + ctx.out.error(&format!("{}: {error}", request.path)); + failures.push(error); + } + } + } + failures + }); + + if failures.is_empty() { + return Ok(()); + } + if failures.len() == 1 { + return Err(failures.remove(0)); + } + let n = failures.len(); + if failures.iter().all(|f| f.exit == Exit::Timeout) { + return Err(CliError::new( + Exit::Timeout, + format!("{n} downloads timed out"), + )); + } + Err(CliError::transfer(format!("{n} downloads failed"))) +} + +/// Queue one transfer and watch it to a verdict. The library reports failure +/// on the status channel rather than through the call that starts the +/// transfer, so the channel is the only thing worth believing. +fn download_one( + client: &dyn SessionApi, + out: &Out, + request: &Request, + download_dir: &str, + timeout: Duration, +) -> CliResult { + // Clear any earlier attempt: entries are keyed by a hash of the remote + // path, so a stale one would shadow this transfer. + let _ = client.remove_download(&request.user, &request.path); + + let (_download, updates) = client + .download( + request.path.clone(), + request.user.clone(), + request.size, + download_dir.to_string(), + ) + .map_err(|e| CliError::transfer(format!("cannot start: {e}")))?; + + out.status(&format!( + "requesting {} from {} ({} bytes)…", + request.path, request.user, request.size + )); + + let deadline = Instant::now() + timeout; + let mut last_report = Instant::now(); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::timeout(format!( + "gave up after {}s", + timeout.as_secs() + ))); + } + match updates.recv_timeout(remaining.min(Duration::from_millis(250))) { + Ok(DownloadStatus::Completed) => { + let file = local_path(download_dir, &request.path); + return Ok(DownloadRecord { + user: request.user.clone(), + path: request.path.clone(), + size: request.size, + file: file.display().to_string(), + }); + } + Ok(DownloadStatus::Failed(reason)) => { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::transfer( + reason.unwrap_or_else(|| "the transfer failed".to_string()), + )); + } + Ok(DownloadStatus::Cancelled) => { + return Err(CliError::transfer("the transfer was cancelled")); + } + Ok(DownloadStatus::Queued) => { + out.status(&format!( + "queued behind {}'s uploads", + request.user + )); + } + Ok(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + }) => { + if last_report.elapsed() >= Duration::from_secs(1) { + last_report = Instant::now(); + out.status(&format!( + "{}: {bytes_downloaded}/{total_bytes} bytes ({:.0} KB/s)", + request.path, + speed_bytes_per_sec / 1024.0 + )); + } + } + Ok(DownloadStatus::Paused { .. } | DownloadStatus::TimedOut) + | Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + let _ = client.remove_download(&request.user, &request.path); + return Err(CliError::transfer( + "the transfer ended without reporting a result", + )); + } + } + } +} + +pub fn cancel(ctx: &Ctx, user: &str, path: &str) -> CliResult { + let session = Session::open(ctx)?; + if session.client.cancel_download(user, path) + | session.client.cancel_upload(user, path) + { + ctx.out.status(&format!("cancelled {path} ({user})")); + return Ok(()); + } + Err(CliError::no_results(format!( + "no download or upload of {path} with {user}" + ))) +} + +/// Parse `search`/`browse` output back into download requests. +/// +/// Both shapes this program emits are accepted: a JSON object per line, or +/// tab-separated text whose first field is the user and last field is the +/// remote path, with the size in between when there is one. +fn read_requests( + input: &mut impl BufRead, + out: &Out, +) -> CliResult> { + let mut requests = Vec::new(); + for (number, line) in input.lines().enumerate() { + let line = line + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + match parse_request(&line) { + Ok(Some(request)) => requests.push(request), + Ok(None) => {} + Err(reason) => { + out.warn(&format!("stdin line {}: {reason}", number + 1)); + } + } + } + Ok(requests) +} + +/// `Ok(None)` for a blank line, `Err` for something unusable. +// aislop-ignore-next-line complexity/function-too-long -- scanner bug, not a long function: the `'{'` char literal below reads as an opening brace, so aislop's counter never closes and measures to EOF. It is 33 lines. +fn parse_request(line: &str) -> Result, String> { + if line.trim().is_empty() { + return Ok(None); + } + + let (user, path, size) = if line.trim_start().starts_with('{') { + let record: JsonRequest = serde_json::from_str(line.trim()) + .map_err(|e| format!("invalid JSON record: {e}"))?; + (record.user, record.path, record.size) + } else { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 2 { + return Err( + "expected tab-separated USER…PATH or a JSON record".to_string() + ); + } + let size = if fields.len() >= 3 { + fields[1].trim().parse().unwrap_or(0) + } else { + 0 + }; + ( + fields[0].trim().to_string(), + fields[fields.len() - 1].trim().to_string(), + size, + ) + }; + + if user.is_empty() || path.is_empty() { + return Err("user and path must not be empty".to_string()); + } + Ok(Some(Request { user, path, size })) +} + +/// The subset of a `search`/`browse` JSON record a download needs. Extra +/// fields are ignored, so a record enriched by `jq` still parses. +#[derive(serde::Deserialize)] +struct JsonRequest { + user: String, + path: String, + #[serde(default)] + size: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use soulseek_rs::File; + use std::collections::HashMap; + + fn file(name: &str, size: u64, bitrate: Option) -> File { + let mut attribs = HashMap::new(); + if let Some(bitrate) = bitrate { + attribs.insert(ATTR_BITRATE, bitrate); + attribs.insert(ATTR_DURATION, 200); + } + File { + username: String::new(), + name: name.to_string(), + size, + attribs, + } + } + + fn result( + user: &str, + slots: u8, + speed: u32, + files: Vec, + ) -> SearchResult { + SearchResult { + token: 1, + files, + slots, + speed, + username: user.to_string(), + } + } + + fn paths_of(found: &[SearchRecord]) -> Vec { + found.iter().map(|c| c.path.clone()).collect() + } + + fn sample() -> Vec { + vec![ + result("slow", 1, 100, vec![file("a\\low.mp3", 10, Some(128))]), + result("busy", 0, 900, vec![file("b\\best.mp3", 90, Some(320))]), + result( + "fast", + 1, + 500, + vec![ + file("c\\good.mp3", 50, Some(256)), + file("c\\unknown.mp3", 70, None), + ], + ), + ] + } + + #[test] + fn candidates_flatten_every_file_of_every_peer() { + let found = candidates(&sample(), &Filter::default()); + assert_eq!(found.len(), 4); + assert!(found.iter().any(|c| c.user == "fast" && c.size == 70)); + } + + #[test] + fn candidates_carry_bitrate_duration_and_slot_state() { + let found = candidates(&sample(), &Filter::default()); + let best = found.iter().find(|c| c.path == "b\\best.mp3").unwrap(); + assert_eq!(best.bitrate, Some(320)); + assert_eq!(best.duration, Some(200)); + assert!(!best.free_slot); + assert_eq!(best.speed, 900); + } + + /// Build a filter the way a command does, so the tests cover the flag + /// normalisation (case, leading dots) rather than assuming it. + fn filter(build: impl FnOnce(&mut FilterArgs)) -> Filter { + let mut args = FilterArgs::default(); + build(&mut args); + Filter::from(&args) + } + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|v| (*v).to_string()).collect() + } + + #[test] + fn min_bitrate_drops_lower_and_unknown_bitrates() { + let filter = filter(|a| a.min_bitrate = Some(256)); + assert_eq!( + paths_of(&candidates(&sample(), &filter)), + ["b\\best.mp3", "c\\good.mp3"] + ); + } + + #[test] + fn free_slots_only_keeps_peers_advertising_a_slot() { + let filter = filter(|a| a.free_slots = true); + let found = candidates(&sample(), &filter); + assert!(found.iter().all(|c| c.free_slot)); + assert!(found.iter().all(|c| c.user != "busy")); + } + + #[test] + fn exclude_drops_a_path_containing_the_term_whatever_its_case() { + let filter = filter(|a| a.exclude = strings(&["LOW"])); + let found = candidates(&sample(), &filter); + assert!(found.iter().all(|c| c.path != "a\\low.mp3")); + assert_eq!(found.len(), 3); + } + + #[test] + fn exclude_matches_any_term_not_all_of_them() { + let filter = filter(|a| a.exclude = strings(&["low", "unknown"])); + assert_eq!( + paths_of(&candidates(&sample(), &filter)), + ["b\\best.mp3", "c\\good.mp3"] + ); + } + + #[test] + fn extension_keeps_only_that_type_however_it_is_written() { + let mixed = vec![result( + "peer", + 1, + 1, + vec![ + file("a.mp3", 1, None), + file("b.FLAC", 2, None), + file("c.ogg", 3, None), + ], + )]; + for spelling in [".flac", "flac", "FLAC", ".FLAC"] { + let filter = filter(|a| a.extension = strings(&[spelling])); + assert_eq!( + paths_of(&candidates(&mixed, &filter)), + ["b.FLAC"], + "--extension {spelling} should match b.FLAC" + ); + } + } + + #[test] + fn several_extensions_are_a_union() { + let mixed = vec![result( + "peer", + 1, + 1, + vec![ + file("a.mp3", 1, None), + file("b.flac", 2, None), + file("c.ogg", 3, None), + ], + )]; + let filter = filter(|a| a.extension = strings(&["mp3", "ogg"])); + assert_eq!(paths_of(&candidates(&mixed, &filter)), ["a.mp3", "c.ogg"]); + } + + #[test] + fn an_extensionless_file_never_satisfies_an_extension_filter() { + let odd = vec![result("peer", 1, 1, vec![file("README", 1, None)])]; + let filter = filter(|a| a.extension = strings(&["mp3"])); + assert!(candidates(&odd, &filter).is_empty()); + } + + #[test] + fn a_dot_in_a_folder_name_is_not_the_files_extension() { + // Only the last path segment decides the extension, or every file under + // "Album (2004).v0" would look like a .v0 file. + let nested = vec![result( + "peer", + 1, + 1, + vec![file("Album (2004).v0\\track.mp3", 1, None)], + )]; + assert!( + candidates(&nested, &filter(|a| a.extension = strings(&["v0"]))) + .is_empty(), + "the folder's .v0 suffix must not count as the file's extension" + ); + assert_eq!( + paths_of(&candidates( + &nested, + &filter(|a| a.extension = strings(&["mp3"])) + )), + ["Album (2004).v0\\track.mp3"] + ); + } + + #[test] + fn size_bounds_are_inclusive_on_both_ends() { + let filter = filter(|a| { + a.min_size = Some(50); + a.max_size = Some(70); + }); + assert_eq!( + paths_of(&candidates(&sample(), &filter)), + ["c\\good.mp3", "c\\unknown.mp3"] + ); + } + + #[test] + fn each_size_bound_works_without_the_other() { + let floor = filter(|a| a.min_size = Some(70)); + assert_eq!( + paths_of(&candidates(&sample(), &floor)), + ["b\\best.mp3", "c\\unknown.mp3"] + ); + let ceiling = filter(|a| a.max_size = Some(10)); + assert_eq!(paths_of(&candidates(&sample(), &ceiling)), ["a\\low.mp3"]); + } + + #[test] + fn filters_combine_as_an_and() { + let filter = filter(|a| { + a.free_slots = true; + a.min_bitrate = Some(200); + a.min_size = Some(20); + a.exclude = strings(&["bad"]); + a.extension = strings(&["mp3"]); + }); + assert_eq!(paths_of(&candidates(&sample(), &filter)), ["c\\good.mp3"]); + } + + #[test] + fn a_filter_that_rejects_everything_is_the_no_results_verdict() { + let filter = filter(|a| a.min_size = Some(u64::MAX)); + let error = ranked(&sample(), &filter, SortKey::Best, 0, "q") + .expect_err("nothing survives the filter"); + assert_eq!(error.exit, Exit::NoResults); + } + + #[test] + fn best_prefers_a_free_slot_over_a_higher_bitrate() { + let mut found = candidates(&sample(), &Filter::default()); + rank(&mut found, SortKey::Best); + assert_eq!(found[0].path, "c\\good.mp3"); + assert_eq!(found[0].user, "fast"); + } + + #[test] + fn sort_modes_lead_with_their_own_key() { + let mut found = candidates(&sample(), &Filter::default()); + rank(&mut found, SortKey::Size); + assert_eq!(found[0].size, 90); + rank(&mut found, SortKey::Bitrate); + assert_eq!(found[0].bitrate, Some(320)); + rank(&mut found, SortKey::Speed); + assert_eq!(found[0].speed, 900); + } + + #[test] + fn ranking_is_deterministic_for_equivalent_results() { + let tie = vec![ + result("zed", 1, 1, vec![file("x.mp3", 1, Some(128))]), + result("amy", 1, 1, vec![file("x.mp3", 1, Some(128))]), + ]; + let mut found = candidates(&tie, &Filter::default()); + rank(&mut found, SortKey::Best); + assert_eq!(found[0].user, "amy"); + } + + #[test] + fn local_path_uses_the_last_segment_of_a_windows_style_path() { + assert_eq!( + local_path("/music", "@@abc\\Albums\\01 - Track.mp3"), + PathBuf::from("/music/01 - Track.mp3") + ); + assert_eq!( + local_path("/music", "plain.mp3"), + PathBuf::from("/music/plain.mp3") + ); + assert_eq!( + local_path("/music", "unix/style/file.mp3"), + PathBuf::from("/music/file.mp3") + ); + } + + #[test] + fn stdin_accepts_the_json_records_search_emits() { + let request = parse_request( + r#"{"user":"bob","path":"a\\b.mp3","size":42,"bitrate":320}"#, + ) + .unwrap() + .unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.path, "a\\b.mp3"); + assert_eq!(request.size, 42); + } + + #[test] + fn stdin_accepts_the_text_records_search_emits() { + let request = parse_request("bob\t4096\t320\t@@x\\track.mp3") + .unwrap() + .unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.size, 4096); + assert_eq!(request.path, "@@x\\track.mp3"); + } + + #[test] + fn stdin_accepts_a_bare_user_and_path_pair() { + let request = parse_request("bob\t@@x\\track.mp3").unwrap().unwrap(); + assert_eq!(request.user, "bob"); + assert_eq!(request.path, "@@x\\track.mp3"); + assert_eq!(request.size, 0); + } + + #[test] + fn stdin_skips_blank_lines_and_rejects_junk() { + assert_eq!(parse_request(" ").unwrap(), None); + assert!(parse_request("nonsense").is_err()); + assert!(parse_request("{\"path\":\"a\"}").is_err()); + assert!(parse_request("\tpath-only").is_err()); + } + + #[test] + fn a_bad_line_does_not_discard_the_good_ones() { + let input = "bob\t10\t320\ta.mp3\nnonsense\n\nsue\tb.mp3\n"; + let out = Out::new(false, true); + let requests = read_requests(&mut input.as_bytes(), &out).unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].user, "sue"); + } + + fn request(user: &str, path: &str) -> Request { + Request { + user: user.into(), + path: path.into(), + size: 1, + } + } + + #[test] + fn duplicate_remote_paths_are_dropped_because_the_store_keys_by_path() { + let out = Out::new(false, true); + let kept = dedupe( + vec![ + request("a", "same.mp3"), + request("b", "same.mp3"), + request("c", "other.mp3"), + ], + "/music", + &out, + ); + assert_eq!(kept.len(), 2); + assert_eq!(kept[0].user, "a"); + assert_eq!(kept[1].path, "other.mp3"); + } + + #[test] + fn requests_that_would_overwrite_the_same_local_file_are_dropped() { + // Different remote paths, same basename: both would be written to + // /music/track.mp3 and the slower transfer would clobber the other. + let out = Out::new(false, true); + let kept = dedupe( + vec![ + request("a", "@@one\\album\\track.mp3"), + request("b", "@@two\\other\\track.mp3"), + ], + "/music", + &out, + ); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].user, "a"); + } + + #[test] + fn stdin_rejects_a_json_record_with_empty_fields() { + assert!(parse_request(r#"{"user":"","path":"a.mp3"}"#).is_err()); + assert!(parse_request(r#"{"user":"bob","path":""}"#).is_err()); + } + + #[test] + fn stdin_ignores_extra_json_fields_a_filter_may_have_added() { + let request = + parse_request(r#"{"user":"bob","path":"a.mp3","size":7,"tag":1}"#) + .unwrap() + .unwrap(); + assert_eq!(request.size, 7); + } +} diff --git a/soulseek-rs/src/commands/wish.rs b/soulseek-rs/src/commands/wish.rs new file mode 100644 index 0000000..94ab351 --- /dev/null +++ b/soulseek-rs/src/commands/wish.rs @@ -0,0 +1,254 @@ +//! Standing searches: `wish`. +//! +//! A wishlist is what makes a client keep looking for something it did not find +//! today. The server treats it as a distinct message (code 103) and rate-limits +//! it to the interval it announces at login, so the schedule is the server's to +//! set — we only decide which queries to send and what to print. + +use super::{Ctx, Session, settings::Store, transfer}; +use crate::api::SessionApi; +use crate::cli::{SortKey, WishRunArgs}; +use crate::output::{CliError, CliResult, Out, WishRecord}; +use crate::persist::config::FileConfig; +use soulseek_rs::Client; +use std::time::Instant; + +pub fn add(out: &Out, store: &Store, query: &str) -> CliResult { + let mut updated = store.config.clone(); + if !updated.add_wish(query) { + if query.trim().is_empty() { + return Err(CliError::usage("a wish needs a query")); + } + out.status(&format!("already wishing for '{}'", query.trim())); + return Ok(()); + } + store.save(&updated)?; + out.emit(&WishRecord { + query: query.trim().to_string(), + }); + Ok(()) +} + +pub fn remove(out: &Out, store: &Store, query: &str) -> CliResult { + let mut updated = store.config.clone(); + if !updated.remove_wish(query) { + return Err(CliError::no_results(format!( + "'{}' is not on the wishlist", + query.trim() + ))); + } + store.save(&updated)?; + out.status(&format!("no longer wishing for '{}'", query.trim())); + Ok(()) +} + +pub fn list(out: &Out, store: &Store) -> CliResult { + let wishes = store.config.wishes(); + if wishes.is_empty() { + return Err(CliError::no_results("the wishlist is empty")); + } + for query in wishes { + out.emit(&WishRecord { query }); + } + Ok(()) +} + +pub fn run(ctx: &Ctx, store: &Store, args: &WishRunArgs) -> CliResult { + let wishes = selected(&store.config, args.only.as_deref())?; + let session = Session::open(ctx)?; + + if !sweep(ctx, session.client.as_ref(), &wishes, &Sweep::from(args)) { + return Err(CliError::no_results( + "no wish matched anything on the network", + )); + } + Ok(()) +} + +/// How to narrow and order what a sweep prints — the parts of `wish run`'s flags +/// that outlive the command, so `serve --wishlist` can sweep without inventing +/// a set of command-line arguments it never received. +#[derive(Default)] +struct Sweep { + filter: transfer::Filter, + sort: SortKey, + limit: usize, +} + +impl From<&WishRunArgs> for Sweep { + fn from(args: &WishRunArgs) -> Self { + Self { + filter: transfer::Filter::from(&args.filter), + sort: args.sort, + limit: args.limit, + } + } +} + +/// Re-run the wishlist for as long as `serve` stays online, on the interval the +/// server set. Called from the serve loop rather than owning it, so uploads keep +/// being reported between sweeps. +pub struct Sweeper { + wishes: Vec, + next: Instant, +} + +impl Sweeper { + /// `None` when there is nothing to look for, so `serve` can say so once + /// instead of waking up forever with an empty list. + #[must_use] + pub fn new(wishes: Vec) -> Option { + (!wishes.is_empty()).then(|| Self { + wishes, + // The first sweep is immediate: a wishlist that waits twelve + // minutes before its first look is indistinguishable from broken. + next: Instant::now(), + }) + } + + /// Search every wish if the interval has elapsed. Does nothing otherwise, + /// so the caller can poll it as often as it likes. + /// + /// this blocks the serve loop for one search window, so uploads go + /// unreported and `--duration` overshoots by that much. Move the sweep to its + /// own thread with a channel of records if that ever matters; what mattered + /// was that it used to be one window *per wish*. + pub fn tick(&mut self, ctx: &Ctx, client: &dyn SessionApi) { + if Instant::now() < self.next { + return; + } + sweep(ctx, client, &self.wishes, &Sweep::default()); + let interval = client.wishlist_interval(); + self.next = Instant::now() + interval; + ctx.out + .status(&format!("next wishlist sweep in {}s", interval.as_secs())); + } +} + +/// Search every wish and print what survived the filters. Returns whether +/// anything did. +/// +/// All the searches go out before anything is waited for: peers answer whenever +/// they get round to it, so one shared window covers every wish, where waiting +/// per wish would cost a full window each. +fn sweep( + ctx: &Ctx, + client: &dyn SessionApi, + wishes: &[String], + how: &Sweep, +) -> bool { + let mut sent = Vec::new(); + for wish in wishes { + match client.start_wishlist_search(wish) { + Ok(()) => sent.push(wish), + Err(e) => ctx + .out + .warn(&format!("'{wish}' could not be searched: {e}")), + } + } + if sent.is_empty() { + return false; + } + + ctx.out.status(&format!( + "wishing for {} ({}s)…", + sent.iter() + .map(|w| format!("'{w}'")) + .collect::>() + .join(", "), + ctx.search_timeout.as_secs() + )); + Client::collect_for(ctx.search_timeout, None); + + let mut found_any = false; + for wish in sent { + let results = client.get_search_results(wish); + let ranked = + transfer::ranked(&results, &how.filter, how.sort, how.limit, wish); + let Ok(found) = ranked else { + ctx.out.status(&format!("nothing for '{wish}' this time")); + continue; + }; + found_any = true; + for candidate in &found { + ctx.out.emit(candidate); + } + } + found_any +} + +/// Which wishes a run covers. `--only` has to name a stored wish: letting a +/// typo through would quietly search for something the user never asked to +/// keep, and report it as a wishlist hit. +/// +/// Matching goes through [`FileConfig::wish`], so a query that `wish add` would +/// call a duplicate is one `--only` can select. +fn selected(config: &FileConfig, only: Option<&str>) -> CliResult> { + let Some(only) = only else { + let wishes = config.wishes(); + if wishes.is_empty() { + return Err(CliError::no_results( + "the wishlist is empty — add one with `wish add QUERY`", + )); + } + return Ok(wishes); + }; + + config + .wish(only) + .map(|wish| vec![wish.clone()]) + .ok_or_else(|| { + CliError::usage(format!( + "'{}' is not on the wishlist; `wish list` shows what is", + only.trim() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::Exit; + + fn stored() -> FileConfig { + let mut config = FileConfig::default(); + config.add_wish("Aphex Twin"); + config.add_wish("Boards of Canada"); + config + } + + #[test] + fn without_only_every_stored_wish_runs() { + assert_eq!(selected(&stored(), None).unwrap(), stored().wishes()); + } + + #[test] + fn an_empty_wishlist_is_the_no_results_verdict() { + let error = + selected(&FileConfig::default(), None).expect_err("nothing to run"); + assert_eq!(error.exit, Exit::NoResults); + } + + #[test] + fn only_selects_one_stored_wish_ignoring_case_and_space() { + assert_eq!( + selected(&stored(), Some(" aphex twin ")).unwrap(), + ["Aphex Twin"], + "the stored spelling is what gets searched" + ); + } + + #[test] + fn only_naming_something_unstored_is_a_usage_error() { + let error = selected(&stored(), Some("plaid")) + .expect_err("a typo must not become an ad-hoc search"); + assert_eq!(error.exit, Exit::Usage); + assert!(error.message.contains("wish list"), "{}", error.message); + } + + #[test] + fn a_sweeper_needs_something_to_look_for() { + assert!(Sweeper::new(Vec::new()).is_none()); + assert!(Sweeper::new(stored().wishes()).is_some()); + } +} diff --git a/soulseek-rs/src/daemon/hub.rs b/soulseek-rs/src/daemon/hub.rs new file mode 100644 index 0000000..49a9e12 --- /dev/null +++ b/soulseek-rs/src/daemon/hub.rs @@ -0,0 +1,518 @@ +//! The single drainer, and the fan-out that makes multiple clients possible. +//! +//! The library's `take_*` accessors are destructive — they hand over their +//! buffer and empty it. That is fine for one process, but a daemon serving two +//! clients cannot let either of them drain: whoever asked first would silently +//! steal the other's events. So the daemon drains once, on its own cadence, +//! and every attached connection gets its own copy. +//! +//! A subscriber that stops reading must not be able to stall the drainer, so +//! the queues are bounded and a full one costs that client its connection +//! rather than costing everyone else their events. + +use super::proto::{ + BrowseEvent, ChatMessageDto, DownloadStatusEvent, Event, Response, + SessionLossEvent, UploadInfoDto, UploadStatusDto, UserMessageDto, +}; +use crate::api::SessionApi; +use soulseek_rs::{DownloadStatus, SessionLoss}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// How often the drainer samples the session. The TUI already redraws on this +/// cadence, so events reach a remote UI as promptly as a local one. +pub const POLL: Duration = Duration::from_millis(100); + +/// How many events may back up for one client before it is disconnected. +/// Generous enough that an ordinary consumer never notices, small enough that +/// a dead one is dropped promptly instead of growing without bound. +const QUEUE_DEPTH: usize = 1024; + +/// How many private messages the daemon keeps for `message.history`, so a +/// client that attaches later still sees what arrived while nobody was +/// listening. +const HISTORY_LIMIT: usize = 500; + +struct Subscriber { + id: u64, + sender: SyncSender, + /// How to end this client's connection. A stalled client cannot be + /// reached any other way, and leaving it attached but silently eventless + /// is worse than disconnecting it: it would keep working while missing + /// every room message, transfer update and upload it should have seen. + /// + /// A closure rather than the socket itself, so the hub stays ignorant of + /// what a connection is made of. + disconnect: Box, +} + +/// Fans daemon-side events out to every attached connection. +pub struct Hub { + subscribers: Mutex>, + next_id: AtomicU64, + /// The conversation so far, newest last — both directions, so a client + /// that attaches later sees a whole conversation rather than half of one. + history: Mutex>, +} + +impl Hub { + #[must_use] + pub const fn new() -> Self { + Self { + subscribers: Mutex::new(Vec::new()), + next_id: AtomicU64::new(0), + history: Mutex::new(Vec::new()), + } + } + + /// Attach a connection. The returned receiver yields notifications until + /// [`Hub::unsubscribe`], or until a full queue costs the client its + /// connection. + pub fn subscribe( + &self, + disconnect: impl Fn() + Send + Sync + 'static, + ) -> (u64, Receiver) { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = sync_channel(QUEUE_DEPTH); + if let Ok(mut subscribers) = self.subscribers.lock() { + subscribers.push(Subscriber { + id, + sender, + disconnect: Box::new(disconnect), + }); + } + (id, receiver) + } + + pub fn unsubscribe(&self, id: u64) { + if let Ok(mut subscribers) = self.subscribers.lock() { + subscribers.retain(|subscriber| subscriber.id != id); + } + } + + pub fn subscribers(&self) -> usize { + self.subscribers + .lock() + .map_or(0, |subscribers| subscribers.len()) + } + + /// Send one event to everyone, dropping any client that has stopped + /// keeping up. The drainer must never block on a subscriber. + fn publish(&self, event: Event, params: serde_json::Value) { + let Ok(mut subscribers) = self.subscribers.lock() else { + return; + }; + let notification = Response::notification(event.wire_name(), params); + subscribers.retain(|subscriber| { + match subscriber.sender.try_send(notification.clone()) { + Ok(()) => true, + Err(TrySendError::Full(_)) => { + soulseek_rs::warn!( + "control client {} is not reading its events; \ + disconnecting it", + subscriber.id + ); + // Closing the socket is the point: a client dropped from + // the fan-out but left connected would carry on believing + // it was subscribed, silently blind to everything after. + (subscriber.disconnect)(); + false + } + Err(TrySendError::Disconnected(_)) => false, + } + }); + } + + /// Add one line to the conversation, oldest lines falling off the front. + pub fn remember(&self, message: ChatMessageDto) { + let Ok(mut history) = self.history.lock() else { + return; + }; + history.push(message); + let overflow = history.len().saturating_sub(HISTORY_LIMIT); + if overflow > 0 { + history.drain(0..overflow); + } + } + + /// Seed the conversation from disk at startup. + pub fn restore(&self, messages: Vec) { + if let Ok(mut history) = self.history.lock() { + *history = messages; + } + } + + #[must_use] + pub fn history(&self) -> Vec { + self.history + .lock() + .map_or_else(|_| Vec::new(), |history| history.clone()) + } +} + +impl Default for Hub { + fn default() -> Self { + Self::new() + } +} + +/// A transfer the daemon started, whose status channel it pumps into events. +pub struct PendingDownload { + pub username: String, + pub filename: String, + pub updates: Receiver, +} + +/// Everything the drainer owns between ticks. +/// +/// Kept apart from [`Hub`] because it is single-threaded by construction: only +/// the drainer touches it, so none of it needs a lock. +#[derive(Default)] +struct DrainState { + /// Last upload state reported per (user, file), so an upload that has not + /// moved is not re-announced every tick. + uploads: HashMap<(String, String), UploadInfoDto>, + last_loss: Option, +} + +/// How long to keep asking whether a peer has answered a browse request. +/// +/// A peer that is offline or uninterested never replies, and the daemon would +/// otherwise poll for it until restart — so a script that browsed a few +/// thousand absent peers would leave the drainer doing thousands of pointless +/// lookups every tick. Comfortably longer than any client's own browse +/// timeout, so this expiry never beats a real answer. +const BROWSE_PATIENCE: Duration = Duration::from_mins(2); + +/// Peers we have asked for a listing and are still waiting on. +/// `take_browse_result` is per-user and destructive, so the daemon has to know +/// who to ask about. +#[derive(Default)] +pub struct PendingBrowses(Mutex>); + +impl PendingBrowses { + pub fn expect(&self, username: &str) { + if let Ok(mut pending) = self.0.lock() { + // Asking again restarts the clock: it is a fresh request. + pending.insert(username.to_string(), Instant::now()); + } + } + + /// Who to ask about this tick, forgetting the ones that have run out of + /// patience. + fn snapshot(&self) -> Vec { + let Ok(mut pending) = self.0.lock() else { + return Vec::new(); + }; + pending.retain(|_, asked| asked.elapsed() < BROWSE_PATIENCE); + pending.keys().cloned().collect() + } + + /// Stop waiting on a peer — because it answered, or because its request + /// never reached the wire. + pub fn forget(&self, username: &str) { + if let Ok(mut pending) = self.0.lock() { + pending.remove(username); + } + } +} + +/// Drain everything the session has buffered and fan it out, forever. +/// +/// This is the only caller of the `take_*` family in daemon mode; anything else +/// draining them would take events out of this loop's hands. +pub fn run( + session: Arc, + hub: Arc, + browses: Arc, + downloads: Arc>>, + stop: Arc, +) { + let mut state = DrainState::default(); + while !stop.load(Ordering::Relaxed) { + tick(&*session, &hub, &browses, &downloads, &mut state); + std::thread::sleep(POLL); + } +} + +fn tick( + session: &dyn SessionApi, + hub: &Hub, + browses: &PendingBrowses, + downloads: &Mutex>, + state: &mut DrainState, +) { + for event in session.take_room_events() { + hub.publish( + Event::Room, + serde_json::to_value(super::proto::RoomEventDto::from(&event)) + .unwrap_or(serde_json::Value::Null), + ); + } + + for message in session.take_private_messages() { + let dto = UserMessageDto::from(&message); + hub.remember(ChatMessageDto { + peer: dto.username.clone(), + outgoing: false, + text: dto.message.clone(), + at: i64::from(dto.timestamp), + }); + hub.publish( + Event::Message, + serde_json::to_value(&dto).unwrap_or(serde_json::Value::Null), + ); + } + + publish_uploads(session, hub, state); + publish_browses(session, hub, browses); + publish_downloads(hub, downloads); + + let loss = session.session_loss(); + if loss == state.last_loss { + return; + } + state.last_loss = loss; + if let Some(loss) = loss { + hub.publish( + Event::SessionLoss, + serde_json::to_value(SessionLossEvent { loss: loss.into() }) + .unwrap_or(serde_json::Value::Null), + ); + } +} + +/// Report an upload whenever it differs from what was last announced. +/// +/// Both sources matter: the drained events carry states that came and went +/// between two ticks (a small file served straight out of the queue), and the +/// live table carries progress. +fn publish_uploads( + session: &dyn SessionApi, + hub: &Hub, + state: &mut DrainState, +) { + let sampled = session + .take_upload_events() + .iter() + .chain(session.uploads().iter()) + .map(UploadInfoDto::from) + .collect::>(); + + for upload in sampled { + let key = (upload.username.clone(), upload.filename.clone()); + if state.uploads.get(&key) == Some(&upload) { + continue; + } + let settled = matches!( + upload.status, + UploadStatusDto::Completed + | UploadStatusDto::Cancelled + | UploadStatusDto::Failed { .. } + ); + hub.publish( + Event::Upload, + serde_json::to_value(&upload).unwrap_or(serde_json::Value::Null), + ); + // An upload that has finished cannot change again, so remembering it + // would grow this table by one entry per file for the life of the + // daemon. + if settled { + state.uploads.remove(&key); + } else { + state.uploads.insert(key, upload); + } + } +} + +fn publish_browses( + session: &dyn SessionApi, + hub: &Hub, + browses: &PendingBrowses, +) { + for username in browses.snapshot() { + let Some(directories) = session.take_browse_result(&username) else { + continue; + }; + browses.forget(&username); + hub.publish( + Event::Browse, + serde_json::to_value(BrowseEvent { + username, + directories: directories.iter().map(Into::into).collect(), + }) + .unwrap_or(serde_json::Value::Null), + ); + } +} + +/// Pump each transfer's status channel, dropping it once it can say no more. +fn publish_downloads(hub: &Hub, downloads: &Mutex>) { + let Ok(mut downloads) = downloads.lock() else { + return; + }; + downloads.retain(|pending| { + loop { + match pending.updates.try_recv() { + Ok(status) => hub.publish( + Event::DownloadStatus, + serde_json::to_value(DownloadStatusEvent { + username: pending.username.clone(), + filename: pending.filename.clone(), + status: (&status).into(), + }) + .unwrap_or(serde_json::Value::Null), + ), + Err(std::sync::mpsc::TryRecvError::Empty) => return true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + return false; + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn drain(receiver: &Receiver) -> Vec { + let mut seen = Vec::new(); + while let Ok(response) = receiver.try_recv() { + seen.push(response.method.unwrap_or_default()); + } + seen + } + + #[test] + fn every_subscriber_receives_the_same_event() { + // The whole reason the hub exists: two clients must both see an event + // that only exists once in the library's buffer. + let hub = Hub::new(); + let (_, first) = hub.subscribe(|| {}); + let (_, second) = hub.subscribe(|| {}); + + hub.publish(Event::Room, json!({ "event": "left", "room": "lobby" })); + + assert_eq!(drain(&first), ["event.room"]); + assert_eq!(drain(&second), ["event.room"]); + } + + #[test] + fn unsubscribing_stops_delivery() { + let hub = Hub::new(); + let (id, receiver) = hub.subscribe(|| {}); + hub.unsubscribe(id); + hub.publish(Event::Upload, json!({})); + assert!(drain(&receiver).is_empty()); + assert_eq!(hub.subscribers(), 0); + } + + #[test] + fn a_client_that_stops_reading_is_dropped_and_the_others_keep_up() { + let hub = Hub::new(); + let (_, slow) = hub.subscribe(|| {}); + let (_, healthy) = hub.subscribe(|| {}); + + // Overrun the slow client's queue while the healthy one keeps reading, + // which is exactly the asymmetry the bound exists to survive. + let mut received = 0; + for _ in 0..(QUEUE_DEPTH + 10) { + hub.publish(Event::Upload, json!({})); + received += drain(&healthy).len(); + } + + assert_eq!( + hub.subscribers(), + 1, + "a client that never reads must lose its connection, not stall the \ + drainer" + ); + assert_eq!( + received, + QUEUE_DEPTH + 10, + "the healthy client must not lose events to the slow one" + ); + drop(slow); + } + + #[test] + fn a_dropped_receiver_removes_its_subscriber() { + let hub = Hub::new(); + let (_, receiver) = hub.subscribe(|| {}); + drop(receiver); + hub.publish(Event::Message, json!({})); + assert_eq!(hub.subscribers(), 0); + } + + #[test] + fn history_keeps_the_newest_messages_within_the_cap() { + let hub = Hub::new(); + for at in 0..(HISTORY_LIMIT as i64 + 5) { + hub.remember(ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: format!("m{at}"), + at, + }); + } + let history = hub.history(); + assert_eq!(history.len(), HISTORY_LIMIT); + assert_eq!( + history[0].at, 5, + "the oldest messages are the ones dropped" + ); + assert_eq!(history[HISTORY_LIMIT - 1].at, HISTORY_LIMIT as i64 + 4); + } + + #[test] + fn history_carries_both_directions_of_a_conversation() { + // Half a conversation is worse than none: a remote client must see + // what this account said as well as what it was told. + let hub = Hub::new(); + hub.remember(ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: "hi".into(), + at: 1, + }); + hub.remember(ChatMessageDto { + peer: "bob".into(), + outgoing: true, + text: "hello".into(), + at: 2, + }); + let history = hub.history(); + assert_eq!(history.len(), 2); + assert!(!history[0].outgoing); + assert!(history[1].outgoing); + } + + #[test] + fn a_browse_is_only_awaited_once() { + let pending = PendingBrowses::default(); + pending.expect("bob"); + pending.expect("bob"); + assert_eq!(pending.snapshot(), ["bob"]); + pending.forget("bob"); + assert!(pending.snapshot().is_empty()); + } + + #[test] + fn a_browse_that_is_never_answered_stops_being_polled() { + // A peer that is offline never replies; without an expiry the drainer + // would look for its answer on every tick until the daemon restarts. + let asked = Instant::now() + .checked_sub(BROWSE_PATIENCE + Duration::from_secs(1)) + .expect("the clock is past the epoch"); + let pending = PendingBrowses(Mutex::new(HashMap::from([( + "ghost".to_string(), + asked, + )]))); + assert!(pending.snapshot().is_empty()); + } +} diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs new file mode 100644 index 0000000..adeb316 --- /dev/null +++ b/soulseek-rs/src/daemon/mod.rs @@ -0,0 +1,596 @@ +//! The resident service: one logged-in session, controlled over a socket. +//! +//! Where a one-shot command logs in, does its job and exits, the daemon logs in +//! once and stays. That is what makes cross-invocation work possible — a queue +//! that survives the command that filled it, a chat window that was open while +//! nobody was watching — and what lets several clients share one session +//! instead of fighting the server's one-login-per-account rule. +//! +//! Two transports, two answers to "who is this": +//! +//! * A Unix socket at `/daemon.sock`, mode 0600. The socket's own +//! permissions are the authentication, the same bargain Docker and ssh-agent +//! make, so a local client — an agent especially — needs no flags and no +//! token. +//! * TCP, opt-in, always requiring the token in `/daemon.token`. + +pub mod hub; +/// Generates `docs/openrpc.json` from the wire types and checks it is current. +/// Test-only: the published document is served at runtime by `include_str!`. +#[cfg(test)] +mod openrpc; +pub mod proto; +pub mod server; + +use crate::api::SessionApi; +use crate::commands::{Ctx, Session}; +use crate::output::{CliError, CliResult, Out}; +use crate::persist::state::{PersistedDownload, PersistedMessage, StateStore}; +use hub::{Hub, PendingBrowses, PendingDownload}; +use proto::ChatMessageDto; +use server::Daemon; +use soulseek_rs::SessionLoss; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// The control socket, where a local client looks without being told to. +pub const SOCKET_NAME: &str = "daemon.sock"; +/// The shared secret for TCP clients — the cookie-file convention, so a local +/// client can pick it up without the operator handling it at all. +pub const TOKEN_NAME: &str = "daemon.token"; + +/// How often daemon-owned state is written back. Frequent enough that a kill +/// loses little, rare enough not to churn the disk on a busy queue. +const SAVE_INTERVAL: Duration = Duration::from_secs(5); + +const RECONNECT_BACKOFF_CAP: Duration = Duration::from_mins(1); + +#[must_use] +pub fn socket_path() -> Option { + crate::persist::paths::state_dir().map(|dir| dir.join(SOCKET_NAME)) +} + +#[must_use] +pub fn token_path() -> Option { + crate::persist::paths::state_dir().map(|dir| dir.join(TOKEN_NAME)) +} + +/// The token a local client should present, when there is one to find. +#[must_use] +pub fn stored_token() -> Option { + let token = std::fs::read_to_string(token_path()?).ok()?; + let token = token.trim().to_string(); + (!token.is_empty()).then_some(token) +} + +/// Read the token, or mint one on first start. +/// +/// Written 0600 and never printed to stdout, so `daemon` output stays usable in +/// a pipe while the secret is still one `daemon token` away. +pub fn load_or_create_token(path: &Path) -> std::io::Result { + if let Some(existing) = std::fs::read_to_string(path) + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + { + return Ok(existing); + } + + let token = mint_token(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, format!("{token}\n"))?; + restrict(path)?; + Ok(token) +} + +/// 256 bits from the OS, hex-encoded. Long enough that the one-second penalty +/// on a wrong answer is the only rate limit the daemon needs. +fn mint_token() -> String { + use std::fmt::Write; + + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("the OS always has randomness"); + bytes + .iter() + .fold(String::with_capacity(64), |mut token, byte| { + let _ = write!(token, "{byte:02x}"); + token + }) +} + +/// Make a secret readable by nobody else. A no-op where the platform has no +/// such notion, which is why TCP is opt-in rather than the default. +#[cfg(unix)] +fn restrict(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +const fn restrict(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// Whether a daemon is listening on the local socket right now. +/// +/// This is what makes daemon mode seamless: a client that finds a live socket +/// uses it, and one that does not behaves exactly as it always has. A socket +/// file left by a crash answers `None`, so a stale file cannot strand a client. +#[must_use] +pub fn local_daemon() -> Option { + let path = socket_path()?; + #[cfg(unix)] + { + std::os::unix::net::UnixStream::connect(&path) + .ok() + .map(|_| path) + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + +/// Run the daemon until it is asked to stop. +pub fn run( + ctx: &Ctx, + bind: Option<&str>, + upload_slots: Option, +) -> CliResult { + let state_dir = crate::persist::paths::state_dir().ok_or_else(|| { + CliError::usage("no state directory: set SOULSEEK_STATE_DIR") + })?; + std::fs::create_dir_all(&state_dir).map_err(|e| { + CliError::usage(format!( + "cannot use state directory {}: {e}", + state_dir.display() + )) + })?; + + let token = load_or_create_token(&state_dir.join(TOKEN_NAME)) + .map_err(|e| CliError::usage(format!("cannot read the token: {e}")))?; + + // Bind before logging in: a second daemon should fail in a millisecond, + // not after a full login handshake against the server. + let mut listeners = Vec::new(); + #[cfg(unix)] + { + let path = state_dir.join(SOCKET_NAME); + listeners.push(server::bind_unix(&path).map_err(|e| { + CliError::usage(format!("cannot listen on {}: {e}", path.display())) + })?); + ctx.out.status(&format!("listening on {}", path.display())); + } + if let Some(address) = bind { + listeners.push(server::bind_tcp(address).map_err(|e| { + CliError::connection(format!("cannot listen on {address}: {e}")) + })?); + ctx.out.status(&format!( + "listening on {address} (remote clients need the token from \ + `soulseek-rs daemon token`)" + )); + } + if listeners.is_empty() { + return Err(CliError::usage( + "nothing to listen on: pass --bind on a platform without Unix \ + sockets", + )); + } + + let session = Session::open(ctx)?; + if let Some(slots) = upload_slots { + session.client.set_upload_slots(usize::from(slots)); + } + + let store = StateStore::new(state_dir); + let hub = Arc::new(Hub::new()); + let browses = Arc::new(PendingBrowses::default()); + let downloads = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + + let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads); + + 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), + download_dir: Mutex::new(ctx.download_dir.clone()), + use_username_dirs: ctx.use_username_dirs, + server: ctx.settings.server_address.to_string(), + token: token.clone(), + started: Instant::now(), + stop: Arc::clone(&stop), + open: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + rooms: Mutex::new(rooms), + searches: Mutex::new(std::collections::HashMap::new()), + }); + + let drainer = { + let session = Arc::clone(&session.client); + let hub = Arc::clone(&hub); + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + hub::run(session, hub, browses, downloads, stop); + }) + }; + + { + let session = Arc::clone(&session.client); + let daemon = Arc::clone(&daemon); + let stop = Arc::clone(&stop); + let out = ctx.out.clone(); + let address = ctx.settings.server_address.to_string(); + std::thread::spawn(move || { + reconnect_loop(&session, &daemon, &out, &address, &stop); + }); + } + + for listener in listeners { + let daemon = Arc::clone(&daemon); + std::thread::spawn(move || listener.serve(daemon)); + } + + let (folders, files) = session.client.shared_counts(); + ctx.out.status(&format!( + "logged in as {}, sharing {files} files in {folders} folders", + session.client.username() + )); + ready(ctx, bind, &token); + + // The listeners run on their own threads; this one only has to notice a + // `daemon.stop` and write state back on the way past. + let mut last_save = Instant::now(); + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(hub::POLL); + if last_save.elapsed() >= SAVE_INTERVAL { + last_save = Instant::now(); + save(&store, session.client.as_ref(), &hub, &daemon); + } + } + + save(&store, session.client.as_ref(), &hub, &daemon); + ctx.out.status("stopping"); + // The drainer watches the same flag; joining keeps a half-written state + // file from racing the process exit. + let _ = drainer.join(); + cleanup(); + Ok(()) +} + +/// Say what is now possible, once, on stderr. +/// +/// The token goes here rather than only into a file because the moment you +/// need it is the moment you bind a port, and hunting for `daemon token` +/// afterwards is a poor first experience. stdout stays empty either way, so +/// `daemon` still composes in a pipeline. +fn ready(ctx: &Ctx, bind: Option<&str>, token: &str) { + const BANNER: &str = " +ready — leave this running. + + On this machine, nothing else to do. Other commands find + it on their own and share this one login: + + soulseek-rs search 'aphex twin' + soulseek-rs (the TUI attaches) + soulseek-rs daemon status + soulseek-rs daemon stop +"; + const LOCAL_ONLY: &str = + " To drive it from another machine, restart with --bind ADDR; + `soulseek-rs daemon token` prints the secret it needs. +"; + + ctx.out.status(BANNER); + + let Some(address) = bind else { + ctx.out.status(LOCAL_ONLY); + return; + }; + + ctx.out.status(&format!( + " From another machine, point a client at {address} with + this token: + + {token} + + soulseek-rs config set daemon :PORT + soulseek-rs config set daemon_token + + That port has no encryption. Keep it off the open internet and \ + reach it over SSH instead. +" + )); +} + +/// Remove the socket file so the next start does not have to reason about +/// whether it is stale. +fn cleanup() { + #[cfg(unix)] + if let Some(path) = socket_path() { + let _ = std::fs::remove_file(path); + } +} + +const fn should_reconnect(loss: Option, owned: bool) -> bool { + owned && matches!(loss, Some(SessionLoss::Disconnected)) +} + +fn reconnect_loop( + session: &Arc, + daemon: &Daemon, + out: &Out, + address: &str, + stop: &AtomicBool, +) { + let owned = session.daemon_endpoint().is_none(); + let mut failures = 0u32; + let mut announced = false; + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(hub::POLL); + if !should_reconnect(session.session_loss(), owned) { + failures = 0; + announced = false; + continue; + } + if !announced { + out.status(&format!( + "the connection to {address} dropped; logging back in…" + )); + announced = true; + } + match session.relogin() { + Ok(true) => { + out.status("logged back in"); + rejoin(out, session.as_ref(), daemon); + failures = 0; + announced = false; + } + outcome => { + let wait = backoff(failures); + failures = failures.saturating_add(1); + out.warn(&format!( + "cannot log back in: {}; trying again in {}s", + relogin_error(outcome), + wait.as_secs() + )); + wait_or_stop(wait, stop); + } + } + } +} + +fn relogin_error(outcome: soulseek_rs::Result) -> String { + match outcome { + Ok(_) => "login rejected by the server".to_string(), + Err(e) => format!("login failed: {e}"), + } +} + +fn backoff(failures: u32) -> Duration { + Duration::from_secs(1 << failures.min(6)).min(RECONNECT_BACKOFF_CAP) +} + +fn wait_or_stop(total: Duration, stop: &AtomicBool) { + let deadline = Instant::now() + total; + while Instant::now() < deadline && !stop.load(Ordering::Relaxed) { + std::thread::sleep(hub::POLL); + } +} + +fn rejoin(out: &Out, session: &dyn SessionApi, daemon: &Daemon) { + join_rooms(out, session, &daemon.rooms()); + for user in session.watched_users() { + if let Err(e) = session.watch_user(&user) { + out.warn(&format!("cannot watch {user} again: {e}")); + } + } +} + +fn join_rooms(out: &Out, session: &dyn SessionApi, rooms: &[String]) { + for room in rooms { + if let Err(e) = session.join_room(room) { + out.warn(&format!("cannot rejoin {room}: {e}")); + } + } +} + +/// Bring back what the last run left: re-queue unfinished transfers, rejoin +/// rooms, and reload the conversation. +fn restore( + ctx: &Ctx, + store: &StateStore, + session: &dyn SessionApi, + hub: &Hub, + downloads: &Mutex>, +) -> Vec { + hub.restore( + store + .load_messages() + .into_iter() + .map(|message| ChatMessageDto { + peer: message.peer, + outgoing: message.outgoing, + text: message.text, + at: message.at.timestamp(), + }) + .collect(), + ); + + let rooms = store.load_rooms(); + join_rooms(&ctx.out, session, &rooms); + + let unfinished: Vec = store + .load_downloads() + .into_iter() + .filter(|download| !download.completed) + .collect(); + if unfinished.is_empty() { + return rooms; + } + ctx.out + .status(&format!("resuming {} transfers", unfinished.len())); + for download in unfinished { + match session.download( + download.filename.clone(), + download.username.clone(), + download.size, + download.download_directory.clone(), + ) { + Ok((_, updates)) => { + if let Ok(mut pending) = downloads.lock() { + pending.push(PendingDownload { + username: download.username, + filename: download.filename, + updates, + }); + } + } + Err(e) => ctx + .out + .warn(&format!("cannot resume {}: {e}", download.filename)), + } + } + rooms +} + +/// Write daemon-owned state back. Best-effort: a state file that cannot be +/// written is not a reason to drop a working session. +fn save( + store: &StateStore, + session: &dyn SessionApi, + hub: &Hub, + daemon: &Daemon, +) { + let downloads: Vec = session + .get_all_downloads() + .iter() + .filter_map(PersistedDownload::capture) + .collect(); + let _ = store.save_downloads(&downloads); + + let messages: Vec = hub + .history() + .into_iter() + .map(|message| PersistedMessage { + peer: message.peer, + outgoing: message.outgoing, + text: message.text, + at: chrono::DateTime::from_timestamp(message.at, 0) + .unwrap_or_default() + .into(), + }) + .collect(); + let _ = store.save_messages(&messages); + let _ = store.save_rooms(&daemon.rooms()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_is_minted_once_and_then_reused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join(TOKEN_NAME); + + let first = load_or_create_token(&path).expect("a token is minted"); + let second = load_or_create_token(&path).expect("the token is reused"); + + assert_eq!( + first, second, + "restarting must not lock existing clients out" + ); + assert_eq!(first.len(), 64, "256 bits, hex encoded"); + assert!(first.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn only_an_owned_dropped_session_reconnects() { + assert!(should_reconnect(Some(SessionLoss::Disconnected), true)); + assert!( + !should_reconnect(Some(SessionLoss::Displaced), true), + "reconnecting a displaced name starts a login war with whoever \ + took it" + ); + assert!( + !should_reconnect(None, true), + "a live session is not retried" + ); + assert!( + !should_reconnect(Some(SessionLoss::Disconnected), false), + "a borrowed session is the daemon it came from to mend" + ); + } + + #[test] + fn the_backoff_doubles_and_then_holds_at_the_cap() { + let seconds: Vec = (0..8).map(|n| backoff(n).as_secs()).collect(); + assert_eq!(seconds, [1, 2, 4, 8, 16, 32, 60, 60]); + assert_eq!( + backoff(u32::MAX), + RECONNECT_BACKOFF_CAP, + "a very long outage must not overflow into a tiny wait" + ); + } + + #[test] + fn two_tokens_are_not_the_same() { + let dir = tempfile::tempdir().expect("a temp dir"); + let first = load_or_create_token(&dir.path().join("a")).unwrap(); + let second = load_or_create_token(&dir.path().join("b")).unwrap(); + assert_ne!(first, second); + } + + #[cfg(unix)] + #[test] + fn a_token_file_is_readable_by_nobody_else() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join(TOKEN_NAME); + load_or_create_token(&path).expect("a token is minted"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "the token is a secret on disk"); + } + + #[test] + fn a_blank_token_file_is_replaced_rather_than_trusted() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join(TOKEN_NAME); + std::fs::write(&path, " \n").unwrap(); + let token = load_or_create_token(&path).expect("a token is minted"); + assert_eq!(token.len(), 64, "an empty secret must never authenticate"); + } + + #[cfg(unix)] + #[test] + fn a_socket_left_by_a_dead_daemon_is_replaced_not_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join(SOCKET_NAME); + // A plain file standing in for what a crash left behind: nothing is + // listening on it, so binding must succeed. + std::fs::write(&path, "").unwrap(); + assert!( + server::bind_unix(&path).is_ok(), + "a stale socket must not strand the daemon" + ); + } + + #[cfg(unix)] + #[test] + fn a_second_daemon_refuses_to_steal_a_live_socket() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join(SOCKET_NAME); + let _first = server::bind_unix(&path).expect("the first daemon binds"); + assert!( + server::bind_unix(&path).is_err(), + "taking over a live socket would silently steal its clients" + ); + } +} diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs new file mode 100644 index 0000000..46ca070 --- /dev/null +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -0,0 +1,512 @@ +//! Generates the published OpenRPC contract from the wire types themselves. +//! +//! `docs/openrpc.json` is what third parties generate clients from, and +//! `rpc.discover` serves it straight out of the binary. Hand-maintaining it +//! would drift the moment a DTO gained a field, so it is derived here from the +//! same structs the daemon actually serializes, and [`document_is_current`] +//! fails the build when the checked-in copy no longer matches. +//! +//! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`. + +use super::proto::{ + Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, + DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, + Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, + 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}; + +/// Where the generated `$ref`s point. OpenRPC keeps shared schemas under +/// `components/schemas`, the same place OpenAPI does. +const DEFINITIONS_PATH: &str = "#/components/schemas/"; + +fn schema_of(generator: &mut SchemaGenerator) -> Value { + generator.subschema_for::().to_value() +} + +/// The parameter schema for one method. +/// +/// Exhaustive on purpose: a new [`Method`] variant does not compile until it is +/// described here, so the published contract cannot fall behind the daemon. +fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { + match method { + Method::Auth => schema_of::(generator), + Method::SearchStart + | Method::SearchResults + | Method::SearchWishlist + | Method::SearchForget => schema_of::(generator), + Method::DownloadStart => schema_of::(generator), + Method::DownloadPause + | Method::DownloadResume + | Method::DownloadRemove + | Method::DownloadRemoveQueued + | Method::DownloadCancel + | Method::UploadCancel => schema_of::(generator), + Method::DownloadSetDir => schema_of::(generator), + Method::AccountPassword => schema_of::(generator), + Method::UploadSlots => schema_of::(generator), + Method::RoomJoin + | Method::RoomLeave + | Method::RoomMembers + | Method::RoomMemberStats => schema_of::(generator), + Method::RoomSay => schema_of::(generator), + Method::MessageSend => schema_of::(generator), + Method::BrowseUser + | Method::UserRequest + | Method::UserInfoOf + | Method::UserWatch + | Method::UserUnwatch => schema_of::(generator), + Method::SharesSet => schema_of::(generator), + // Methods that take no parameters. + Method::DaemonStatus + | Method::DaemonStop + | Method::RpcDiscover + | Method::SearchWishlistInterval + | Method::DownloadList + | Method::UploadList + | Method::PrivilegesCheck + | Method::PrivilegesOwn + | Method::RoomListRequest + | Method::MessageHistory + | Method::SearchList + | Method::SharesStatusOf + | Method::SharesFiles + | Method::SharesReindex + | Method::UserWatched => json!({ "type": "null" }), + } +} + +/// The result schema for one method. Exhaustive for the same reason. +fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { + match method { + Method::Auth => schema_of::(generator), + Method::DaemonStatus => schema_of::(generator), + Method::RpcDiscover => json!({ "type": "object" }), + Method::SearchResults => schema_of::(generator), + Method::SearchList => schema_of::(generator), + Method::SearchWishlistInterval => { + schema_of::(generator) + } + Method::DownloadStart => schema_of::(generator), + Method::DownloadList => schema_of::(generator), + Method::UploadList => schema_of::(generator), + Method::PrivilegesOwn => schema_of::(generator), + Method::RoomMembers => schema_of::(generator), + Method::RoomMemberStats => schema_of::(generator), + Method::UserWatched => schema_of::(generator), + Method::MessageHistory => schema_of::(generator), + Method::UserInfoOf => schema_of::(generator), + Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { + schema_of::(generator) + } + Method::SharesFiles => schema_of::(generator), + // Everything else acknowledges and says nothing more. + Method::DaemonStop + | Method::SearchStart + | Method::SearchWishlist + | Method::DownloadPause + | Method::DownloadResume + | Method::DownloadRemove + | Method::DownloadRemoveQueued + | Method::DownloadCancel + | Method::DownloadSetDir + | Method::AccountPassword + | Method::UploadCancel + | Method::UploadSlots + | Method::PrivilegesCheck + | Method::RoomListRequest + | Method::RoomJoin + | Method::RoomLeave + | Method::RoomSay + | Method::MessageSend + | Method::BrowseUser + | Method::SearchForget + | Method::UserRequest + | Method::UserWatch + | Method::UserUnwatch => schema_of::(generator), + } +} + +/// What a method does, for the generated documentation. +fn summary(method: Method) -> &'static str { + match method { + Method::Auth => { + "Authenticate. Must be the first request on a connection." + } + Method::DaemonStatus => { + "Who the daemon is logged in as and what its session is doing." + } + Method::DaemonStop => "Ask the daemon to shut down.", + Method::RpcDiscover => "This document.", + Method::SearchStart => { + "Put a search on the wire and return at once; results accumulate." + } + Method::SearchResults => { + "Everything that has answered a search so far." + } + Method::SearchList => { + "Every search this session has run, shared by all clients." + } + Method::SearchForget => { + "Drop a search and its results, for every client at once." + } + Method::SearchWishlist => { + "Send a query as a wishlist search (server code 103)." + } + Method::SearchWishlistInterval => { + "How long the server wants between wishlist searches." + } + Method::DownloadStart => { + "Queue a transfer. Progress arrives as event.download_status." + } + Method::DownloadList => "Every transfer the daemon knows about.", + Method::DownloadPause => "Pause a transfer in progress.", + Method::DownloadResume => "Resume a paused transfer.", + Method::DownloadRemove => "Forget a transfer entirely.", + Method::DownloadRemoveQueued => "Drop a transfer that has not started.", + Method::DownloadCancel => { + "Stop a download, queued or in progress; its partial file is deleted." + } + 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.", + Method::PrivilegesCheck => { + "Ask the server for this account's privilege time." + } + Method::PrivilegesOwn => { + "Privilege seconds left, or null if the server has not answered." + } + Method::RoomListRequest => { + "Ask the server for the public room list; it arrives as event.room." + } + Method::RoomJoin => "Join a chat room.", + Method::RoomLeave => "Leave a chat room.", + Method::RoomSay => "Post a message to a room.", + Method::RoomMembers => "Who is currently in a room.", + Method::RoomMemberStats => { + "What the server said about each member of a joined room." + } + Method::UserWatch => { + "Watch a user, so the server keeps pushing their status." + } + Method::UserUnwatch => "Stop watching a user.", + Method::UserWatched => "Everyone this session is watching.", + Method::MessageSend => "Send a private message.", + Method::MessageHistory => { + "Private messages the daemon collected, including while nobody was attached." + } + Method::BrowseUser => { + "Ask a peer for its share listing; it arrives as event.browse." + } + Method::UserRequest => { + "Ask the server about a user; poll user.info for the answer." + } + 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.", + } +} + +/// The payload schema for one pushed event. Exhaustive, like the methods. +fn event_schema(event: Event, generator: &mut SchemaGenerator) -> Value { + match event { + Event::Room => schema_of::(generator), + Event::Message => schema_of::(generator), + Event::Upload => schema_of::(generator), + Event::DownloadStatus => schema_of::(generator), + Event::Browse => schema_of::(generator), + Event::SessionLoss => schema_of::(generator), + } +} + +fn event_summary(event: Event) -> &'static str { + match event { + Event::Room => "Something happened in a room this session has joined.", + Event::Message => "A private message arrived.", + Event::Upload => "An upload changed state.", + Event::DownloadStatus => "A transfer moved.", + Event::Browse => "A peer answered a browse request.", + Event::SessionLoss => { + "The server session was lost. A disconnected session is reconnected \ + on its own and events resume; a displaced one is terminal. Read \ + daemon.status for the live state." + } + } +} + +/// Turn a params object schema into OpenRPC content descriptors — one per +/// field, which is what `by-name` means. +/// +/// A single descriptor holding the whole struct would tell a generator to send +/// `{"params": {"params": {…}}}`, and every generated call would come back +/// `-32602`. The daemon deserializes the params object directly into the +/// struct, so each of its fields is a parameter in its own right. +fn descriptors( + params: &Value, + definitions: &serde_json::Map, +) -> Vec { + let Some(schema) = resolve(params, definitions) else { + return Vec::new(); + }; + let required: Vec<&str> = schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(name, property)| { + json!({ + "name": name, + "required": required.contains(&name.as_str()), + "schema": property, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Follow a `$ref` into the generated definitions. +fn resolve<'a>( + schema: &'a Value, + definitions: &'a serde_json::Map, +) -> Option<&'a Value> { + let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { + return schema.get("properties").map(|_| schema); + }; + definitions.get(reference.strip_prefix(DEFINITIONS_PATH)?) +} + +/// Build the whole document. +fn document() -> Value { + let settings = SchemaSettings::draft2020_12().with(|settings| { + settings.definitions_path = DEFINITIONS_PATH.into(); + }); + let mut generator = SchemaGenerator::new(settings); + + // Schemas first, definitions second: a params schema is a `$ref` until the + // generator has been asked for everything, and expanding it into + // descriptors needs the definition it points at. + let shapes: Vec<(Method, Value, Value)> = Method::ALL + .iter() + .map(|&method| { + ( + method, + params_schema(method, &mut generator), + result_schema(method, &mut generator), + ) + }) + .collect(); + let event_shapes: Vec<(Event, Value)> = Event::ALL + .iter() + .map(|&event| (event, event_schema(event, &mut generator))) + .collect(); + + let definitions: serde_json::Map = + generator.take_definitions(true).into_iter().collect(); + + let methods = shapes.into_iter().map(|(method, params, result)| { + json!({ + "name": method.wire_name(), + "summary": summary(method), + "paramStructure": "by-name", + "params": descriptors(¶ms, &definitions), + "result": { + "name": "result", + "schema": result, + }, + }) + }); + + // OpenRPC has no first-class notion of a server-pushed notification, so + // the events are published as methods flagged with an extension field. + // A generator still emits their payload types; the delivery semantics are + // prose in docs/daemon-protocol.md. + let events = event_shapes.into_iter().map(|(event, payload)| { + json!({ + "name": event.wire_name(), + "summary": event_summary(event), + "x-notification": true, + "paramStructure": "by-name", + // Unlike a method, an event's `params` object *is* the payload, + // and several payloads are tagged enums with no single property + // map to split. One descriptor carrying the whole schema is + // what a generator needs to emit the type. + "params": [{ + "name": "payload", + "required": true, + "schema": payload, + }], + "result": { "name": "result", "schema": { "type": "null" } }, + }) + }); + + json!({ + "openrpc": "1.3.2", + "info": { + "title": "soulseek-rs daemon", + // The contract's own version, which is what a client negotiates in + // `auth`. The crate version would put a release bump in this file + // and fail `document_is_current` on every release PR. + "version": crate::daemon::proto::PROTOCOL_VERSION.to_string(), + "description": "Control a running soulseek-rs daemon. Newline-delimited \ + JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \ + TCP (remote, authenticated by the token in /daemon.token). `auth` must \ + be the first request on a connection. Errors carry the CLI exit status in \ + `error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \ + are never requested.", + "license": { "name": "MIT" }, + }, + "methods": methods.into_iter().chain(events).collect::>(), + "components": { "schemas": definitions }, + }) +} + +/// Where the published document lives. Inside this crate, not the repository's +/// docs/: `include_str!` has to find it in the published package too. +fn document_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("docs") + .join("openrpc.json") +} + +#[test] +fn document_is_current() { + let generated = format!( + "{}\n", + serde_json::to_string_pretty(&document()) + .expect("the document is plain data") + ); + + let path = document_path(); + if std::env::var_os("UPDATE_OPENRPC").is_some() { + std::fs::write(&path, &generated).expect("docs/ is writable"); + return; + } + + let published = std::fs::read_to_string(&path).unwrap_or_default(); + assert_eq!( + published, generated, + "docs/openrpc.json is out of date with the wire types — regenerate it \ + with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`" + ); +} + +#[test] +fn every_method_and_event_is_published() { + let document = document(); + let published: Vec<&str> = document["methods"] + .as_array() + .expect("methods is an array") + .iter() + .map(|method| method["name"].as_str().expect("a name is a string")) + .collect(); + + for method in Method::ALL { + assert!( + published.contains(&method.wire_name()), + "{method} is missing from the published contract" + ); + } + for event in Event::ALL { + assert!( + published.contains(&event.wire_name()), + "{event} is missing from the published contract" + ); + } + assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len()); +} + +/// The published parameters must be the keys the daemon actually reads. +/// +/// This is the shape a generated client sends. Publishing the params struct as +/// one descriptor named `params` type-checks and generates cleanly, and every +/// call it produces fails `-32602`, because the client then sends +/// `{"params": {"params": {…}}}` where the daemon expects the inner object. +#[test] +fn published_parameters_are_the_keys_the_daemon_reads() { + use super::proto::{QueryParams, RoomRef, TransferRef}; + + let document = document(); + let published = |name: &str| -> Vec { + document["methods"] + .as_array() + .expect("methods is an array") + .iter() + .find(|method| method["name"] == name) + .unwrap_or_else(|| panic!("{name} is published"))["params"] + .as_array() + .expect("params is an array") + .iter() + .map(|p| p["name"].as_str().expect("a name").to_string()) + .collect() + }; + let keys = |value: &Value| -> Vec { + value + .as_object() + .expect("params serialize as an object") + .keys() + .cloned() + .collect() + }; + + assert_eq!( + published("room.join"), + keys( + &serde_json::to_value(RoomRef { + room: "lobby".into() + }) + .unwrap() + ) + ); + assert_eq!( + published("search.results"), + keys(&serde_json::to_value(QueryParams { query: "x".into() }).unwrap()) + ); + assert_eq!( + published("download.pause"), + keys( + &serde_json::to_value(TransferRef { + username: "bob".into(), + filename: "a.mp3".into() + }) + .unwrap() + ) + ); + assert!( + published("daemon.status").is_empty(), + "a method that takes nothing publishes no parameters" + ); +} + +#[test] +fn a_wire_name_round_trips_through_its_enum() { + for method in Method::ALL { + assert_eq!(Method::from_wire(method.wire_name()), Some(*method)); + } + for event in Event::ALL { + assert_eq!(Event::from_wire(event.wire_name()), Some(*event)); + } + assert_eq!(Method::from_wire("search.nope"), None); +} diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs new file mode 100644 index 0000000..2598859 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -0,0 +1,897 @@ +//! The data types that cross the wire. +//! +//! Library types never travel as themselves. Each has an explicit DTO and a +//! mechanical conversion, so the published shape is versioned independently of +//! `soulseek-rs-lib` — a refactor there cannot silently change what a +//! third-party script receives, and the compiler points here when one would. + +use serde::{Deserialize, Serialize}; +use soulseek_rs::types::{ + Download, DownloadMetadata, RoomUserStats, UserPresence, UserStats, + UserStatus, +}; +use soulseek_rs::{ + DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, + SharedDirectory, SharedFileEntry, UploadInfo, UploadStatus, UserInfo, + UserMessage, +}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthParams { + /// Required on TCP. Omitted on a Unix socket, where the socket's own + /// permissions already prove the caller is this user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default)] + pub protocol: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthResult { + pub protocol: u32, + pub daemon_version: String, + /// The Soulseek account the daemon is logged in as. + pub username: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct FileDto { + pub name: String, + pub size: u64, + /// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds). + pub attribs: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResultDto { + pub token: u32, + pub username: String, + pub slots: u8, + pub speed: u32, + pub files: Vec, +} + +impl From<&SearchResult> for SearchResultDto { + fn from(result: &SearchResult) -> Self { + Self { + token: result.token, + username: result.username.clone(), + slots: result.slots, + speed: result.speed, + files: result + .files + .iter() + .map(|file| FileDto { + name: file.name.clone(), + size: file.size, + attribs: file.attribs.clone(), + }) + .collect(), + } + } +} + +impl From for SearchResult { + fn from(dto: SearchResultDto) -> Self { + Self { + token: dto.token, + slots: dto.slots, + speed: dto.speed, + files: dto + .files + .into_iter() + .map(|file| File { + username: dto.username.clone(), + name: file.name, + size: file.size, + attribs: file.attribs, + }) + .collect(), + username: dto.username, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum DownloadStatusDto { + Queued, + InProgress { + bytes_downloaded: u64, + total_bytes: u64, + speed_bytes_per_sec: f64, + }, + Paused { + bytes_downloaded: u64, + total_bytes: u64, + }, + Completed, + Cancelled, + Failed { + reason: Option, + }, + TimedOut, +} + +impl From<&DownloadStatus> for DownloadStatusDto { + fn from(status: &DownloadStatus) -> Self { + match status { + DownloadStatus::Queued => Self::Queued, + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + speed_bytes_per_sec: *speed_bytes_per_sec, + }, + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + }, + DownloadStatus::Completed => Self::Completed, + DownloadStatus::Cancelled => Self::Cancelled, + DownloadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + DownloadStatus::TimedOut => Self::TimedOut, + } + } +} + +impl From for DownloadStatus { + fn from(dto: DownloadStatusDto) -> Self { + match dto { + DownloadStatusDto::Queued => Self::Queued, + DownloadStatusDto::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + }, + DownloadStatusDto::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded, + total_bytes, + }, + DownloadStatusDto::Completed => Self::Completed, + DownloadStatusDto::Cancelled => Self::Cancelled, + DownloadStatusDto::Failed { reason } => Self::Failed(reason), + DownloadStatusDto::TimedOut => Self::TimedOut, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadMetadataDto { + #[serde(default)] + pub bitrate: Option, + #[serde(default)] + pub length_seconds: Option, + #[serde(default)] + pub peer_upload_speed: Option, + #[serde(default)] + pub peer_free_slots: Option, +} + +impl From<&DownloadMetadata> for DownloadMetadataDto { + fn from(metadata: &DownloadMetadata) -> Self { + Self { + bitrate: metadata.bitrate, + length_seconds: metadata.length_seconds, + peer_upload_speed: metadata.peer_upload_speed, + peer_free_slots: metadata.peer_free_slots, + } + } +} + +impl From for DownloadMetadata { + fn from(dto: DownloadMetadataDto) -> Self { + Self { + bitrate: dto.bitrate, + length_seconds: dto.length_seconds, + peer_upload_speed: dto.peer_upload_speed, + peer_free_slots: dto.peer_free_slots, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadDto { + pub username: String, + pub filename: String, + pub token: u32, + pub size: u64, + pub download_directory: String, + pub status: DownloadStatusDto, + #[serde(default)] + pub queue_position: Option, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +impl From<&Download> for DownloadDto { + fn from(download: &Download) -> Self { + Self { + username: download.username.clone(), + filename: download.filename.clone(), + token: download.token, + size: download.size, + download_directory: download.download_directory.clone(), + status: DownloadStatusDto::from(&download.status), + queue_position: download.queue_position, + metadata: DownloadMetadataDto::from(&download.metadata), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum UploadStatusDto { + /// Waiting for a slot, at this 1-based place in the queue. + Queued { + place: u32, + }, + InProgress, + Completed, + Cancelled, + Failed { + reason: String, + }, +} + +impl From<&UploadStatus> for UploadStatusDto { + fn from(status: &UploadStatus) -> Self { + match status { + UploadStatus::Queued(place) => Self::Queued { place: *place }, + UploadStatus::InProgress => Self::InProgress, + UploadStatus::Completed => Self::Completed, + UploadStatus::Cancelled => Self::Cancelled, + UploadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + } + } +} + +impl From for UploadStatus { + fn from(dto: UploadStatusDto) -> Self { + match dto { + UploadStatusDto::Queued { place } => Self::Queued(place), + UploadStatusDto::InProgress => Self::InProgress, + UploadStatusDto::Completed => Self::Completed, + UploadStatusDto::Cancelled => Self::Cancelled, + UploadStatusDto::Failed { reason } => Self::Failed(reason), + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UploadInfoDto { + pub username: String, + pub filename: String, + pub size: u64, + pub bytes_sent: u64, + pub status: UploadStatusDto, + pub speed_bytes_per_sec: f64, +} + +impl From<&UploadInfo> for UploadInfoDto { + fn from(upload: &UploadInfo) -> Self { + Self { + username: upload.username.clone(), + filename: upload.filename.clone(), + size: upload.size, + bytes_sent: upload.bytes_sent, + status: UploadStatusDto::from(&upload.status), + speed_bytes_per_sec: upload.speed_bytes_per_sec, + } + } +} + +impl From for UploadInfo { + fn from(dto: UploadInfoDto) -> Self { + Self { + username: dto.username, + filename: dto.filename, + size: dto.size, + bytes_sent: dto.bytes_sent, + status: dto.status.into(), + speed_bytes_per_sec: dto.speed_bytes_per_sec, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomInfoDto { + pub name: String, + pub user_count: u32, +} + +impl From<&RoomInfo> for RoomInfoDto { + fn from(room: &RoomInfo) -> Self { + Self { + name: room.name.clone(), + user_count: room.user_count, + } + } +} + +impl From for RoomInfo { + fn from(dto: RoomInfoDto) -> Self { + Self { + name: dto.name, + user_count: dto.user_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum RoomEventDto { + List { + rooms: Vec, + }, + Joined { + room: String, + users: Vec, + }, + Left { + room: String, + }, + Message { + room: String, + username: String, + message: String, + }, + UserJoined { + room: String, + username: String, + }, + UserLeft { + room: String, + username: String, + }, + /// The ticker board of a room, sent on join. + Tickers { + room: String, + tickers: Vec, + }, + TickerAdded { + room: String, + username: String, + ticker: String, + }, + TickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed; `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room. + PrivateMembers { + room: String, + users: Vec, + }, + /// Who runs a private room. + PrivateOperators { + room: String, + users: Vec, + }, + /// A private room's member (`members` true) or operator roster changed. + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership or operatorship of a private room changed. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created. + CantCreate { + room: String, + }, +} + +/// One user's ticker in a room. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomTickerDto { + pub username: String, + pub ticker: String, +} + +impl From<&soulseek_rs::RoomTicker> for RoomTickerDto { + fn from(ticker: &soulseek_rs::RoomTicker) -> Self { + Self { + username: ticker.username.clone(), + ticker: ticker.ticker.clone(), + } + } +} + +impl From for soulseek_rs::RoomTicker { + fn from(dto: RoomTickerDto) -> Self { + Self { + username: dto.username, + ticker: dto.ticker, + } + } +} + +impl From<&RoomEvent> for RoomEventDto { + fn from(event: &RoomEvent) -> Self { + match event { + RoomEvent::List(rooms) => Self::List { + rooms: rooms.iter().map(RoomInfoDto::from).collect(), + }, + RoomEvent::Joined { room, users } => Self::Joined { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::Left { room } => Self::Left { room: room.clone() }, + RoomEvent::Message { + room, + username, + message, + } => Self::Message { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::UserJoined { room, username } => Self::UserJoined { + room: room.clone(), + username: username.clone(), + }, + RoomEvent::UserLeft { room, username } => Self::UserLeft { + 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() } + } + } + } +} + +impl From for RoomEvent { + fn from(dto: RoomEventDto) -> Self { + match dto { + RoomEventDto::List { rooms } => { + Self::List(rooms.into_iter().map(RoomInfo::from).collect()) + } + RoomEventDto::Joined { room, users } => { + Self::Joined { room, users } + } + RoomEventDto::Left { room } => Self::Left { room }, + RoomEventDto::Message { + room, + username, + message, + } => Self::Message { + room, + username, + message, + }, + RoomEventDto::UserJoined { room, username } => { + Self::UserJoined { room, username } + } + 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 }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserPresenceDto { + /// `online`, `away`, or `offline`. + pub status: String, + pub privileged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserStatsDto { + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, +} + +/// The inverse of [`UserStatus`]'s `Display`. An unrecognised word reads as +/// offline, matching how the wire code is decoded. +fn status_from_str(status: &str) -> UserStatus { + match status { + "online" => UserStatus::Online, + "away" => UserStatus::Away, + _ => UserStatus::Offline, + } +} + +/// One member of a room, as the JoinRoom reply described them. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomUserStatsDto { + pub username: String, + /// `online`, `away`, or `offline`. + pub status: String, + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, + /// True when the member's upload slots are all occupied. + pub slots_full: bool, + pub country: Option, +} + +impl From<&RoomUserStats> for RoomUserStatsDto { + fn from(stats: &RoomUserStats) -> Self { + Self { + username: stats.username.clone(), + status: stats.status.to_string(), + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + slots_full: stats.slots_full, + country: stats.country.clone(), + } + } +} + +impl From for RoomUserStats { + fn from(dto: RoomUserStatsDto) -> Self { + Self { + username: dto.username, + status: status_from_str(&dto.status), + average_speed: dto.average_speed, + shared_files: dto.shared_files, + shared_folders: dto.shared_folders, + slots_full: dto.slots_full, + country: dto.country, + } + } +} + +/// Each half is `None` until its reply lands, so a consumer can tell "the +/// server said offline" from "the server has not answered yet". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserInfoDto { + pub username: String, + #[serde(default)] + pub presence: Option, + #[serde(default)] + pub stats: Option, +} + +impl From<&UserInfo> for UserInfoDto { + fn from(info: &UserInfo) -> Self { + Self { + username: info.username.clone(), + presence: info.presence.map(|presence| UserPresenceDto { + status: presence.status.to_string(), + privileged: presence.privileged, + }), + stats: info.stats.map(|stats| UserStatsDto { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }), + } + } +} + +impl From for UserInfo { + fn from(dto: UserInfoDto) -> Self { + let mut info = Self::pending(dto.username); + info.presence = dto.presence.map(|presence| UserPresence { + status: status_from_str(&presence.status), + privileged: presence.privileged, + }); + info.stats = dto.stats.map(|stats| UserStats { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }); + info + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedDirectoryDto { + pub name: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedFileEntryDto { + /// Basename; join with the directory to get a downloadable path. + pub name: String, + pub size: u64, + /// `(code, value)` audio attributes the owner advertises. + pub attributes: Vec<(u32, u32)>, +} + +impl From<&SharedDirectory> for SharedDirectoryDto { + fn from(directory: &SharedDirectory) -> Self { + Self { + name: directory.name.clone(), + files: directory + .files + .iter() + .map(|file| SharedFileEntryDto { + name: file.name.clone(), + size: file.size, + attributes: file.attributes.clone(), + }) + .collect(), + } + } +} + +/// The same, consuming the listing. The daemon owns what `shared_listing` +/// hands it and drops it straight after, so copying every name and attribute +/// out of it would double the cost of answering `shares.files`. +impl From for SharedDirectoryDto { + fn from(directory: SharedDirectory) -> Self { + Self { + name: directory.name, + files: directory + .files + .into_iter() + .map(|file| SharedFileEntryDto { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), + } + } +} + +impl From for SharedDirectory { + fn from(dto: SharedDirectoryDto) -> Self { + Self { + name: dto.name, + files: dto + .files + .into_iter() + .map(|file| SharedFileEntry { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserMessageDto { + pub id: u32, + pub timestamp: u32, + pub username: String, + pub message: String, + pub new_message: bool, +} + +impl From<&UserMessage> for UserMessageDto { + fn from(message: &UserMessage) -> Self { + Self { + id: message.id(), + timestamp: message.timestamp(), + username: message.username().to_string(), + message: message.message().to_string(), + new_message: message.is_new(), + } + } +} + +impl From for UserMessage { + fn from(dto: UserMessageDto) -> Self { + Self::new( + dto.id, + dto.timestamp, + dto.username, + dto.message, + dto.new_message, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum SessionLossDto { + Displaced, + Disconnected, +} + +impl From for SessionLossDto { + fn from(loss: SessionLoss) -> Self { + match loss { + SessionLoss::Displaced => Self::Displaced, + SessionLoss::Disconnected => Self::Disconnected, + } + } +} + +impl From for SessionLoss { + fn from(dto: SessionLossDto) -> Self { + match dto { + SessionLossDto::Displaced => Self::Displaced, + SessionLossDto::Disconnected => Self::Disconnected, + } + } +} + +/// What the daemon is and what its session is doing, from `daemon.status`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DaemonStatus { + pub username: String, + pub server: String, + pub daemon_version: String, + pub protocol: u32, + #[serde(default)] + pub listen_port: Option, + pub shared_folders: u32, + pub shared_files: u32, + pub download_dir: String, + #[serde(default)] + pub session_loss: Option, + /// Control connections currently attached. + pub clients: usize, + pub uptime_secs: u64, +} + +/// What the share index holds, from `shares.status` / `shares.reindex`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharesStatus { + pub folders: u32, + pub files: u32, + pub directories: Vec, +} diff --git a/soulseek-rs/src/daemon/proto/envelope.rs b/soulseek-rs/src/daemon/proto/envelope.rs new file mode 100644 index 0000000..662d760 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/envelope.rs @@ -0,0 +1,150 @@ +//! JSON-RPC 2.0 framing: what a request, a reply, and an error look like on +//! the wire, independent of the methods they carry. + +use crate::output::Exit; +use serde::{Deserialize, Serialize}; + +/// A request from a client. `id` is absent for a notification, which by +/// JSON-RPC rule gets no reply. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Request { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request, +/// and is absent on a daemon-initiated notification. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Response { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn jsonrpc_version() -> String { + "2.0".to_string() +} + +/// A failure, carrying the exit code the same command would have produced +/// locally so a remote run exits exactly as a local one does. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ErrorData { + /// The `Exit` discriminant this failure maps to. + pub exit: u8, +} + +/// Application errors all share one code; the exit status in `data` is what a +/// caller branches on. +pub const CODE_APPLICATION: i32 = -32000; +pub const CODE_PARSE: i32 = -32700; +pub const CODE_INVALID_REQUEST: i32 = -32600; +pub const CODE_METHOD_NOT_FOUND: i32 = -32601; +pub const CODE_INVALID_PARAMS: i32 = -32602; + +impl RpcError { + #[must_use] + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + /// An application failure that should reproduce `exit` on the client. + #[must_use] + pub fn application(exit: Exit, message: impl Into) -> Self { + Self { + code: CODE_APPLICATION, + message: message.into(), + data: Some(ErrorData { exit: exit.code() }), + } + } + + /// The exit status this error asks the client to produce, defaulting to a + /// generic failure when the daemon did not say. + #[must_use] + pub fn exit(&self) -> Exit { + match self.data.as_ref().map(|data| data.exit) { + Some(0) => Exit::Ok, + Some(2) => Exit::Usage, + Some(3) => Exit::Connection, + Some(4) => Exit::NoResults, + Some(5) => Exit::Timeout, + Some(6) => Exit::Transfer, + Some(7) => Exit::SessionLost, + _ => Exit::Failure, + } + } +} + +impl Response { + #[must_use] + pub fn result( + id: Option, + value: serde_json::Value, + ) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id, + method: None, + params: None, + result: Some(value), + error: None, + } + } + + /// A failed reply. + /// + /// An error always carries an `id` member, explicitly `null` when the + /// request was too malformed to have one. JSON-RPC requires it, and + /// without it a client following the "no id means a notification" rule + /// would drop the error and wait out its timeout for a reply that is + /// already there. + #[must_use] + pub fn failure(id: Option, error: RpcError) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id: Some(id.unwrap_or(serde_json::Value::Null)), + method: None, + params: None, + result: None, + error: Some(error), + } + } + + /// A daemon-initiated event: no id, so no reply is expected. + #[must_use] + pub fn notification(method: &str, params: serde_json::Value) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id: None, + method: Some(method.to_string()), + params: Some(params), + result: None, + error: None, + } + } +} diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs new file mode 100644 index 0000000..de93784 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -0,0 +1,236 @@ +//! What each method takes and returns, and what each pushed event carries. + +use super::dto::RoomUserStatsDto; +use super::dto::{ + DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto, + SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto, +}; +use serde::{Deserialize, Serialize}; + +/// A void result. JSON-RPC allows a null result, but an object leaves room to +/// add a field later without changing the shape a client already parses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Ack { + pub ok: bool, +} + +impl Ack { + pub const OK: Self = Self { ok: true }; +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct QueryParams { + pub query: String, +} + +/// Names one transfer, which is how both directions are addressed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct TransferRef { + pub username: String, + pub filename: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomRef { + pub room: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserRef { + pub username: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SayParams { + pub room: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct MessageParams { + pub username: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStartParams { + pub username: String, + pub filename: String, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SlotsParams { + pub slots: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DirectoriesParams { + pub directories: Vec, +} + +/// A new password for the account the daemon is logged in as. Plain text on +/// the wire, like the token: the transport is the local socket, or TCP behind +/// an SSH tunnel (see docs/daemon-protocol.md). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct PasswordParams { + pub password: String, +} + +/// Where the daemon should land transfers from now on, on *its* filesystem. +/// The same trust as `shares.set`: an authenticated caller is the owner. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DirectoryParams { + pub directory: String, +} + +/// Every search this session has run, newest last. Shared by all clients, so +/// a second one attaching sees what the first has been looking for. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Searches { + pub searches: Vec, +} + +/// One search as the daemon sees it, for a client that did not run it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchSummary { + pub query: String, + /// Files found so far, across every peer that has answered. + pub files: usize, + /// How long ago the query went out. A client compares this with its own + /// search window to decide whether the search is still collecting. + pub started_secs_ago: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResults { + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Downloads { + pub downloads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStarted { + pub download: DownloadDto, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Uploads { + pub uploads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Members { + pub users: Vec, +} + +/// What the server said about each member of a room when it was joined. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct MemberStats { + pub members: Vec, +} + +/// Everyone this session is watching, sorted by name. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Watched { + pub users: Vec, +} + +/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a +/// direction, because a conversation the daemon collected includes what this +/// account sent as well as what it received. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct ChatMessageDto { + /// The other party, whichever way the message went. + pub peer: String, + pub outgoing: bool, + pub text: String, + /// Unix seconds. + pub at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Messages { + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserResult { + #[serde(default)] + pub user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Seconds { + #[serde(default)] + pub seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct IntervalSeconds { + pub seconds: u64, +} + +/// A peer answered a browse request. Carried whole rather than as a "go ask +/// for it" nudge, because the daemon's own copy is consumed when it drains. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct BrowseEvent { + pub username: String, + pub directories: Vec, +} + +/// This session's own share index, as a peer receives it — what +/// `shares.files` answers with. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedListing { + pub directories: Vec, +} + +/// One step in a transfer this client started. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStatusEvent { + pub username: String, + pub filename: String, + pub status: DownloadStatusDto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SessionLossEvent { + pub loss: SessionLossDto, +} diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs new file mode 100644 index 0000000..9f24d7c --- /dev/null +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -0,0 +1,417 @@ +//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it. +//! +//! Library types never travel as themselves. Every payload is a DTO defined +//! here with an explicit conversion, so the wire format is versioned +//! independently of the library: a refactor inside `soulseek-rs-lib` cannot +//! silently change what a third-party script receives, and the compiler points +//! at this file when one would. +//! +//! The schemas published in `docs/openrpc.json` are derived from these types +//! (see the tests at the bottom), which is what keeps the advertised contract +//! and the running daemon in agreement. + +/// Bumped when a change would break a client written against the old shape. +/// Sent in the `auth` reply so a mismatch is caught at connect time rather +/// than as a confusing failure three calls later. +pub const PROTOCOL_VERSION: u32 = 2; + +/// The OpenRPC document, served by `rpc.discover` so a generator can point at +/// a running daemon instead of needing this repository. +pub const OPENRPC: &str = include_str!("../../../docs/openrpc.json"); + +mod dto; +mod envelope; +mod method; + +pub use dto::*; +pub use envelope::*; +pub use method::*; + +/// Declare a wire vocabulary once: the enum, the complete list, and the +/// name mapping in both directions. +/// +/// Everything that consumes one of these matches on the enum, so the match is +/// exhaustive and adding a line here is a compile error everywhere the new +/// member still has to be handled — the dispatcher, the published schema, the +/// remote client. A `&[&str]` table checked by a test would only fail after +/// the fact, and only if someone remembered to write the test. +macro_rules! wire_vocabulary { + ($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum $name { + $($variant),* + } + + impl $name { + /// Every member, in published order. + pub const ALL: &'static [Self] = &[$(Self::$variant),*]; + + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + $(Self::$variant => $wire),* + } + } + + #[must_use] + pub fn from_wire(name: &str) -> Option { + match name { + $($wire => Some(Self::$variant),)* + _ => None, + } + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.wire_name()) + } + } + }; +} + +wire_vocabulary! { + /// Every method the daemon answers. + Method { + Auth => "auth", + DaemonStatus => "daemon.status", + DaemonStop => "daemon.stop", + RpcDiscover => "rpc.discover", + SearchStart => "search.start", + SearchResults => "search.results", + SearchList => "search.list", + SearchForget => "search.forget", + SearchWishlist => "search.wishlist", + SearchWishlistInterval => "search.wishlist_interval", + DownloadStart => "download.start", + DownloadList => "download.list", + DownloadPause => "download.pause", + DownloadResume => "download.resume", + DownloadRemove => "download.remove", + DownloadRemoveQueued => "download.remove_queued", + DownloadCancel => "download.cancel", + DownloadSetDir => "download.set_dir", + UploadList => "upload.list", + UploadCancel => "upload.cancel", + UploadSlots => "upload.slots", + PrivilegesCheck => "privileges.check", + PrivilegesOwn => "privileges.own", + RoomListRequest => "room.list_request", + RoomJoin => "room.join", + RoomLeave => "room.leave", + RoomSay => "room.say", + RoomMembers => "room.members", + RoomMemberStats => "room.member_stats", + MessageSend => "message.send", + MessageHistory => "message.history", + BrowseUser => "browse.user", + UserRequest => "user.request", + UserInfoOf => "user.info", + UserWatch => "user.watch", + UserUnwatch => "user.unwatch", + UserWatched => "user.watched", + AccountPassword => "account.password", + SharesStatusOf => "shares.status", + SharesFiles => "shares.files", + SharesSet => "shares.set", + SharesReindex => "shares.reindex", + } +} + +wire_vocabulary! { + /// Events the daemon pushes without being asked. + /// + /// These exist because the library's `take_*` accessors are destructive: + /// with two clients attached, whichever drained first would steal the + /// others' events. The daemon is the single drainer and fans out. + Event { + Room => "event.room", + Message => "event.message", + Upload => "event.upload", + DownloadStatus => "event.download_status", + Browse => "event.browse", + SessionLoss => "event.session_loss", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::Exit; + use soulseek_rs::types::{UserPresence, UserStats, UserStatus}; + use soulseek_rs::{ + DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, + SharedDirectory, UploadStatus, UserInfo, UserMessage, + }; + use std::collections::HashMap; + + #[test] + fn a_search_result_survives_the_round_trip() { + let mut attribs = HashMap::new(); + attribs.insert(0, 320); + attribs.insert(1, 210); + let original = SearchResult { + token: 7, + slots: 2, + speed: 900, + username: "bob".into(), + files: vec![File { + username: "bob".into(), + name: "@@x\\a.mp3".into(), + size: 4096, + attribs, + }], + }; + + let json = serde_json::to_string(&SearchResultDto::from(&original)) + .expect("a DTO serializes"); + let back: SearchResult = serde_json::from_str::(&json) + .unwrap() + .into(); + + assert_eq!(back.token, original.token); + assert_eq!(back.username, original.username); + assert_eq!(back.slots, original.slots); + assert_eq!(back.speed, original.speed); + assert_eq!(back.files.len(), 1); + assert_eq!(back.files[0].name, "@@x\\a.mp3"); + assert_eq!(back.files[0].size, 4096); + assert_eq!(back.files[0].attribs.get(&0), Some(&320)); + // The per-file username is carried by the parent result, so it has to + // be restored rather than lost. + assert_eq!(back.files[0].username, "bob"); + } + + #[test] + fn every_download_status_survives_the_round_trip() { + let statuses = [ + DownloadStatus::Queued, + DownloadStatus::InProgress { + bytes_downloaded: 10, + total_bytes: 100, + speed_bytes_per_sec: 1.5, + }, + DownloadStatus::Paused { + bytes_downloaded: 10, + total_bytes: 100, + }, + DownloadStatus::Completed, + DownloadStatus::Cancelled, + DownloadStatus::Failed(Some("gone".into())), + DownloadStatus::Failed(None), + DownloadStatus::TimedOut, + ]; + for status in &statuses { + let dto = DownloadStatusDto::from(status); + let json = serde_json::to_string(&dto).unwrap(); + let back: DownloadStatusDto = serde_json::from_str(&json).unwrap(); + assert_eq!(dto, back, "{status:?} must survive the wire"); + assert_eq!( + DownloadStatusDto::from(&DownloadStatus::from(back)), + dto, + "{status:?} must convert back to the same library value" + ); + } + } + + #[test] + fn every_upload_status_survives_the_round_trip() { + let statuses = [ + UploadStatus::Queued(3), + UploadStatus::InProgress, + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".into()), + ]; + for status in &statuses { + let dto = UploadStatusDto::from(status); + let json = serde_json::to_string(&dto).unwrap(); + let back: UploadStatus = + serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(&back, status); + } + } + + #[test] + fn every_room_event_survives_the_round_trip() { + let events = [ + RoomEvent::List(vec![RoomInfo { + name: "lobby".into(), + user_count: 42, + }]), + RoomEvent::Joined { + room: "lobby".into(), + users: vec!["bob".into()], + }, + RoomEvent::Left { + room: "lobby".into(), + }, + RoomEvent::Message { + room: "lobby".into(), + username: "bob".into(), + message: "hi".into(), + }, + RoomEvent::UserJoined { + room: "lobby".into(), + username: "sue".into(), + }, + RoomEvent::UserLeft { + room: "lobby".into(), + username: "sue".into(), + }, + ]; + for event in &events { + let json = + serde_json::to_string(&RoomEventDto::from(event)).unwrap(); + let back: RoomEvent = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(&back, event); + } + } + + #[test] + fn a_partly_answered_user_stays_partly_answered() { + // "the server has not said" must not arrive as "offline, shares + // nothing": that is the difference between waiting and giving up. + let pending = UserInfo::pending("bob".into()); + let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap(); + let back: UserInfo = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(back.username, "bob"); + assert!(back.presence.is_none()); + assert!(back.stats.is_none()); + assert!(!back.is_complete()); + } + + #[test] + fn a_complete_user_survives_the_round_trip() { + let mut info = UserInfo::pending("bob".into()); + info.presence = Some(UserPresence { + status: UserStatus::Away, + privileged: true, + }); + info.stats = Some(UserStats { + average_speed: 500, + shared_files: 10, + shared_folders: 2, + }); + + let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap(); + let back: UserInfo = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(back, info); + } + + #[test] + fn a_private_message_survives_the_round_trip() { + let message = + UserMessage::new(9, 1234, "bob".into(), "hi".into(), true); + let json = + serde_json::to_string(&UserMessageDto::from(&message)).unwrap(); + let back: UserMessage = serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(back.id(), 9); + assert_eq!(back.timestamp(), 1234); + assert_eq!(back.username(), "bob"); + assert_eq!(back.message(), "hi"); + } + + #[test] + fn a_shared_listing_survives_the_round_trip() { + let directory = SharedDirectory { + name: "@@x\\Music".into(), + files: vec![soulseek_rs::SharedFileEntry { + name: "a.mp3".into(), + size: 4096, + attributes: vec![(0, 320)], + }], + }; + let json = serde_json::to_string(&SharedDirectoryDto::from(&directory)) + .unwrap(); + let back: SharedDirectory = + serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(back.name, directory.name); + assert_eq!(back.files, directory.files); + } + + #[test] + fn an_error_carries_the_exit_code_a_local_run_would_have_produced() { + for exit in [ + Exit::Usage, + Exit::Connection, + Exit::NoResults, + Exit::Timeout, + Exit::Transfer, + Exit::SessionLost, + ] { + let error = RpcError::application(exit, "nope"); + let json = serde_json::to_string(&error).unwrap(); + let back: RpcError = serde_json::from_str(&json).unwrap(); + assert_eq!(back.exit(), exit); + } + } + + #[test] + fn an_error_without_an_exit_code_is_a_generic_failure() { + let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method"); + assert_eq!(error.exit(), Exit::Failure); + } + + #[test] + fn a_request_parses_without_optional_fields() { + let request: Request = serde_json::from_str( + r#"{"jsonrpc":"2.0","method":"daemon.status"}"#, + ) + .expect("params and id are optional"); + assert_eq!(request.method, "daemon.status"); + assert!(request.id.is_none()); + assert!(request.params.is_null()); + } + + #[test] + fn an_error_always_carries_an_id_even_when_there_was_none() { + // JSON-RPC requires it, and without it a client following the + // documented "no id means a notification" rule drops the error and + // waits out its timeout for a reply that already arrived. + let json = serde_json::to_string(&Response::failure( + None, + RpcError::new(CODE_PARSE, "unparseable"), + )) + .expect("a response is plain data"); + assert!(json.contains("\"id\":null"), "{json}"); + } + + #[test] + fn a_notification_carries_no_id_so_no_reply_is_expected() { + let notification = + Response::notification("event.room", serde_json::json!({"a": 1})); + let json = serde_json::to_string(¬ification).unwrap(); + assert!(!json.contains("\"id\""), "{json}"); + assert!(json.contains("\"method\":\"event.room\""), "{json}"); + } + + #[test] + fn a_response_serializes_exactly_one_of_result_and_error() { + let ok = serde_json::to_string(&Response::result( + Some(serde_json::json!(1)), + serde_json::json!({}), + )) + .unwrap(); + assert!(ok.contains("\"result\"")); + assert!(!ok.contains("\"error\"")); + + let failed = serde_json::to_string(&Response::failure( + Some(serde_json::json!(1)), + RpcError::application(Exit::NoResults, "nothing"), + )) + .unwrap(); + assert!(failed.contains("\"error\"")); + assert!(!failed.contains("\"result\"")); + } +} diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs new file mode 100644 index 0000000..f11ac23 --- /dev/null +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -0,0 +1,1055 @@ +//! The control listener: who may connect, and what they may ask for. +//! +//! One thread per connection, which is noise next to the thousand-odd peer +//! threads the client already runs. Each connection owns a writer thread fed by +//! a channel, so request replies and pushed events interleave onto the socket +//! without a lock and without either being able to block the other. + +use super::hub::{Hub, PendingBrowses, PendingDownload}; +use super::proto::{ + Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto, + DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, + DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, + MemberStats, Members, MessageParams, Messages, Method, OPENRPC, + 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}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Everything a connection needs to answer a request. +pub struct Daemon { + pub session: Arc, + /// Where this host keeps the account's password. The daemon owns the + /// login, so a password it changes it also has to store. + pub secrets: Arc, + pub hub: Arc, + pub browses: Arc, + pub downloads: Arc>>, + /// Where transfers land, on *this* machine. A caller may point it at a + /// different folder with `download.set_dir`, but never per download. + pub download_dir: Mutex, + pub use_username_dirs: bool, + pub server: String, + pub token: String, + pub started: Instant, + pub stop: Arc, + /// Connections currently being served, capped so an unauthenticated peer + /// cannot spawn threads without bound. + pub open: Arc, + /// Rooms this session has joined, so a restart can rejoin them. + pub rooms: Mutex>, + /// When each search went out. The window a search collects for belongs to + /// the client, so a second client can only tell a running search from a + /// finished one if the daemon remembers when it started. + pub searches: Mutex>, +} + +impl Daemon { + /// Answer one request. + /// + /// The match is exhaustive over [`Method`], so a new method cannot be added + /// to the protocol without being implemented here. + pub fn call( + &self, + method: Method, + params: Value, + ) -> Result { + match method { + // Handled during the handshake; reaching here means a second + // `auth` on an established connection, which is answered with the + // shape the contract publishes rather than a bare acknowledgement. + Method::Auth => ok(self.identify()), + Method::DaemonStatus => ok(self.status()), + Method::DaemonStop => { + self.stop.store(true, Ordering::Relaxed); + ok(Ack::OK) + } + Method::RpcDiscover => serde_json::from_str(OPENRPC).map_err(|e| { + RpcError::new( + CODE_APPLICATION, + format!("the contract is unreadable: {e}"), + ) + }), + + Method::SearchStart => { + let query: QueryParams = parse(params)?; + // Zero window: put the search on the wire and return. The + // caller decides how long to let answers accumulate, which is + // what keeps one client's search from blocking the daemon. + self.session + .search_with_cancel(&query.query, Duration::ZERO, None) + .map_err(|e| { + RpcError::application( + Exit::Connection, + format!("search failed: {e}"), + ) + })?; + self.record_search_start(&query.query); + ok(Ack::OK) + } + Method::SearchResults => { + let query: QueryParams = parse(params)?; + ok(SearchResults { + results: self + .session + .get_search_results(&query.query) + .iter() + .map(SearchResultDto::from) + .collect(), + }) + } + Method::SearchList => ok(Searches { + searches: self + .session + .all_searches() + .into_iter() + .map(|search| SearchSummary { + files: search.files, + started_secs_ago: self.search_age(&search.query), + query: search.query, + }) + .collect(), + }), + Method::SearchForget => { + let query: QueryParams = parse(params)?; + self.forget_search_start(&query.query); + ok(Ack { + ok: self.session.forget_search(&query.query), + }) + } + Method::SearchWishlist => { + let query: QueryParams = parse(params)?; + self.session.start_wishlist_search(&query.query).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; + // Without a start time every other window would render this + // search as finished the moment it goes out. + self.record_search_start(&query.query); + ok(Ack::OK) + } + Method::SearchWishlistInterval => ok(IntervalSeconds { + seconds: self.session.wishlist_interval().as_secs(), + }), + + Method::DownloadStart => self.start_download(parse(params)?), + Method::DownloadSetDir => { + let dir: DirectoryParams = parse(params)?; + self.set_download_dir(dir.directory)?; + ok(Ack::OK) + } + Method::DownloadList => ok(Downloads { + downloads: self + .session + .get_all_downloads() + .iter() + .map(DownloadDto::from) + .collect(), + }), + Method::DownloadPause => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self + .session + .pause_download(&transfer.username, &transfer.filename), + }) + } + Method::DownloadResume => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self.session.resume_download( + &transfer.username, + &transfer.filename, + ), + }) + } + Method::DownloadRemove => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self.session.remove_download( + &transfer.username, + &transfer.filename, + ), + }) + } + Method::DownloadRemoveQueued => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self.session.remove_queued_download( + &transfer.username, + &transfer.filename, + ), + }) + } + Method::DownloadCancel => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self.session.cancel_download( + &transfer.username, + &transfer.filename, + ), + }) + } + + Method::UploadList => ok(Uploads { + uploads: self + .session + .uploads() + .iter() + .map(UploadInfoDto::from) + .collect(), + }), + Method::UploadCancel => { + let transfer: TransferRef = parse(params)?; + ok(Ack { + ok: self + .session + .cancel_upload(&transfer.username, &transfer.filename), + }) + } + Method::UploadSlots => { + let slots: SlotsParams = parse(params)?; + self.session.set_upload_slots(slots.slots); + ok(Ack::OK) + } + + Method::PrivilegesCheck => { + self.session.check_privileges().map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + ok(Ack::OK) + } + Method::PrivilegesOwn => ok(Seconds { + seconds: self.session.own_privilege_seconds(), + }), + + Method::RoomListRequest => { + self.session.request_room_list().map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + ok(Ack::OK) + } + Method::RoomJoin => { + let room: RoomRef = parse(params)?; + self.session.join_room(&room.room).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + self.joined(&room.room, true); + ok(Ack::OK) + } + Method::RoomLeave => { + let room: RoomRef = parse(params)?; + self.session.leave_room(&room.room).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + self.joined(&room.room, false); + ok(Ack::OK) + } + Method::RoomSay => { + let say: SayParams = parse(params)?; + self.session.say_in_room(&say.room, &say.message).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; + ok(Ack::OK) + } + Method::RoomMembers => { + let room: RoomRef = parse(params)?; + ok(Members { + users: self.session.room_members(&room.room), + }) + } + + Method::RoomMemberStats => { + let room: RoomRef = parse(params)?; + ok(MemberStats { + members: self + .session + .room_member_stats(&room.room) + .iter() + .map(RoomUserStatsDto::from) + .collect(), + }) + } + + Method::MessageSend => { + let message: MessageParams = parse(params)?; + self.session + .send_private_message(&message.username, &message.message) + .map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + // Record our own half of the conversation: the drainer only + // ever sees incoming messages, so without this a client that + // attaches later reads a one-sided chat. + self.hub.remember(ChatMessageDto { + peer: message.username, + outgoing: true, + text: message.message, + at: now_seconds(), + }); + ok(Ack::OK) + } + Method::MessageHistory => ok(Messages { + messages: self.hub.history(), + }), + + Method::BrowseUser => { + let user: UserRef = parse(params)?; + // Register before asking: the answer can land on the very next + // drainer tick, and an unexpected listing is discarded. + self.browses.expect(&user.username); + if let Err(e) = self.session.browse_user(&user.username) { + self.browses.forget(&user.username); + return Err(RpcError::application( + Exit::Connection, + e.to_string(), + )); + } + ok(Ack::OK) + } + Method::UserRequest => { + let user: UserRef = parse(params)?; + self.session.request_user_info(&user.username).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; + ok(Ack::OK) + } + Method::UserInfoOf => { + let user: UserRef = parse(params)?; + ok(UserResult { + user: self + .session + .user_info(&user.username) + .as_ref() + .map(UserInfoDto::from), + }) + } + + Method::UserWatch => { + let user: UserRef = parse(params)?; + self.session.watch_user(&user.username).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + ok(Ack::OK) + } + Method::UserUnwatch => { + let user: UserRef = parse(params)?; + self.session.unwatch_user(&user.username).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + ok(Ack::OK) + } + Method::UserWatched => ok(Watched { + 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 + .set_shared_directories(directories.directories) + .map_err(|e| { + RpcError::application(Exit::Usage, e.to_string()) + })?; + ok(self.shares()) + } + Method::SharesReindex => { + self.session + .set_shared_directories(self.session.shared_directories()) + .map_err(|e| { + RpcError::application(Exit::Usage, e.to_string()) + })?; + ok(self.shares()) + } + } + } + + /// Track membership so a restart can rejoin what was open. The library + /// has no "which rooms am I in" accessor, so the daemon keeps the list. + fn joined(&self, room: &str, member: bool) { + let Ok(mut rooms) = self.rooms.lock() else { + return; + }; + rooms.retain(|name| name != room); + if member { + rooms.push(room.to_string()); + } + } + + #[must_use] + pub fn rooms(&self) -> Vec { + self.rooms + .lock() + .map_or_else(|_| Vec::new(), |rooms| rooms.clone()) + } + + fn start_download( + &self, + params: DownloadStartParams, + ) -> Result { + // The daemon's own directory, always. Letting a caller name a path + // per download would scatter transfers in ways `daemon.status` cannot + // report; changing the folder is a deliberate session-wide act, + // `download.set_dir`. + let directory = crate::directories::download_dir_for( + &self.download_dir(), + ¶ms.username, + self.use_username_dirs, + ); + let (download, updates) = self + .session + .download_with_metadata( + params.filename.clone(), + params.username.clone(), + params.size, + directory, + params.metadata.into(), + ) + .map_err(|e| { + RpcError::application( + Exit::Transfer, + format!("cannot start: {e}"), + ) + })?; + + if let Ok(mut pending) = self.downloads.lock() { + pending.push(PendingDownload { + username: params.username, + filename: params.filename, + updates, + }); + } + ok(DownloadStarted { + download: DownloadDto::from(&download), + }) + } + + fn download_dir(&self) -> String { + self.download_dir + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Point future transfers at `directory`, creating it on *this* host. + /// The same trust boundary as `shares.set`: authenticated means owner. + fn set_download_dir(&self, directory: String) -> Result<(), RpcError> { + if directory.trim().is_empty() { + return Err(RpcError::new( + CODE_INVALID_PARAMS, + "the download directory cannot be empty".to_string(), + )); + } + std::fs::create_dir_all(&directory).map_err(|e| { + RpcError::application( + Exit::Usage, + format!("cannot create {directory}: {e}"), + ) + })?; + *self + .download_dir + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = directory; + Ok(()) + } + + fn shares(&self) -> SharesStatus { + let (folders, files) = self.session.shared_counts(); + SharesStatus { + folders, + files, + directories: self.session.shared_directories(), + } + } + + /// How long ago a search went out, or a long time when we never saw it + /// start — a search restored or begun before this daemon did. + fn search_age(&self, query: &str) -> u64 { + self.searches + .lock() + .ok() + .and_then(|searches| { + searches.get(query).map(|at| at.elapsed().as_secs()) + }) + .unwrap_or(u64::MAX) + } + + fn record_search_start(&self, query: &str) { + if let Ok(mut searches) = self.searches.lock() { + searches.insert(query.to_string(), Instant::now()); + } + } + + fn forget_search_start(&self, query: &str) { + if let Ok(mut searches) = self.searches.lock() { + searches.remove(query); + } + } + + fn identify(&self) -> AuthResult { + AuthResult { + protocol: PROTOCOL_VERSION, + daemon_version: crate::cli::VERSION.to_string(), + username: self.session.username(), + } + } + + fn status(&self) -> DaemonStatus { + let (folders, files) = self.session.shared_counts(); + DaemonStatus { + username: self.session.username(), + server: self.server.clone(), + daemon_version: crate::cli::VERSION.to_string(), + protocol: PROTOCOL_VERSION, + listen_port: self.session.listen_port(), + shared_folders: folders, + shared_files: files, + download_dir: self.download_dir(), + session_loss: self.session.session_loss().map(Into::into), + clients: self.hub.subscribers(), + uptime_secs: self.started.elapsed().as_secs(), + } + } +} + +fn ok(value: T) -> Result { + serde_json::to_value(value).map_err(|e| { + RpcError::new( + CODE_APPLICATION, + format!("cannot serialize the reply: {e}"), + ) + }) +} + +fn parse(params: Value) -> Result { + serde_json::from_value(params) + .map_err(|e| RpcError::new(CODE_INVALID_PARAMS, e.to_string())) +} + +/// Wall-clock seconds, to stamp a message the way the server stamps the ones +/// it delivers. A clock before the epoch reads as zero rather than panicking. +fn now_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs() as i64) +} + +mod transport; + +#[cfg(unix)] +pub use transport::bind_unix; +pub use transport::{Listener, bind_tcp}; + +#[cfg(test)] +mod tests { + use super::super::proto::{CODE_METHOD_NOT_FOUND, CODE_PARSE}; + use super::transport::{accepts, answer, constant_time_eq}; + use super::*; + use serde_json::json; + use std::sync::mpsc::Receiver; + + #[test] + fn a_unix_socket_needs_no_token_but_tcp_does() { + let daemon = daemon_with_token("secret"); + assert!( + accepts(&daemon, true, None), + "socket permissions are the auth" + ); + assert!(!accepts(&daemon, false, None), "TCP must demand a token"); + } + + #[test] + fn a_wrong_token_is_refused_on_every_transport() { + let daemon = daemon_with_token("secret"); + assert!(!accepts(&daemon, false, Some("guess"))); + assert!( + !accepts(&daemon, true, Some("guess")), + "a token that is offered is always checked, even locally" + ); + assert!(accepts(&daemon, false, Some("secret"))); + } + + #[test] + fn token_comparison_does_not_short_circuit_on_length_or_content() { + assert!(constant_time_eq("abc", "abc")); + assert!(!constant_time_eq("abc", "abd")); + assert!(!constant_time_eq("abc", "abcd")); + assert!(!constant_time_eq("", "a")); + assert!(constant_time_eq("", "")); + } + + #[test] + fn an_unknown_method_is_reported_rather_than_ignored() { + let daemon = daemon_with_token("t"); + let response = answer( + r#"{"jsonrpc":"2.0","id":1,"method":"search.nope"}"#, + &daemon, + ) + .expect("a request with an id is always answered"); + let error = response.error.expect("an unknown method is an error"); + assert_eq!(error.code, CODE_METHOD_NOT_FOUND); + } + + #[test] + fn a_notification_gets_no_reply_even_when_it_fails() { + let daemon = daemon_with_token("t"); + assert!( + answer(r#"{"jsonrpc":"2.0","method":"search.nope"}"#, &daemon) + .is_none(), + "a request without an id must be answered with silence" + ); + } + + #[test] + fn unparseable_input_is_a_parse_error_not_a_dropped_connection() { + let daemon = daemon_with_token("t"); + let response = + answer("this is not json", &daemon).expect("garbage is answered"); + assert_eq!(response.error.expect("an error").code, CODE_PARSE); + } + + #[test] + fn bad_params_are_reported_as_such() { + let daemon = daemon_with_token("t"); + let error = daemon + .call(Method::RoomJoin, json!({ "not_a_room": 1 })) + .expect_err("the params do not match"); + assert_eq!(error.code, CODE_INVALID_PARAMS); + } + + #[test] + fn discover_serves_the_published_contract() { + let daemon = daemon_with_token("t"); + let document = daemon + .call(Method::RpcDiscover, Value::Null) + .expect("the contract is compiled in"); + assert_eq!(document["openrpc"], "1.3.2"); + assert!( + document["methods"] + .as_array() + .is_some_and(|methods| methods.len() + == Method::ALL.len() + + super::super::proto::Event::ALL.len()), + "discover must describe every method and event" + ); + } + + #[test] + fn member_stats_are_published_for_a_joined_room() { + let daemon = daemon_with_token("t"); + let stats = daemon + .call(Method::RoomMemberStats, json!({ "room": STUB_ROOM })) + .expect("member stats"); + assert_eq!(stats["members"][0]["username"], "alice"); + assert_eq!(stats["members"][0]["status"], "online"); + assert_eq!(stats["members"][0]["slots_full"], true); + assert_eq!(stats["members"][0]["country"], "NL"); + } + + #[test] + fn a_room_that_was_never_joined_has_no_member_stats() { + // Empty, not an error: asking about a room this session is not in is + // an ordinary thing for a client to do. + let daemon = daemon_with_token("t"); + let stats = daemon + .call(Method::RoomMemberStats, json!({ "room": "elsewhere" })) + .expect("member stats"); + 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` + // records, `user.watched` must report, and `user.unwatch` must undo. + let daemon = daemon_with_token("t"); + let user = |name: &str| json!({ "username": name }); + + daemon + .call(Method::UserWatch, user("alice")) + .expect("watch"); + daemon.call(Method::UserWatch, user("bob")).expect("watch"); + let watched = daemon + .call(Method::UserWatched, Value::Null) + .expect("watched"); + assert_eq!(watched["users"], json!(["alice", "bob"])); + + daemon + .call(Method::UserUnwatch, user("alice")) + .expect("unwatch"); + let watched = daemon + .call(Method::UserWatched, Value::Null) + .expect("watched"); + assert_eq!( + watched["users"], + json!(["bob"]), + "unwatching one must not clear the rest" + ); + } + + #[test] + fn watching_the_same_user_twice_lists_them_once() { + let daemon = daemon_with_token("t"); + let alice = json!({ "username": "alice" }); + daemon + .call(Method::UserWatch, alice.clone()) + .expect("watch"); + daemon.call(Method::UserWatch, alice).expect("watch"); + let watched = daemon + .call(Method::UserWatched, Value::Null) + .expect("watched"); + assert_eq!(watched["users"], json!(["alice"])); + } + + #[test] + fn stop_is_recorded_so_the_daemon_can_wind_down() { + let daemon = daemon_with_token("t"); + assert!(!daemon.stop.load(Ordering::Relaxed)); + daemon + .call(Method::DaemonStop, Value::Null) + .expect("stop is always accepted"); + assert!(daemon.stop.load(Ordering::Relaxed)); + } + + // A session that answers nothing: enough to exercise dispatch, parameter + // validation, and the auth gate without a Soulseek server. + /// A session that answers nothing, with two exceptions the methods under + /// test need: it remembers watches, so what one call records the next can + /// read back, and it describes the members of one room, so the stats + /// method can return something for a joined room and nothing for any + /// other. + #[derive(Default)] + struct SilentSession { + watched: Mutex>, + /// What `account.password` asked the session to set, if anything. + password: Mutex>, + } + + /// The one room `SilentSession` claims to have joined. + const STUB_ROOM: &str = "jazz"; + + fn daemon_with_token(token: &str) -> Daemon { + daemon_with(token, Arc::new(SilentSession::default())) + } + + /// A daemon over a session the caller can read back, with an in-memory + /// secret store: no test may touch the machine's keychain. + fn daemon_with(token: &str, session: Arc) -> Daemon { + Daemon { + session, + 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())), + download_dir: Mutex::new("/tmp".to_string()), + use_username_dirs: false, + server: "server.invalid:2416".to_string(), + token: token.to_string(), + started: Instant::now(), + stop: Arc::new(AtomicBool::new(false)), + open: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + rooms: Mutex::new(Vec::new()), + searches: Mutex::new(std::collections::HashMap::new()), + } + } + + impl SessionApi for SilentSession { + fn username(&self) -> String { + "tester".to_string() + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } + fn listen_port(&self) -> Option { + None + } + fn session_loss(&self) -> Option { + None + } + fn search( + &self, + _query: &str, + _timeout: Duration, + ) -> soulseek_rs::Result> { + Ok(Vec::new()) + } + fn search_with_cancel( + &self, + _query: &str, + _timeout: Duration, + _cancel: Option>, + ) -> soulseek_rs::Result> { + Ok(Vec::new()) + } + fn get_search_results( + &self, + _key: &str, + ) -> Vec { + Vec::new() + } + fn all_searches(&self) -> Vec { + Vec::new() + } + fn forget_search(&self, _query: &str) -> bool { + false + } + fn get_search_results_count(&self, _key: &str) -> usize { + 0 + } + fn try_get_search_results( + &self, + _key: &str, + ) -> Option> { + None + } + fn start_wishlist_search( + &self, + _query: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn wishlist_interval(&self) -> Duration { + Duration::from_mins(12) + } + fn download( + &self, + filename: String, + username: String, + size: u64, + directory: String, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + Receiver, + )> { + self.download_with_metadata( + filename, + username, + size, + directory, + soulseek_rs::types::DownloadMetadata::default(), + ) + } + fn download_with_metadata( + &self, + _filename: String, + _username: String, + _size: u64, + _directory: String, + _metadata: soulseek_rs::types::DownloadMetadata, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + Receiver, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn get_all_downloads(&self) -> Vec { + Vec::new() + } + fn pause_download(&self, _username: &str, _filename: &str) -> bool { + false + } + fn resume_download(&self, _username: &str, _filename: &str) -> bool { + false + } + fn remove_queued_download( + &self, + _username: &str, + _filename: &str, + ) -> bool { + false + } + fn remove_download(&self, _username: &str, _filename: &str) -> bool { + false + } + fn uploads(&self) -> Vec { + Vec::new() + } + fn take_upload_events(&self) -> Vec { + Vec::new() + } + fn cancel_upload(&self, _username: &str, _filename: &str) -> bool { + false + } + fn cancel_download(&self, _username: &str, _filename: &str) -> bool { + false + } + fn set_upload_slots(&self, _slots: usize) {} + fn check_privileges(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn own_privilege_seconds(&self) -> Option { + None + } + fn request_room_list(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn say_in_room( + &self, + _room: &str, + _message: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn room_members(&self, _room: &str) -> Vec { + Vec::new() + } + fn room_member_stats( + &self, + room: &str, + ) -> Vec { + if room != STUB_ROOM { + return Vec::new(); + } + vec![soulseek_rs::types::RoomUserStats { + username: "alice".to_string(), + status: soulseek_rs::types::UserStatus::Online, + average_speed: 1024, + shared_files: 20, + shared_folders: 3, + slots_full: true, + country: Some("NL".to_string()), + }] + } + fn take_room_events(&self) -> Vec { + Vec::new() + } + fn send_private_message( + &self, + _username: &str, + _message: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_private_messages(&self) -> Vec { + Vec::new() + } + fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_browse_result( + &self, + _username: &str, + ) -> Option> { + None + } + fn request_user_info( + &self, + _username: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watch_user(&self, username: &str) -> soulseek_rs::Result<()> { + let mut watched = self.watched.lock().expect("watch list"); + if !watched.iter().any(|u| u == username) { + watched.push(username.to_string()); + watched.sort(); + } + Ok(()) + } + fn unwatch_user(&self, username: &str) -> soulseek_rs::Result<()> { + self.watched + .lock() + .expect("watch list") + .retain(|u| u != username); + Ok(()) + } + fn watched_users(&self) -> Vec { + self.watched.lock().expect("watch list").clone() + } + fn user_info(&self, _username: &str) -> Option { + None + } + fn shared_counts(&self) -> (u32, u32) { + (0, 0) + } + fn shared_listing(&self) -> Vec { + Vec::new() + } + fn shared_directories(&self) -> Vec { + Vec::new() + } + fn set_shared_directories( + &self, + _directories: Vec, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn set_download_directory( + &self, + _directory: String, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + } +} diff --git a/soulseek-rs/src/daemon/server/transport.rs b/soulseek-rs/src/daemon/server/transport.rs new file mode 100644 index 0000000..b9ecd00 --- /dev/null +++ b/soulseek-rs/src/daemon/server/transport.rs @@ -0,0 +1,467 @@ +//! Getting bytes to and from a control client: who may connect, how a request +//! is framed, and how replies and pushed events share one socket. +//! +//! One thread per connection, which is noise next to the thousand-odd peer +//! threads the client already runs. Each connection owns a writer thread fed by +//! a channel, so request replies and events interleave without a lock and +//! neither can block the other. + +use super::super::proto::{ + AuthParams, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE, + Method, Request, Response, RpcError, +}; +use super::Daemon; +use crate::output::Exit; +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::time::Duration; + +/// How long a failed authentication costs the caller. Not a serious defence — +/// the token is 256 bits — but it keeps a wrong-token loop off the CPU. +const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1); + +/// Replies waiting to go out on one connection. Bounded, so a client that has +/// stopped reading cannot make the daemon buffer without limit. +const WRITE_QUEUE_DEPTH: usize = 1024; + +/// The longest request line the daemon will read. Generous next to the largest +/// real request (a `shares.set` naming every folder) and small enough that an +/// unauthenticated peer cannot make the daemon allocate without bound. +const MAX_LINE: u64 = 1 << 20; + +/// How long an unauthenticated connection may stay silent. Applies only until +/// `auth` succeeds — an established client is expected to idle, holding its +/// connection open for events without sending anything. +const AUTH_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long to wait before accepting again after a failure. +const ACCEPT_BACKOFF: Duration = Duration::from_millis(100); + +/// How many control connections may be open at once. Far above any real use +/// (a TUI, a few scripts, an agent), and low enough that an unauthenticated +/// peer cannot exhaust the daemon's threads. +const MAX_CONNECTIONS: usize = 64; + +/// Where control connections arrive. +/// +/// The Unix socket is the local path — its file permissions are the +/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in +/// and always needs the token. +pub enum Listener { + #[cfg(unix)] + Unix(std::os::unix::net::UnixListener), + Tcp(TcpListener), +} + +pub enum Stream { + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), + Tcp(TcpStream), +} + +impl Stream { + fn try_clone(&self) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.try_clone().map(Self::Unix), + Self::Tcp(stream) => stream.try_clone().map(Self::Tcp), + } + } + + fn set_read_timeout(&self, timeout: Option) { + let _ = match self { + #[cfg(unix)] + Self::Unix(stream) => stream.set_read_timeout(timeout), + Self::Tcp(stream) => stream.set_read_timeout(timeout), + }; + } + + /// End the connection from another thread. This is what makes "a client + /// that stops reading is disconnected" true rather than aspirational: the + /// hub can only reach a stalled client by closing its socket. + pub(super) fn shutdown(&self) { + let _ = match self { + #[cfg(unix)] + Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both), + Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both), + }; + } + + /// Whether the transport itself proves who is calling. + const fn trusted_by_transport(&self) -> bool { + match self { + #[cfg(unix)] + Self::Unix(_) => true, + Self::Tcp(_) => false, + } + } +} + +impl std::io::Read for Stream { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.read(buffer), + Self::Tcp(stream) => stream.read(buffer), + } + } +} + +impl Write for Stream { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.write(buffer), + Self::Tcp(stream) => stream.write(buffer), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.flush(), + Self::Tcp(stream) => stream.flush(), + } + } +} + +impl Listener { + /// Accept forever, handing each connection to its own thread. + pub fn serve(self, daemon: Arc) { + loop { + match self.accept() { + Ok(stream) => spawn_connection(stream, Arc::clone(&daemon)), + Err(e) => { + soulseek_rs::warn!("control accept failed: {e}"); + // Out of file descriptors does not clear by the next + // instruction; retrying flat out spins a core. + std::thread::sleep(ACCEPT_BACKOFF); + } + } + } + } + + fn accept(&self) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(listener) => { + listener.accept().map(|(stream, _)| Stream::Unix(stream)) + } + Self::Tcp(listener) => { + listener.accept().map(|(stream, _)| Stream::Tcp(stream)) + } + } + } +} + +fn spawn_connection(stream: Stream, daemon: Arc) { + let open = daemon.open.fetch_add(1, Ordering::Relaxed) + 1; + if open > MAX_CONNECTIONS { + daemon.open.fetch_sub(1, Ordering::Relaxed); + soulseek_rs::warn!( + "refusing a control connection: {MAX_CONNECTIONS} already open. \ + This is a leak unless you really have that many clients — please \ + report it with `soulseek-rs daemon status`." + ); + stream.shutdown(); + return; + } + + // A handle kept back so the connection can be closed if it never gets a + // thread to serve it. + let refused = stream.try_clone(); + let slots = Arc::clone(&daemon.open); + + // `Builder` rather than `thread::spawn`, which panics when the OS will not + // give us a thread. That panic would unwind through the accept loop and + // take the listener with it, so one exhausted moment on a busy client + // would end control for good. Refusing this one connection recovers. + let spawned = std::thread::Builder::new() + .name("soulseek-control".to_string()) + .spawn(move || { + if let Err(e) = handle(stream, daemon.as_ref()) { + // Loud, not debug: a client sees only "the connection closed", + // so this line is the only account of why it did. + soulseek_rs::warn!( + "control connection dropped before it could be served: {e}" + ); + } + daemon.open.fetch_sub(1, Ordering::Relaxed); + }); + + if let Err(e) = spawned { + soulseek_rs::warn!("cannot serve a control connection: {e}"); + if let Ok(refused) = refused { + refused.shutdown(); + } + slots.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Serve one connection: authenticate, then answer requests until it closes. +fn handle(stream: Stream, daemon: &Daemon) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let trusted = stream.trusted_by_transport(); + let closer = stream.try_clone()?; + + // A peer that connects and says nothing must not hold a thread forever. + stream.set_read_timeout(Some(AUTH_TIMEOUT)); + + let (out, outbox) = sync_channel::(WRITE_QUEUE_DEPTH); + let writer = std::thread::spawn(move || write_loop(stream, &outbox)); + + if !authenticate(&mut reader, daemon, trusted, &out) { + // Let the writer flush the refusal before closing: shutting the socket + // first discards the reply, and the caller learns only that the + // connection vanished — which is indistinguishable from the daemon + // having crashed. + drop(out); + let _ = writer.join(); + closer.shutdown(); + return Ok(()); + } + // An established client is expected to idle, holding the connection open + // for events without sending anything. + closer.set_read_timeout(None); + + // Only now does this connection get to see events: an unauthenticated + // socket must learn nothing at all. + let (subscription, events) = { + let closer = closer.try_clone()?; + daemon.hub.subscribe(move || closer.shutdown()) + }; + let pump = { + let out = out.clone(); + std::thread::spawn(move || { + for event in events { + if out.send(event).is_err() { + break; + } + } + }) + }; + + // Requests are answered on this thread, in order. A method slow enough to + // matter (re-indexing a large share) delays only the connection that asked + // for it, which is the right person to wait — and every other client has a + // connection of its own. Spawning per request would instead churn a thread + // for each of the handful of polls a TUI makes every frame. + // + // Errors break rather than propagate: returning here would skip the + // unsubscribe below and leak a hub subscriber plus two threads for the + // life of the daemon. + loop { + match read_line(&mut reader) { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + if let Some(response) = answer(&line, daemon) + && out.send(response).is_err() + { + break; + } + } + Ok(None) => break, + Err(e) => { + soulseek_rs::debug!("control connection ended: {e}"); + break; + } + } + } + + daemon.hub.unsubscribe(subscription); + drop(out); + let _ = pump.join(); + let _ = writer.join(); + closer.shutdown(); + Ok(()) +} + +/// Read one newline-delimited request, refusing a line long enough to be an +/// attack rather than a request. `Ok(None)` means the peer hung up. +fn read_line( + reader: &mut BufReader, +) -> std::io::Result> { + let mut line = String::new(); + let read = reader.by_ref().take(MAX_LINE).read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if read as u64 == MAX_LINE && !line.ends_with('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request line exceeds the maximum size", + )); + } + Ok(Some(line)) +} + +/// The first request has to be `auth`, and has to be good. +fn authenticate( + reader: &mut BufReader, + daemon: &Daemon, + trusted: bool, + out: &SyncSender, +) -> bool { + let Ok(Some(line)) = read_line(reader) else { + return false; + }; + let request: Request = match serde_json::from_str(&line) { + Ok(request) => request, + Err(e) => { + let _ = out.send(Response::failure( + None, + RpcError::new(CODE_PARSE, e.to_string()), + )); + return false; + } + }; + + if Method::from_wire(&request.method) != Some(Method::Auth) { + let _ = out.send(Response::failure( + request.id, + RpcError::new( + CODE_INVALID_REQUEST, + "the first request on a connection must be `auth`", + ), + )); + return false; + } + + let params: AuthParams = + serde_json::from_value(request.params).unwrap_or_default(); + if !accepts(daemon, trusted, params.token.as_deref()) { + std::thread::sleep(AUTH_FAILURE_DELAY); + let _ = out.send(Response::failure( + request.id, + RpcError::application(Exit::Connection, "authentication failed"), + )); + return false; + } + + let result = serde_json::to_value(daemon.identify()).unwrap_or(Value::Null); + let _ = out.send(Response::result(request.id, result)); + true +} + +/// A Unix socket has already proved the caller is this user, so a token is +/// optional there and mandatory everywhere else. A token that *is* offered is +/// always checked, so a wrong one never passes. +pub(super) fn accepts( + daemon: &Daemon, + trusted: bool, + token: Option<&str>, +) -> bool { + match token { + Some(token) => constant_time_eq(token, &daemon.token), + None => trusted, + } +} + +/// Compare without leaking the position of the first difference through timing. +pub(super) fn constant_time_eq(left: &str, right: &str) -> bool { + if left.len() != right.len() { + return false; + } + left.bytes() + .zip(right.bytes()) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +/// Turn one request line into the reply it deserves, or `None` for a +/// notification, which by JSON-RPC rule is answered with silence. +pub(super) fn answer(line: &str, daemon: &Daemon) -> Option { + let request: Request = match serde_json::from_str(line) { + Ok(request) => request, + Err(e) => { + return Some(Response::failure( + None, + RpcError::new(CODE_PARSE, e.to_string()), + )); + } + }; + let id = request.id.clone(); + let Some(method) = Method::from_wire(&request.method) else { + return id.is_some().then(|| { + Response::failure( + request.id, + RpcError::new( + CODE_METHOD_NOT_FOUND, + format!("no such method: {}", request.method), + ), + ) + }); + }; + + let outcome = daemon.call(method, request.params); + id.map(|id| match outcome { + Ok(result) => Response::result(Some(id), result), + Err(error) => Response::failure(Some(id), error), + }) +} + +fn write_loop(stream: Stream, outbox: &Receiver) { + let mut stream = stream; + for response in outbox { + let Ok(line) = serde_json::to_string(&response) else { + continue; + }; + if writeln!(stream, "{line}").is_err() || stream.flush().is_err() { + return; + } + } +} + +/// Bind the TCP control port, refusing to come up rather than silently +/// listening somewhere unintended. +pub fn bind_tcp(address: &str) -> std::io::Result { + TcpListener::bind(address).map(Listener::Tcp) +} + +/// Bind the Unix control socket with permissions that make it the local +/// authentication: only this user can open it. +/// +/// Bound under a temporary name and moved into place, because `bind` creates +/// the socket with the process umask — for the moment between `bind` and +/// `set_permissions` the real path would be connectable by anyone on the +/// machine, and on this transport connecting *is* authenticating. The rename +/// is atomic, so the published path never exists with the wrong mode. +#[cfg(unix)] +pub fn bind_unix(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::PermissionsExt; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // A socket file outlives the process that made it, so a previous crash + // would otherwise make the daemon unstartable. + if path.exists() { + remove_stale_socket(path)?; + } + + let staging = path.with_extension("binding"); + let _ = std::fs::remove_file(&staging); + let listener = std::os::unix::net::UnixListener::bind(&staging)?; + std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o600))?; + std::fs::rename(&staging, path)?; + Ok(Listener::Unix(listener)) +} + +/// Remove a socket file left by a dead daemon — but never one a live daemon is +/// still serving, which would silently steal its clients. +#[cfg(unix)] +fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> { + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!("a daemon is already listening on {}", path.display()), + )), + Err(_) => std::fs::remove_file(path), + } +} diff --git a/soulseek-rs/src/directories.rs b/soulseek-rs/src/directories.rs new file mode 100644 index 0000000..3130846 --- /dev/null +++ b/soulseek-rs/src/directories.rs @@ -0,0 +1,164 @@ +//! Resolution and validation of the configured download / shared directories. + +use soulseek_rs::utils::path::expand_tilde; +use std::path::PathBuf; + +/// Validate and normalize an optional shared-directory setting. +/// +/// Returns `Ok(None)` when nothing is shared (unset or blank), `Ok(Some(path))` +/// for a `~`-expanded, existing directory, or `Err` with a human-readable +/// message when the configured path is not a usable directory. +pub fn resolve_shared_directory( + raw: Option<&str>, +) -> Result, String> { + let Some(raw) = raw else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let path = expand_tilde(trimmed); + if !path.exists() { + return Err(format!( + "shared directory does not exist: {}", + path.display() + )); + } + if !path.is_dir() { + return Err(format!( + "shared path is not a directory: {}", + path.display() + )); + } + Ok(Some(path)) +} + +/// Validate several configured share paths; invalid ones are skipped with a +/// warning instead of aborting (the client just shares less). +#[must_use] +pub fn resolve_shared_directories(dirs: &[String]) -> Vec { + let mut resolved = Vec::new(); + for dir in dirs { + match resolve_shared_directory(Some(dir)) { + Ok(Some(path)) => resolved.push(path.display().to_string()), + Ok(None) => {} + Err(e) => eprintln!("⚠️ Ignoring shared directory: {e}"), + } + } + resolved +} + +/// The folder a download from `username` lands in: `download_dir` itself, or +/// a per-user subfolder of it when `per_user` is set, as SoulseekQt does. +#[must_use] +pub fn download_dir_for( + download_dir: &str, + username: &str, + per_user: bool, +) -> String { + if !per_user { + return download_dir.to_string(); + } + std::path::Path::new(download_dir) + .join(sanitize_path_component(username)) + .display() + .to_string() +} + +/// A peer-chosen name made safe as one path component: separators and +/// characters Windows refuses become `_`, so `../..` cannot climb out. +fn sanitize_path_component(name: &str) -> String { + let mut out: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + if out.is_empty() || out.ends_with(['.', ' ']) { + out.push('_'); + } + if is_reserved(&out) { + out.insert(0, '_'); + } + out +} + +/// Windows device names (`CON`, `COM1`, …) that cannot be a folder name. +fn is_reserved(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (matches!(stem.get(..3), Some("COM" | "LPT")) + && matches!(stem.as_bytes().get(3..), Some([b'1'..=b'9']))) +} + +#[cfg(test)] +mod tests { + use super::{download_dir_for, resolve_shared_directory}; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn unique_temp_dir() -> std::path::PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir() + .join(format!("soulseek-rs-dirtest-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn unset_or_blank_shares_nothing() { + assert_eq!(resolve_shared_directory(None), Ok(None)); + assert_eq!(resolve_shared_directory(Some("")), Ok(None)); + assert_eq!(resolve_shared_directory(Some(" ")), Ok(None)); + } + + #[test] + fn existing_directory_is_accepted() { + let dir = unique_temp_dir(); + let resolved = + resolve_shared_directory(Some(dir.to_str().unwrap())).unwrap(); + assert_eq!(resolved, Some(dir.clone())); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_directory_is_rejected() { + let missing = std::env::temp_dir().join("soulseek-rs-does-not-exist-x"); + let _ = std::fs::remove_dir_all(&missing); + let result = resolve_shared_directory(Some(missing.to_str().unwrap())); + assert!(result.is_err()); + } + + #[test] + fn a_file_is_not_a_valid_shared_directory() { + let dir = unique_temp_dir(); + let file = dir.join("not-a-dir.txt"); + std::fs::write(&file, b"x").unwrap(); + let result = resolve_shared_directory(Some(file.to_str().unwrap())); + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + fn dl(name: &str) -> String { + std::path::Path::new("/dl").join(name).display().to_string() + } + + #[test] + fn per_user_folder_is_a_child_of_the_download_dir() { + assert_eq!(download_dir_for("/dl", "alice", false), "/dl"); + assert_eq!(download_dir_for("/dl", "alice", true), dl("alice")); + } + + #[test] + fn a_hostile_username_cannot_leave_the_download_dir() { + assert_eq!(download_dir_for("/dl", "../..", true), dl(".._.._")); + assert_eq!(download_dir_for("/dl", "/tmp/x", true), dl("_tmp_x")); + assert_eq!(download_dir_for("/dl", "..", true), dl(".._")); + assert_eq!(download_dir_for("/dl", "", true), dl("_")); + assert_eq!(download_dir_for("/dl", "com1", true), dl("_com1")); + } +} diff --git a/soulseek-rs/src/lib.rs b/soulseek-rs/src/lib.rs new file mode 100644 index 0000000..f09c0c3 --- /dev/null +++ b/soulseek-rs/src/lib.rs @@ -0,0 +1,22 @@ +//! The program itself. `main.rs` is a thin entry point over this crate, so +//! every module is compiled once and integration tests drive the same code the +//! binary runs. + +pub mod api; +pub mod cli; +pub mod commands; +pub mod daemon; +pub(crate) mod directories; +pub mod models; +pub mod output; +pub mod persist; +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; diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs new file mode 100644 index 0000000..1aec4fe --- /dev/null +++ b/soulseek-rs/src/main.rs @@ -0,0 +1,23 @@ +//! Entry point. Everything it does lives in the library crate, so the same +//! code is what integration tests drive. + +use soulseek_rs_tui::output::{Exit, Out}; +use soulseek_rs_tui::run; +use std::process::ExitCode; + +fn main() -> ExitCode { + dotenv::dotenv().ok(); + let _ = color_eyre::install(); + + let cli = ::parse(); + run::init_logging(&cli); + + let out = Out::new(cli.json, cli.quiet); + match run::run(cli, &out) { + Ok(()) => Exit::Ok.into(), + Err(error) => { + out.error(&error.message); + error.exit.into() + } + } +} diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs new file mode 100644 index 0000000..a97f16e --- /dev/null +++ b/soulseek-rs/src/models/app_state.rs @@ -0,0 +1,753 @@ +use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; +use chrono::{DateTime, Local}; +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}; +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SearchStatus { + Active, + Completed, +} + +pub struct SearchEntry { + pub query: String, + pub status: SearchStatus, + pub results: Vec, + /// What the session says this search has found, for a window that has not + /// fetched the results itself. Fetching every search every frame is what + /// a shared cache would otherwise cost. + pub known_files: usize, + /// Whether this window started the search. Its own worker knows when it + /// finishes; for the rest the session is the authority. + pub owned: bool, + pub start_time: Instant, + pub cancel_flag: Arc, +} + +pub struct DownloadEntry { + pub download: Download, + pub receiver: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusedPane { + Searches, + Results, + Downloads, +} + +impl FocusedPane { + /// Every focusable pane, in the order `Tab` walks them: the number each + /// carries in its legend, which is also declaration order. + pub const ALL: [Self; 3] = [Self::Searches, Self::Results, Self::Downloads]; + + /// Its position in [`Self::ALL`]. + const fn index(self) -> usize { + self as usize + } +} + +/// Which panes are on screen. A hidden pane gives its space to the others; a +/// zoomed one takes the whole content area for itself, so `Tab` through the +/// panes while zoomed reads as switching windows. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PaneLayout { + hidden: [bool; FocusedPane::ALL.len()], + pub zoomed: bool, + /// Cell height dragged onto the divider between Results and the row + /// underneath. `None` keeps the default proportions. + pub top_height: Option, + /// Cell widths dragged onto the dividers in the bottom row. `None` keeps + /// the default proportions; Info, which has no divider of its own, takes + /// whatever the row has left over. + pub searches_width: Option, + pub downloads_width: Option, +} + +impl PaneLayout { + #[must_use] + pub const fn is_visible(&self, pane: FocusedPane) -> bool { + !self.hidden[pane.index()] + } + + /// The panes still on screen, in `Tab` order. + pub fn visible(&self) -> impl Iterator + '_ { + FocusedPane::ALL + .into_iter() + .filter(move |pane| self.is_visible(*pane)) + } + + /// Take `pane` off the screen. Refuses when it is the last one showing — + /// an empty window has nothing to press a key in — and says so. + pub fn hide(&mut self, pane: FocusedPane) -> bool { + if self.visible().count() <= 1 { + return false; + } + self.hidden[pane.index()] = true; + true + } + + pub const fn show(&mut self, pane: FocusedPane) { + self.hidden[pane.index()] = false; + } + + /// The visible pane after (or before) `from`, wrapping around. `from` + /// itself may already be hidden, which is how focus leaves a pane that + /// was just hidden. + #[must_use] + pub fn neighbour(&self, from: FocusedPane, forward: bool) -> FocusedPane { + let count = FocusedPane::ALL.len(); + let step = if forward { 1 } else { count - 1 }; + let mut index = from.index(); + for _ in 0..count { + index = (index + step) % count; + let candidate = FocusedPane::ALL[index]; + if self.is_visible(candidate) { + return candidate; + } + } + from + } +} + +/// Where a scrolling log is being read. +/// +/// Either following its newest line, or held at a row of its history so +/// that arriving messages do not push the reader along. The renderer records +/// the log's size at each draw, which is what lets the keys move by a +/// screenful and stop at the ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LogView { + /// The first visible row while held in the history; `None` follows the + /// tail. + top: Option, + /// Rows the log had, and rows the window showed, at the last draw. + rows: usize, + height: usize, +} + +impl LogView { + /// Whether the newest line is on screen, so new messages are being seen. + #[must_use] + pub const fn following(&self) -> bool { + self.top.is_none() + } + + /// Rows a page key moves: what the window showed last time. + #[must_use] + pub const fn page(&self) -> usize { + self.height + } + + const fn last_top(&self) -> usize { + self.rows.saturating_sub(self.height) + } + + /// The rows of a log `len` long to draw in a window `height` tall. A + /// held position that has reached the tail goes back to following it. + pub fn window( + &mut self, + len: usize, + height: usize, + ) -> std::ops::Range { + self.rows = len; + self.height = height.max(1); + let top = match self.top { + Some(top) if top < self.last_top() => top, + _ => { + self.top = None; + self.last_top() + } + }; + top..(top + self.height).min(len) + } + + /// Move the window `rows` down the log (up, when negative), holding + /// wherever it lands unless that is the tail. + pub fn scroll(&mut self, rows: isize) { + let current = self.top.unwrap_or_else(|| self.last_top()); + let next = current.saturating_add_signed(rows); + self.top = (next < self.last_top()).then_some(next); + } + + pub const fn to_oldest(&mut self) { + self.top = Some(0); + } + + pub const fn to_newest(&mut self) { + self.top = None; + } +} + +/// 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>, +} + +impl WrappedLog { + /// The rows for `lines`, `wrap` turning one line into its rows (none, + /// for a line a filter drops). + pub fn rows( + &mut self, + width: usize, + key: &str, + lines: &[T], + wrap: impl Fn(&T) -> Vec>, + ) -> &[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 { + Search, + Message, + Browse, +} + +/// Direction of a private message relative to the local user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageDirection { + Incoming, + Outgoing, +} + +/// A private message shown in the chat popup. +pub struct ChatMessage { + pub direction: MessageDirection, + /// The other party: the sender for incoming, the recipient for outgoing. + pub peer: String, + pub text: String, + /// Local wall-clock time the message was sent or received. + pub at: DateTime, +} + +#[allow(clippy::struct_excessive_bools)] +pub struct AppState { + // Searches + pub searches: Vec, + pub searches_table_state: TableState, + pub selected_search_index: Option, + /// Cells the query column is scrolled to the left. + pub searches_query_offset: usize, + + // Results + pub results_items: Vec, + pub results_filtered_items: Vec, + pub results_filtered_indices: Vec, + pub results_table_state: TableState, + pub results_selected_indices: std::collections::HashSet, + pub results_filter_query: String, + pub results_is_filtering: bool, + /// Cells the file-name column is scrolled to the left. + pub results_name_offset: usize, + + // Downloads + pub downloads: Vec, + pub downloads_table_state: TableState, + /// Cells the transfers' file-name column is scrolled to the left. + pub downloads_name_offset: usize, + pub downloads_receiver_channel: + Option)>>, + pub downloads_sender_channel: + Option)>>, + pub active_downloads_count: usize, + + // UI State + pub focused_pane: FocusedPane, + pub layout: PaneLayout, + /// The keys overlay (`?`) is open. + pub show_help: bool, + /// Where the keys overlay is being read, for a terminal too short to + /// show it whole. + pub help_view: LogView, + /// Set once, when the window is closing, to why. + pub exit: Option, + pub command_bar_active: bool, + pub command_bar_input: String, + pub command_bar_cursor_position: usize, + pub command_bar_mode: CommandBarMode, + + // Private messages + pub messages: Vec, + pub show_messages: bool, + /// Incoming private messages received while the inbox was closed. + pub unread_messages: usize, + /// Conversation the chat popup is pinned to; unset follows the newest. + pub chat_peer: Option, + /// 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, + + // Browse users' shared files (one tab per user) + pub browse: BrowseTabs, + pub show_browse: bool, + pub browse_table_state: TableState, + + // Chat rooms + pub rooms: RoomsState, + pub show_rooms: bool, + pub rooms_list_table_state: TableState, + + // Settings popup (download folder + share paths) + pub settings: Option, + + // Uploads we are serving (refreshed from the client every tick) + pub uploads: Vec, + + // Pane areas for mouse interaction + pub searches_pane_area: Option, + pub results_pane_area: Option, + pub downloads_pane_area: Option, + /// The whole area the panes share, and the bottom row's part of it, + /// recorded at each draw so a divider drag maps to cells. + pub content_area: Option, + pub row_area: Option, + pub info_pane_area: Option, + /// The border rows a mouse can grab to resize: the one Results sits on + /// and the seam at the right of each row pane. + pub hsplit_divider: Option, + pub vsplit_dividers: Vec<(FocusedPane, Rect)>, + /// Where the open popup was last drawn, for its page size. Only one is + /// ever open, so one record serves them all. + pub popup_area: Option, +} + +impl AppState { + #[must_use] + pub fn new() -> Self { + let mut searches_table_state = TableState::default(); + searches_table_state.select(Some(0)); + + let mut results_table_state = TableState::default(); + results_table_state.select(Some(0)); + + let mut downloads_table_state = TableState::default(); + downloads_table_state.select(Some(0)); + + Self { + searches: Vec::new(), + searches_table_state, + selected_search_index: None, + searches_query_offset: 0, + + results_items: Vec::new(), + results_filtered_items: Vec::new(), + results_filtered_indices: Vec::new(), + results_table_state, + results_selected_indices: std::collections::HashSet::new(), + results_filter_query: String::new(), + results_is_filtering: false, + results_name_offset: 0, + + downloads: Vec::new(), + downloads_table_state, + downloads_name_offset: 0, + downloads_receiver_channel: None, + downloads_sender_channel: None, + active_downloads_count: 0, + + focused_pane: FocusedPane::Searches, + layout: PaneLayout::default(), + show_help: false, + help_view: LogView::default(), + exit: None, + command_bar_active: false, + command_bar_input: String::new(), + command_bar_cursor_position: 0, + command_bar_mode: CommandBarMode::Search, + + messages: Vec::new(), + show_messages: false, + unread_messages: 0, + 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(), + show_browse: false, + browse_table_state: TableState::default(), + + rooms: RoomsState::new(), + show_rooms: false, + rooms_list_table_state: TableState::default(), + + settings: None, + + uploads: Vec::new(), + + 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, + } + } + + /// How far the focused list's long column is scrolled sideways. + pub const fn name_offset_mut(&mut self, pane: FocusedPane) -> &mut usize { + match pane { + FocusedPane::Searches => &mut self.searches_query_offset, + FocusedPane::Results => &mut self.results_name_offset, + FocusedPane::Downloads => &mut self.downloads_name_offset, + } + } + + /// Where `pane` was last drawn; `None` while it is hidden. + #[must_use] + pub const fn pane_area(&self, pane: FocusedPane) -> Option { + match pane { + FocusedPane::Searches => self.searches_pane_area, + FocusedPane::Results => self.results_pane_area, + FocusedPane::Downloads => self.downloads_pane_area, + } + } + + /// Put the focus on `pane`, bringing it back on screen if it was hidden: + /// pressing a pane's number is also how it comes back. + pub const fn focus_pane(&mut self, pane: FocusedPane) { + self.layout.show(pane); + self.focused_pane = pane; + } + + /// Move the focus to the next (or previous) visible pane. + pub fn cycle_focus(&mut self, forward: bool) { + self.focused_pane = self.layout.neighbour(self.focused_pane, forward); + } + + /// Hide the focused pane and move the focus to its neighbour. The last + /// visible pane stays; there would be nowhere for the focus to go. + pub fn hide_focused_pane(&mut self) { + let pane = self.focused_pane; + if self.layout.hide(pane) { + self.focused_pane = self.layout.neighbour(pane, true); + } + } + + pub const fn toggle_zoom(&mut self) { + self.layout.zoomed = !self.layout.zoomed; + } + + /// Open the chat popup on a conversation with `peer`, ready to type. + pub fn open_chat(&mut self, peer: String) { + self.chat_peer = Some(peer); + self.chat_input.clear(); + self.chat_view.to_newest(); + self.chat_composing = true; + self.show_messages = true; + self.unread_messages = 0; + } + + /// The conversation the chat popup shows: the pinned peer, else whoever + /// was last talked to. + #[must_use] + pub fn active_chat_peer(&self) -> Option<&str> { + self.chat_peer + .as_deref() + .or_else(|| self.messages.last().map(|m| m.peer.as_str())) + } + + /// Conversation partners in first-contact order. Rescans every + /// message per frame; an inbox is never big enough to care. + #[must_use] + pub fn chat_peers(&self) -> Vec<&str> { + let mut peers: Vec<&str> = Vec::new(); + for message in &self.messages { + if !peers.contains(&message.peer.as_str()) { + peers.push(&message.peer); + } + } + // A conversation opened but not yet written to has no messages. + if let Some(peer) = self.chat_peer.as_deref() + && !peers.contains(&peer) + { + peers.push(peer); + } + peers + } + + /// Pin the chat popup to the next (or previous) conversation. + pub fn cycle_chat_peer(&mut self, forward: bool) { + let peers = self.chat_peers(); + if peers.is_empty() { + return; + } + let current = self + .active_chat_peer() + .and_then(|active| peers.iter().position(|p| *p == active)) + .unwrap_or(0); + let next = if forward { + (current + 1) % peers.len() + } else { + (current + peers.len() - 1) % peers.len() + }; + let peer = peers[next].to_string(); + // A different conversation opens at its end; the same one stays put. + if next != current { + self.chat_view.to_newest(); + } + self.chat_peer = Some(peer); + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn say(state: &mut AppState, peer: &str) { + state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: peer.to_string(), + text: "hi".to_string(), + at: Local::now(), + }); + } + + #[test] + fn chat_defaults_to_the_newest_conversation_and_cycles() { + let mut state = AppState::new(); + assert_eq!(state.active_chat_peer(), None); + + say(&mut state, "alice"); + say(&mut state, "bob"); + say(&mut state, "alice"); + assert_eq!(state.chat_peers(), vec!["alice", "bob"]); + assert_eq!(state.active_chat_peer(), Some("alice")); + + state.cycle_chat_peer(true); + assert_eq!(state.active_chat_peer(), Some("bob")); + state.cycle_chat_peer(true); // wraps + assert_eq!(state.active_chat_peer(), Some("alice")); + state.cycle_chat_peer(false); + assert_eq!(state.active_chat_peer(), Some("bob")); + } + + fn visible(layout: &PaneLayout) -> Vec { + 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(); + assert_eq!(visible(&state.layout), FocusedPane::ALL); + assert!(!state.layout.zoomed); + assert!(!state.show_help); + } + + #[test] + fn tab_cycles_the_focus_through_the_visible_panes_and_wraps() { + let mut state = AppState::new(); + assert_eq!(state.focused_pane, FocusedPane::Searches); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Results); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Searches, "wraps"); + state.cycle_focus(false); + assert_eq!(state.focused_pane, FocusedPane::Downloads, "and back"); + } + + #[test] + fn hiding_the_focused_pane_moves_the_focus_past_it() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Results); + state.hide_focused_pane(); + assert_eq!( + visible(&state.layout), + [FocusedPane::Searches, FocusedPane::Downloads] + ); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + + // Tab now skips the hidden pane in both directions. + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Searches); + state.cycle_focus(false); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + } + + #[test] + fn hiding_wraps_the_focus_to_the_first_pane() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Downloads); + state.hide_focused_pane(); + assert_eq!(state.focused_pane, FocusedPane::Searches); + } + + #[test] + fn the_last_visible_pane_cannot_be_hidden() { + let mut state = AppState::new(); + state.hide_focused_pane(); + state.hide_focused_pane(); + assert_eq!(visible(&state.layout), [FocusedPane::Downloads]); + state.hide_focused_pane(); + assert_eq!( + visible(&state.layout), + [FocusedPane::Downloads], + "one pane always stays on screen" + ); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + } + + #[test] + fn focusing_a_hidden_pane_by_number_brings_it_back() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Results); + state.hide_focused_pane(); + assert!(!state.layout.is_visible(FocusedPane::Results)); + + state.focus_pane(FocusedPane::Results); + + assert!(state.layout.is_visible(FocusedPane::Results)); + assert_eq!(state.focused_pane, FocusedPane::Results); + } + + #[test] + fn zoom_toggles_and_leaves_hidden_panes_alone() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Searches); + state.hide_focused_pane(); + state.toggle_zoom(); + assert!(state.layout.zoomed); + assert!(!state.layout.is_visible(FocusedPane::Searches)); + state.toggle_zoom(); + assert!(!state.layout.zoomed); + assert!(!state.layout.is_visible(FocusedPane::Searches)); + } + + #[test] + fn a_log_view_follows_the_tail_until_held_and_holds_against_new_rows() { + let mut view = LogView::default(); + assert_eq!(view.window(100, 10), 90..100); + assert!(view.following()); + + view.scroll(-10); + assert_eq!(view.window(100, 10), 80..90); + assert!(!view.following()); + + // Five more rows arrive: the held window does not move. + assert_eq!(view.window(105, 10), 80..90); + + // Back down to the tail resumes following, including new rows. + view.scroll(10); + view.scroll(10); + assert_eq!(view.window(105, 10), 95..105); + assert!(view.following()); + + view.to_oldest(); + assert_eq!(view.window(105, 10), 0..10); + view.scroll(-10); + assert_eq!(view.window(105, 10), 0..10, "clamped at the top"); + view.to_newest(); + assert_eq!(view.window(105, 10), 95..105); + } + + #[test] + fn a_log_shorter_than_its_window_is_never_held() { + let mut view = LogView::default(); + assert_eq!(view.window(3, 10), 0..3); + view.to_oldest(); + assert_eq!(view.window(3, 10), 0..3); + assert!(view.following(), "nothing to hold"); + } + + #[test] + fn cycling_to_the_same_chat_keeps_the_place() { + let mut state = AppState::new(); + say(&mut state, "alice"); + state.chat_view.window(50, 10); + state.chat_view.scroll(-10); + state.cycle_chat_peer(true); + assert!(!state.chat_view.following(), "only one chat: nothing moved"); + say(&mut state, "bob"); + state.cycle_chat_peer(true); + assert!(state.chat_view.following(), "a new chat opens at its end"); + } + + #[test] + fn opening_a_chat_pins_a_peer_with_no_messages_yet() { + let mut state = AppState::new(); + say(&mut state, "alice"); + state.open_chat("carol".to_string()); + assert_eq!(state.active_chat_peer(), Some("carol")); + assert!(state.show_messages && state.chat_composing); + assert_eq!(state.unread_messages, 0); + assert_eq!(state.chat_peers(), vec!["alice", "carol"]); + } +} diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs new file mode 100644 index 0000000..f428f79 --- /dev/null +++ b/soulseek-rs/src/models/browse.rs @@ -0,0 +1,893 @@ +//! Model for browsing another user's shared files as a collapsible tree. +//! +//! The library returns a *flat* `Vec` where each directory +//! carries its full backslash-separated path and only directories that directly +//! contain files are present. This module synthesizes the intermediate folders +//! into a real nested tree, and provides a flattened, expansion-aware row view +//! for rendering and navigation. + +use soulseek_rs::SharedDirectory; +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +/// A node in the browse tree: a folder (with children) or a file leaf. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowseNode { + Folder { + /// Last path segment (display name). + name: String, + /// Full backslash path (expansion key + display). + path: String, + children: Vec, + }, + File { + /// Basename (display name). + name: String, + /// Full backslash virtual path — the exact key used to download it. + path: String, + size: u64, + }, +} + +impl BrowseNode { + fn sort_key(&self) -> String { + match self { + Self::Folder { name, .. } | Self::File { name, .. } => { + name.to_lowercase() + } + } + } +} + +/// The synthesized tree plus counts for the title bar. +#[derive(Debug, Default)] +pub struct BuiltTree { + pub nodes: Vec, + pub file_count: usize, + pub folder_count: usize, +} + +/// Intermediate builder keyed by path segment while synthesizing folders. +#[derive(Default)] +struct Builder { + full_path: String, + folders: HashMap, + /// `(basename, size, full_virtual_path)`. + files: Vec<(String, u64, String)>, +} + +impl Builder { + fn into_nodes( + self, + file_count: &mut usize, + folder_count: &mut usize, + ) -> Vec { + let mut folders: Vec = self + .folders + .into_iter() + .map(|(name, child)| { + *folder_count += 1; + let path = child.full_path.clone(); + let children = child.into_nodes(file_count, folder_count); + BrowseNode::Folder { + name, + path, + children, + } + }) + .collect(); + folders.sort_by_key(BrowseNode::sort_key); + + let mut files: Vec = self + .files + .into_iter() + .map(|(name, size, path)| { + *file_count += 1; + BrowseNode::File { name, path, size } + }) + .collect(); + files.sort_by_key(BrowseNode::sort_key); + + folders.extend(files); + folders + } +} + +/// Build a nested tree from the peer's flat directory listing, synthesizing +/// intermediate folders. File download paths are taken from the *original* +/// directory name so they are byte-exact for the download request. +#[must_use] +pub fn build_browse_tree(directories: &[SharedDirectory]) -> BuiltTree { + let mut root = Builder::default(); + + for dir in directories { + // Walk/synthesize the folder chain, tolerating leading/trailing/doubled + // backslashes by dropping empty segments. + let mut node = &mut root; + let mut parent_path = String::new(); + for segment in dir.name.split('\\').filter(|s| !s.is_empty()) { + let child_path = if parent_path.is_empty() { + segment.to_string() + } else { + format!("{parent_path}\\{segment}") + }; + node = + node.folders.entry(segment.to_string()).or_insert_with(|| { + Builder { + full_path: child_path.clone(), + ..Builder::default() + } + }); + parent_path = child_path; + } + + // File paths come from the original name so downloads match exactly. + let dir_path = dir.name.trim_end_matches('\\'); + for file in &dir.files { + let file_path = if dir_path.is_empty() { + file.name.clone() + } else { + format!("{dir_path}\\{}", file.name) + }; + node.files.push((file.name.clone(), file.size, file_path)); + } + } + + let mut file_count = 0; + let mut folder_count = 0; + let nodes = root.into_nodes(&mut file_count, &mut folder_count); + BuiltTree { + nodes, + file_count, + folder_count, + } +} + +/// One rendered/navigable row of the flattened tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowseRow { + pub depth: usize, + pub is_folder: bool, + pub expanded: bool, + pub name: String, + pub path: String, + pub size: Option, +} + +/// 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, + needle: Option<&str>, + rows: &mut Vec, + emit: bool, +) -> bool { + let mut kept = false; + for node in nodes { + match node { + BrowseNode::Folder { + name, + path, + children, + } => { + 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 } => { + 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, +) -> 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. +fn find_node<'a>( + nodes: &'a [BrowseNode], + path: &str, +) -> Option<&'a BrowseNode> { + for node in nodes { + match node { + BrowseNode::Folder { + path: p, children, .. + } => { + if p == path { + return Some(node); + } + if let Some(found) = find_node(children, path) { + return Some(found); + } + } + BrowseNode::File { path: p, .. } => { + if p == path { + return Some(node); + } + } + } + } + None +} + +/// All file leaves under `node` as `(full_path, size)`, descending the tree +/// structurally (never string-prefix matching). +fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { + let mut out = Vec::new(); + collect_files(node, &mut out); + out +} + +fn collect_files(node: &BrowseNode, out: &mut Vec<(String, u64)>) { + match node { + BrowseNode::File { path, size, .. } => out.push((path.clone(), *size)), + BrowseNode::Folder { children, .. } => { + for child in children { + collect_files(child, out); + } + } + } +} + +/// Loading state of an in-flight browse. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowseStatus { + Loading, + Loaded, + Empty, + TimedOut, +} + +/// UI state for browsing one user. +pub struct BrowseState { + pub username: String, + /// This session's own share index rather than a peer's: filled from the + /// local index, re-indexed rather than re-requested, and not downloadable. + pub own: bool, + pub status: BrowseStatus, + pub tree: Vec, + expanded: HashSet, + /// The tree flattened for the current expansion, kept between frames: + /// a heavy sharer's tree runs to tens of thousands of rows. + rows: Vec, + /// 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>, + /// Whether typing goes to the filter. + pub filtering: bool, + pub selected_row: usize, + pub file_count: usize, + pub folder_count: usize, + pub requested_at: Instant, +} + +impl BrowseState { + #[must_use] + 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, + requested_at: Instant::now(), + } + } + + /// 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]) { + let built = build_browse_tree(directories); + self.expanded = built + .nodes + .iter() + .filter_map(|n| match n { + BrowseNode::Folder { path, .. } => Some(path.clone()), + BrowseNode::File { .. } => None, + }) + .collect(); + self.status = if built.nodes.is_empty() { + BrowseStatus::Empty + } else { + BrowseStatus::Loaded + }; + self.tree = built.nodes; + 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) -> &[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) { + for node in nodes { + if let BrowseNode::Folder { path, children, .. } = node { + out.insert(path.clone()); + folder_paths(children, out); + } + } +} + +/// A set of open browse views (tabs), one per user, like the chat-room tabs. +#[derive(Default)] +pub struct BrowseTabs { + pub tabs: Vec, + pub active: usize, +} + +impl BrowseTabs { + #[must_use] + pub const fn new() -> Self { + Self { + tabs: Vec::new(), + active: 0, + } + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.tabs.is_empty() + } + + #[must_use] + pub fn active_tab(&self) -> Option<&BrowseState> { + self.tabs.get(self.active) + } + + #[must_use] + pub fn active_tab_mut(&mut self) -> Option<&mut BrowseState> { + self.tabs.get_mut(self.active) + } + + /// Focus the tab for `username`, or open a fresh loading one. Returns + /// `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.own && t.username == username) + { + self.active = idx; + // Re-request only if the previous attempt gave up. + if self.tabs[idx].status == BrowseStatus::TimedOut { + self.tabs[idx] = BrowseState::loading(username.to_string()); + true + } else { + false + } + } else { + self.tabs.push(BrowseState::loading(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + } + + /// Where this session's own share tab sits, if it is open. + #[must_use] + pub fn own_index(&self) -> Option { + self.tabs.iter().position(|tab| tab.own) + } + + /// Focus the tab showing this session's own shares, opening it if it is + /// not there yet. Returns `true` when it still needs its listing. + pub fn open_own(&mut self, username: &str) -> bool { + if let Some(idx) = self.own_index() { + self.active = idx; + return false; + } + self.tabs + .push(BrowseState::loading_own(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + + /// Whether the focused tab is this session's own share index. + #[must_use] + pub fn active_is_own(&self) -> bool { + self.active_tab().is_some_and(|tab| tab.own) + } + + /// Who the active tab's files would be downloaded from, if anyone. The + /// own tab names nobody: its files are already on this disk. + #[must_use] + pub fn download_target(&self) -> Option<&str> { + self.active_tab() + .filter(|tab| !tab.own) + .map(|tab| tab.username.as_str()) + } + + /// Retry the active tab if it timed out: reset it to loading and return the + /// username so the caller can re-issue the request. Returns `None` if the + /// active tab isn't in a retryable (timed-out) state. + pub fn retry_active(&mut self) -> Option { + let tab = self.tabs.get_mut(self.active)?; + if tab.status == BrowseStatus::TimedOut { + let username = tab.username.clone(); + *tab = BrowseState::loading(username.clone()); + Some(username) + } else { + None + } + } + + /// Close the active tab, returning `false` when no tabs remain. + pub fn close_active(&mut self) -> bool { + if self.active < self.tabs.len() { + self.tabs.remove(self.active); + } + if self.active >= self.tabs.len() { + self.active = self.tabs.len().saturating_sub(1); + } + !self.tabs.is_empty() + } + + pub const fn next_tab(&mut self) { + if !self.tabs.is_empty() { + self.active = (self.active + 1) % self.tabs.len(); + } + } + + pub const fn prev_tab(&mut self) { + if !self.tabs.is_empty() { + self.active = (self.active + self.tabs.len() - 1) % self.tabs.len(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The tree flattened for `expanded`, unfiltered. + fn flatten_browse( + nodes: &[BrowseNode], + expanded: &HashSet, + ) -> Vec { + 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(), + files: files + .iter() + .map(|(n, s)| soulseek_rs::SharedFileEntry { + name: (*n).to_string(), + size: *s, + attributes: Vec::new(), + }) + .collect(), + } + } + + #[test] + fn intermediate_folders_are_synthesized() { + let tree = + build_browse_tree(&[dir("share\\music\\album", &[("a.mp3", 10)])]); + // share -> music -> album -> a.mp3 + assert_eq!(tree.file_count, 1); + assert_eq!(tree.folder_count, 3); + let BrowseNode::Folder { + name, + path, + children, + } = &tree.nodes[0] + else { + panic!("expected a folder"); + }; + assert_eq!(name, "share"); + assert_eq!(path, "share"); + assert!( + matches!(&children[0], BrowseNode::Folder { name, .. } if name == "music") + ); + } + + #[test] + fn same_named_dirs_merge_regardless_of_order() { + let tree = build_browse_tree(&[ + dir("share\\music\\album", &[("a.mp3", 1)]), + dir("share\\music", &[("b.mp3", 2)]), + ]); + // A single "share" -> single "music" holding album/ and b.mp3. + assert_eq!(tree.nodes.len(), 1); + let BrowseNode::Folder { + children: share_kids, + .. + } = &tree.nodes[0] + else { + panic!(); + }; + assert_eq!(share_kids.len(), 1); // just "music" + let BrowseNode::Folder { + children: music_kids, + .. + } = &share_kids[0] + else { + panic!(); + }; + // folders first (album), then files (b.mp3) + assert_eq!(music_kids.len(), 2); + assert!( + matches!(&music_kids[0], BrowseNode::Folder { name, .. } if name == "album") + ); + assert!( + matches!(&music_kids[1], BrowseNode::File { name, .. } if name == "b.mp3") + ); + } + + #[test] + fn file_paths_are_byte_exact_from_original_name() { + // Trailing backslash and a root-level (empty name) directory. + let tree = build_browse_tree(&[ + dir("share\\music\\", &[("song.flac", 5)]), + dir("", &[("root.txt", 3)]), + ]); + let files = collect_all(&tree.nodes); + assert!(files.contains(&("share\\music\\song.flac".to_string(), 5))); + assert!(files.contains(&("root.txt".to_string(), 3))); + } + + fn collect_all(nodes: &[BrowseNode]) -> Vec<(String, u64)> { + let mut out = Vec::new(); + for n in nodes { + out.extend(files_under(n)); + } + out + } + + #[test] + fn flatten_only_descends_into_expanded_folders() { + let tree = build_browse_tree(&[dir("a\\b", &[("f.mp3", 1)])]); + let mut expanded = HashSet::new(); + // Nothing expanded: only the top folder "a" shows. + assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 1); + expanded.insert("a".to_string()); + assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 2); // a, b + expanded.insert("a\\b".to_string()); + assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 3); // a, b, f + } + + #[test] + fn files_under_collects_all_descendant_leaves() { + let tree = build_browse_tree(&[ + dir("a\\b", &[("x.mp3", 1)]), + dir("a\\c", &[("y.mp3", 2), ("z.mp3", 3)]), + ]); + let all = files_under(&tree.nodes[0]); // folder "a" + assert_eq!(all.len(), 3); + } + + #[test] + fn browse_tabs_open_focus_close_and_cycle() { + let mut tabs = BrowseTabs::new(); + assert!(tabs.is_empty()); + + // New users open new tabs and request a browse. + assert!(tabs.open("alice")); + assert!(tabs.open("bob")); + assert_eq!(tabs.tabs.len(), 2); + assert_eq!(tabs.active, 1); + assert_eq!(tabs.active_tab().unwrap().username, "bob"); + + // Re-opening a live tab just focuses it (no new request). + assert!(!tabs.open("alice")); + assert_eq!(tabs.active, 0); + assert_eq!(tabs.tabs.len(), 2); + + // Cycling wraps. + tabs.next_tab(); + assert_eq!(tabs.active, 1); + tabs.next_tab(); + assert_eq!(tabs.active, 0); + tabs.prev_tab(); + assert_eq!(tabs.active, 1); + + // Closing the active tab returns whether any remain. + assert!(tabs.close_active()); + assert_eq!(tabs.tabs.len(), 1); + assert!(!tabs.close_active()); + 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(); + tabs.open("alice"); + tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; + // Re-opening a timed-out tab retries (returns true) and resets it. + assert!(tabs.open("alice")); + assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading); + } + + #[test] + fn browse_tabs_retry_active_only_when_timed_out() { + let mut tabs = BrowseTabs::new(); + tabs.open("alice"); + assert_eq!(tabs.retry_active(), None); + tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; + // Timed-out tab retries and resets to loading. + assert_eq!(tabs.retry_active().as_deref(), Some("alice")); + assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading); + // Now loading again, no further retry. + 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 { + 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()); + state.load(&[dir("music\\album", &[("a.mp3", 1)])]); + assert_eq!(state.status, BrowseStatus::Loaded); + // Top-level "music" auto-expanded → music and album rows visible. + assert!(state.rows().len() >= 2); + + let mut empty = BrowseState::loading("nobody".to_string()); + empty.load(&[]); + assert_eq!(empty.status, BrowseStatus::Empty); + } +} diff --git a/soulseek-rs/src/models/file_display_data.rs b/soulseek-rs/src/models/file_display_data.rs new file mode 100644 index 0000000..008f8d5 --- /dev/null +++ b/soulseek-rs/src/models/file_display_data.rs @@ -0,0 +1,10 @@ +#[derive(Clone, Default)] +pub struct FileDisplayData { + pub filename: String, + pub size: u64, + pub username: String, + pub speed: u32, + pub slots: u8, + pub bitrate: Option, + pub length_seconds: Option, +} diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs new file mode 100644 index 0000000..69f5710 --- /dev/null +++ b/soulseek-rs/src/models/mod.rs @@ -0,0 +1,20 @@ +mod app_state; +mod browse; +mod file_display_data; +mod rooms; +mod settings; + +pub use app_state::{ + AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, TuiExit, + WrappedLog, +}; +pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; +pub use file_display_data::FileDisplayData; +pub use rooms::{ + ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, + matching_users, +}; +pub use settings::{ + AccountInfo, SettingsAction, SettingsMode, SettingsRow, SettingsState, +}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs new file mode 100644 index 0000000..8897f39 --- /dev/null +++ b/soulseek-rs/src/models/rooms.rs @@ -0,0 +1,761 @@ +use crate::models::{LogView, WrappedLog}; +use chrono::{DateTime, Local}; +use soulseek_rs::types::{RoomEvent, RoomInfo}; + +/// One line in a room's chat log. A `None` username marks a system line +/// (joins/leaves), rendered dimmed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomLine { + pub username: Option, + pub text: String, + /// Local wall-clock time the line was appended. + pub at: DateTime, +} + +impl RoomLine { + fn chat(username: String, text: String) -> Self { + Self { + username: Some(username), + text, + at: Local::now(), + } + } + fn system(text: String) -> Self { + Self { + username: None, + text, + at: Local::now(), + } + } +} + +/// A chat room the user has open (a tab). +#[derive(Debug, Clone, Default)] +pub struct OpenRoom { + pub name: String, + pub users: Vec, + pub lines: Vec, + /// Unread messages received while this room was not being viewed. + pub unread: usize, + /// Per-room compose buffer. + pub input: String, + /// Where the log is being read. + pub view: LogView, + /// The log wrapped for drawing. + pub wrapped: WrappedLog, +} + +impl OpenRoom { + fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } +} + +/// Which sub-view of the rooms popup is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoomsView { + /// The browsable/filterable list of public rooms. + List, + /// The open-room tabs and their chat logs. + Chat, +} + +/// All chat-room UI state: the discoverable room list plus the set of open +/// rooms (tabs). +pub struct RoomsState { + /// Latest public-room snapshot from the server. + pub available: Vec, + pub list_filter: String, + pub list_is_filtering: bool, + /// Selected index into the *filtered* room list. + pub list_selected: usize, + /// Rooms the user has joined (tabs). + pub open: Vec, + /// Index into `open` of the active tab. + pub active: usize, + 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), + /// 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, +} + +/// A room's two filterable lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatFilter { + Log, + Users, +} + +impl Default for RoomsState { + fn default() -> Self { + Self::new() + } +} + +impl RoomsState { + #[must_use] + pub const fn new() -> Self { + Self { + available: Vec::new(), + list_filter: String::new(), + list_is_filtering: false, + list_selected: 0, + open: Vec::new(), + active: 0, + 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(); + } + } + + /// The public rooms matching the current filter, busiest first then by name. + #[must_use] + pub fn filtered_rooms(&self) -> Vec { + let needle = self.list_filter.to_lowercase(); + let mut rooms: Vec = self + .available + .iter() + .filter(|r| { + needle.is_empty() || r.name.to_lowercase().contains(&needle) + }) + .cloned() + .collect(); + rooms.sort_by(|a, b| { + b.user_count + .cmp(&a.user_count) + .then_with(|| a.name.cmp(&b.name)) + }); + rooms + } + + /// The room name currently highlighted in the list view, if any. + #[must_use] + pub fn selected_room_name(&self) -> Option { + let rooms = self.filtered_rooms(); + rooms.get(self.list_selected).map(|r| r.name.clone()) + } + + #[must_use] + pub fn open_index(&self, name: &str) -> Option { + self.open.iter().position(|r| r.name == name) + } + + #[must_use] + pub fn active_room(&self) -> Option<&OpenRoom> { + self.open.get(self.active) + } + + /// Open (or focus) a room by name, switching to the Chat view. Returns + /// `true` if the room was newly opened (so the caller should `join_room`). + pub fn focus_or_open(&mut self, name: &str) -> bool { + self.view = RoomsView::Chat; + self.user_selected = 0; + if let Some(idx) = self.open_index(name) { + self.active = idx; + self.mark_active_read(); + false + } else { + self.open.push(OpenRoom::new(name.to_string())); + self.active = self.open.len() - 1; + true + } + } + + /// The username highlighted in the active room's member list, if any. + #[must_use] + pub fn selected_user(&self) -> Option { + self.visible_users() + .get(self.user_selected) + .map(|user| (*user).to_string()) + } + + /// Where the active room's log is being read, to move it. + pub fn active_view_mut(&mut self) -> Option<&mut LogView> { + self.open.get_mut(self.active).map(|room| &mut room.view) + } + + pub const fn select_user_up(&mut self) { + self.user_selected = self.user_selected.saturating_sub(1); + } + + pub fn select_user_down(&mut self) { + let len = self.visible_users().len(); + if len > 0 { + self.user_selected = (self.user_selected + 1).min(len - 1); + } + } + + /// Close the active tab, returning its room name (for `leave_room`). + pub fn close_active(&mut self) -> Option { + if self.active >= self.open.len() { + return None; + } + let room = self.open.remove(self.active); + if self.active >= self.open.len() { + self.active = self.open.len().saturating_sub(1); + } + self.user_selected = 0; + if self.open.is_empty() { + self.view = RoomsView::List; + } else { + // The tab we land on is now being viewed; clear its stale badge. + self.mark_active_read(); + } + Some(room.name) + } + + pub fn next_tab(&mut self) { + if !self.open.is_empty() { + self.active = (self.active + 1) % self.open.len(); + self.user_selected = 0; + self.mark_active_read(); + } + } + + pub fn prev_tab(&mut self) { + if !self.open.is_empty() { + self.active = (self.active + self.open.len() - 1) % self.open.len(); + self.user_selected = 0; + self.mark_active_read(); + } + } + + /// Clear the unread badge on the active room. + pub fn mark_active_read(&mut self) { + if let Some(room) = self.open.get_mut(self.active) { + room.unread = 0; + } + } + + /// Total unread messages across all open rooms (for the closed-popup badge). + #[must_use] + pub fn total_unread(&self) -> usize { + self.open.iter().map(|r| r.unread).sum() + } + + /// Apply a room event. `viewing` is the room the user is actively looking + /// at (Chat view open on that tab), whose incoming messages should not + /// accrue an unread badge. + pub fn apply_event(&mut self, event: RoomEvent, viewing: Option<&str>) { + match event { + RoomEvent::List(rooms) => { + // Keep the highlight on the same room across a re-sort, since + // list_selected is a positional index into the sorted list. + let selected_name = self.selected_room_name(); + self.available = rooms; + let filtered = self.filtered_rooms(); + self.list_selected = selected_name + .and_then(|name| { + filtered.iter().position(|r| r.name == name) + }) + .unwrap_or_else(|| { + self.list_selected.min(filtered.len().saturating_sub(1)) + }); + } + RoomEvent::Joined { room, users } => { + let idx = self.ensure_open(&room); + self.open[idx].users = users; + self.open[idx] + .lines + .push(RoomLine::system("— joined —".to_string())); + } + RoomEvent::Left { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx] + .lines + .push(RoomLine::system("— left —".to_string())); + } + } + RoomEvent::Message { + room, + username, + message, + } => { + // Only record messages for rooms we currently have open; a + // message for a room we just left must not resurrect its tab. + if let Some(idx) = self.open_index(&room) { + self.open[idx] + .lines + .push(RoomLine::chat(username, message)); + if viewing != Some(room.as_str()) { + self.open[idx].unread += 1; + } + } + } + RoomEvent::UserJoined { room, username } => { + if let Some(idx) = self.open_index(&room) { + if !self.open[idx].users.contains(&username) { + self.open[idx].users.push(username.clone()); + } + self.open[idx] + .lines + .push(RoomLine::system(format!("→ {username}"))); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].users.retain(|u| u != &username); + self.open[idx] + .lines + .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 + // 'b'/'m' never act on the wrong member or a phantom one. + self.clamp_user_selected(); + } + + /// Keep `user_selected` within the active room's member list. + fn clamp_user_selected(&mut self) { + let len = self.visible_users().len(); + self.user_selected = self.user_selected.min(len.saturating_sub(1)); + } + + /// Index of the open room `name`, creating an empty tab if necessary. + fn ensure_open(&mut self, name: &str) -> usize { + if let Some(idx) = self.open_index(name) { + idx + } else { + self.open.push(OpenRoom::new(name.to_string())); + self.open.len() - 1 + } + } +} + +/// 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::*; + + fn room(name: &str, users: u32) -> RoomInfo { + RoomInfo { + name: name.to_string(), + user_count: users, + } + } + + #[test] + fn filtered_rooms_sorts_busiest_first_then_name() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::List(vec![ + room("blues", 3), + room("jazz", 10), + room("ambient", 10), + ]), + None, + ); + let names: Vec = + state.filtered_rooms().into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["ambient", "jazz", "blues"]); + } + + #[test] + fn filter_matches_case_insensitively() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::List(vec![room("Jazz", 1), room("Blues", 1)]), + None, + ); + state.list_filter = "JAZ".to_string(); + let names: Vec = + state.filtered_rooms().into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["Jazz"]); + } + + #[test] + fn joining_creates_a_tab_with_members() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec!["alice".to_string(), "bob".to_string()], + }, + None, + ); + assert_eq!(state.open.len(), 1); + assert_eq!(state.open[0].name, "jazz"); + assert_eq!(state.open[0].users, vec!["alice", "bob"]); + } + + #[test] + fn message_to_unviewed_room_increments_unread() { + let mut state = RoomsState::new(); + state.focus_or_open("jazz"); + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "hi".to_string(), + }, + None, // not viewing anything + ); + assert_eq!(state.open[0].unread, 1); + assert_eq!(state.total_unread(), 1); + } + + #[test] + fn message_to_viewed_room_does_not_increment_unread() { + let mut state = RoomsState::new(); + state.focus_or_open("jazz"); + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "hi".to_string(), + }, + Some("jazz"), + ); + assert_eq!(state.open[0].unread, 0); + assert_eq!(state.open[0].lines.len(), 1); + } + + #[test] + fn focus_or_open_is_idempotent_and_marks_read() { + let mut state = RoomsState::new(); + assert!(state.focus_or_open("jazz")); // newly opened + state.open[0].unread = 4; + assert!(!state.focus_or_open("jazz")); // already open + assert_eq!(state.open.len(), 1); + assert_eq!(state.open[0].unread, 0, "focusing clears unread"); + } + + #[test] + fn closing_active_tab_returns_name_and_adjusts_index() { + let mut state = RoomsState::new(); + state.focus_or_open("a"); + state.focus_or_open("b"); + assert_eq!(state.active, 1); + assert_eq!(state.close_active().as_deref(), Some("b")); + assert_eq!(state.active, 0); + assert_eq!(state.open.len(), 1); + assert_eq!(state.close_active().as_deref(), Some("a")); + assert!(state.open.is_empty()); + assert_eq!(state.view, RoomsView::List); + } + + #[test] + fn tab_cycling_wraps_and_marks_read() { + let mut state = RoomsState::new(); + state.focus_or_open("a"); + state.focus_or_open("b"); + state.open[0].unread = 2; + state.next_tab(); // 1 -> 0 + assert_eq!(state.active, 0); + assert_eq!(state.open[0].unread, 0); + state.prev_tab(); // 0 -> 1 + assert_eq!(state.active, 1); + } + + #[test] + fn message_for_unopened_room_is_dropped() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "hi".to_string(), + }, + None, + ); + assert!( + state.open.is_empty(), + "a message must not fabricate a tab for an unjoined room" + ); + } + + #[test] + fn message_does_not_resurrect_a_closed_room() { + let mut state = RoomsState::new(); + state.focus_or_open("jazz"); + assert_eq!(state.close_active().as_deref(), Some("jazz")); + // A straggler message arrives after we left. + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "bye".to_string(), + }, + None, + ); + assert!(state.open.is_empty(), "left room must stay closed"); + } + + #[test] + fn list_refresh_keeps_highlight_on_the_same_room() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::List(vec![ + room("jazz", 5), + room("blues", 3), + room("ambient", 1), + ]), + None, + ); + // Highlight "ambient" (index 2 in busiest-first order). + state.list_selected = 2; + assert_eq!(state.selected_room_name().as_deref(), Some("ambient")); + // A refresh reorders ambient to the top; the highlight should follow. + state.apply_event( + RoomEvent::List(vec![ + room("jazz", 5), + room("blues", 3), + room("ambient", 50), + ]), + None, + ); + assert_eq!(state.selected_room_name().as_deref(), Some("ambient")); + } + + #[test] + fn closing_a_tab_clears_unread_on_the_newly_active_room() { + let mut state = RoomsState::new(); + state.focus_or_open("a"); + state.focus_or_open("b"); // active = 1 + state.open[0].unread = 3; // unread accrued on "a" while viewing "b" + assert_eq!(state.close_active().as_deref(), Some("b")); + assert_eq!(state.active, 0); + assert_eq!( + state.open[0].unread, 0, + "landing on room a should clear its badge" + ); + assert_eq!(state.total_unread(), 0); + } + + #[test] + fn user_selection_navigates_and_resets_across_tabs() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec![ + "alice".to_string(), + "bob".to_string(), + "carol".to_string(), + ], + }, + None, + ); + state.focus_or_open("jazz"); + assert_eq!(state.selected_user().as_deref(), Some("alice")); + state.select_user_down(); + state.select_user_down(); + assert_eq!(state.selected_user().as_deref(), Some("carol")); + state.select_user_down(); // clamps at the last user + assert_eq!(state.selected_user().as_deref(), Some("carol")); + state.select_user_up(); + assert_eq!(state.selected_user().as_deref(), Some("bob")); + + // Opening another room resets the user highlight. + state.focus_or_open("blues"); + assert_eq!(state.user_selected, 0); + assert_eq!(state.selected_user(), None); // blues has no members yet + } + + #[test] + fn user_selection_stays_in_range_when_members_leave() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec![ + "alice".to_string(), + "bob".to_string(), + "carol".to_string(), + ], + }, + None, + ); + state.focus_or_open("jazz"); + state.user_selected = 2; // carol highlighted (last) + + // carol leaves: selection must clamp to the new last index, not point + // past the end (which would make 'b'/'m' silent no-ops). + state.apply_event( + RoomEvent::UserLeft { + room: "jazz".to_string(), + username: "carol".to_string(), + }, + None, + ); + assert_eq!(state.user_selected, 1); + assert_eq!(state.selected_user().as_deref(), Some("bob")); + + // Everyone leaves: selection collapses to 0 and yields no user. + state.apply_event( + RoomEvent::UserLeft { + room: "jazz".to_string(), + username: "bob".to_string(), + }, + None, + ); + state.apply_event( + RoomEvent::UserLeft { + room: "jazz".to_string(), + username: "alice".to_string(), + }, + None, + ); + assert_eq!(state.user_selected, 0); + assert_eq!(state.selected_user(), None); + } + + #[test] + fn user_joined_and_left_update_membership() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec!["alice".to_string()], + }, + None, + ); + state.apply_event( + RoomEvent::UserJoined { + room: "jazz".to_string(), + username: "bob".to_string(), + }, + None, + ); + assert_eq!(state.open[0].users, vec!["alice", "bob"]); + state.apply_event( + RoomEvent::UserLeft { + room: "jazz".to_string(), + username: "alice".to_string(), + }, + None, + ); + assert_eq!(state.open[0].users, vec!["bob"]); + } +} diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs new file mode 100644 index 0000000..90121cd --- /dev/null +++ b/soulseek-rs/src/models/settings.rs @@ -0,0 +1,524 @@ +//! 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}; + +/// What the popup is doing with the keyboard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SettingsMode { + /// Moving between rows. + Navigate, + /// Typing a new download directory. + 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, + UseUsernameDirs, + DownloadDir, + /// Share path at this index. + Share(usize), +} + +/// What the TUI should do after a key was handled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SettingsAction { + None, + /// Settings changed: persist config and apply the new share set live. + Apply, + /// Re-scan the current share paths (files changed on disk). + Reindex, + /// Close the popup. + Close, + /// Make this the account's password. + ChangePassword(String), + /// End the session and forget the stored password. + Logout, +} + +/// Who the session is logged in as and what it offers the network. +/// +/// Captured when the popup opens rather than read while drawing: attached to a +/// daemon every one of these is a round trip, and the popup redraws each tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountInfo { + pub username: String, + /// Already formatted, e.g. "1234 files in 56 folders". + pub shares: String, + /// The port peers can reach us on, when the listener is up. + pub listen_port: Option, + /// The daemon this session is borrowed from. `Some` means the account is + /// the daemon's, so this window cannot log it out. + pub daemon: Option, +} + +pub struct SettingsState { + pub account: AccountInfo, + pub download_dir: String, + pub share_dirs: Vec, + pub use_username_dirs: bool, + /// Index into [`Self::rows`]. + pub selected: usize, + pub mode: SettingsMode, + /// Edit buffer while typing. + pub input: String, + /// One-line feedback ("Re-indexed: 1234 files in 56 folders"). + pub status: Option, +} + +impl SettingsState { + #[must_use] + pub const fn new( + account: AccountInfo, + download_dir: String, + share_dirs: Vec, + use_username_dirs: bool, + ) -> Self { + Self { + account, + download_dir, + share_dirs, + use_username_dirs, + selected: 0, + mode: SettingsMode::Navigate, + input: String::new(), + status: None, + } + } + + /// The daemon owns its own login; a window borrowing it can only detach. + #[must_use] + pub const fn can_log_out(&self) -> bool { + self.account.daemon.is_none() + } + + /// Every row, in the order they are drawn. + #[must_use] + pub fn rows(&self) -> Vec { + let mut rows = vec![SettingsRow::ChangePassword]; + if self.can_log_out() { + rows.push(SettingsRow::Logout); + } + rows.push(SettingsRow::UseUsernameDirs); + 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::ConfirmingLogout => self.handle_logout_confirm(key), + SettingsMode::EditingDownloadDir + | SettingsMode::AddingShare + | SettingsMode::NewPassword + | SettingsMode::RepeatPassword(_) => self.handle_typing(key), + } + } + + fn handle_navigate(&mut self, key: KeyEvent) -> SettingsAction { + self.status = None; + match key.code { + KeyCode::Esc | KeyCode::Char('q' | 'o') => SettingsAction::Close, + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + SettingsAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(self.last_row()); + 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') => { + 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, + _ => SettingsAction::None, + } + } + + 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::UseUsernameDirs => { + self.use_username_dirs = !self.use_username_dirs; + return SettingsAction::Apply; + } + 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 => { + self.mode = SettingsMode::Navigate; + self.input.clear(); + SettingsAction::None + } + KeyCode::Enter => self.submit(), + KeyCode::Backspace => { + self.input.pop(); + SettingsAction::None + } + KeyCode::Char(c) => { + self.input.push(c); + SettingsAction::None + } + _ => 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)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyModifiers; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_str(state: &mut SettingsState, text: &str) { + for c in text.chars() { + state.handle_key(key(KeyCode::Char(c))); + } + } + + 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( + account(), + "/dl".into(), + vec!["/dl".into(), "/music".into()], + false, + ) + } + + /// 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] + fn escape_closes_from_navigation() { + assert_eq!( + state().handle_key(key(KeyCode::Esc)), + SettingsAction::Close + ); + } + + #[test] + fn adding_a_share_path_applies() { + let mut s = state(); + s.handle_key(key(KeyCode::Char('a'))); + assert_eq!(s.mode, SettingsMode::AddingShare); + type_str(&mut s, "/flacs"); + let action = s.handle_key(key(KeyCode::Enter)); + 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] + fn adding_a_duplicate_share_is_a_no_op() { + let mut s = state(); + s.handle_key(key(KeyCode::Char('a'))); + type_str(&mut s, "/music"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.share_dirs.len(), 2); + } + + #[test] + fn deleting_the_selected_share_applies() { + let mut s = state(); + 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_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); + } + + #[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"); + s.handle_key(key(KeyCode::Backspace)); + s.handle_key(key(KeyCode::Backspace)); + s.handle_key(key(KeyCode::Backspace)); + type_str(&mut s, "/new"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::Apply); + assert_eq!(s.download_dir, "/new"); + } + + #[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); + assert_eq!(s.download_dir, "/dl"); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn r_requests_a_reindex() { + assert_eq!( + state().handle_key(key(KeyCode::Char('r'))), + SettingsAction::Reindex + ); + } + + #[test] + fn selection_is_clamped_to_the_row_count() { + let mut s = state(); + for _ in 0..10 { + s.handle_key(key(KeyCode::Down)); + } + assert_eq!(s.selected_row(), SettingsRow::Share(1)); + for _ in 0..10 { + s.handle_key(key(KeyCode::Up)); + } + 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()], + false, + ); + assert_eq!( + s.rows(), + vec![ + SettingsRow::ChangePassword, + SettingsRow::UseUsernameDirs, + 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::UseUsernameDirs); + s.handle_key(key(KeyCode::Down)); + assert_eq!(s.selected_row(), SettingsRow::DownloadDir); + } +} diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs new file mode 100644 index 0000000..21d6aa4 --- /dev/null +++ b/soulseek-rs/src/output.rs @@ -0,0 +1,742 @@ +//! Output discipline for the one-shot commands. +//! +//! stdout carries records and nothing else, stderr carries progress, and the +//! process exit code carries the verdict. Records render either as +//! newline-delimited JSON (`--json`) or as tab-separated text, so both `jq` +//! and `cut` are first-class consumers. + +use serde::Serialize; +use std::io::{IsTerminal, Write}; + +/// Documented exit statuses. Anything a wrapper script needs to branch on has +/// its own code; clap already uses 2 for argument errors, so config problems +/// share it. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Exit { + Ok = 0, + /// Something unexpected went wrong (also the panic/TUI bucket). + Failure = 1, + /// Bad arguments, missing credentials, unusable configuration. + Usage = 2, + /// Could not reach the server, or the server rejected the login. + Connection = 3, + /// The operation succeeded but produced nothing. + NoResults = 4, + /// Gave up waiting for a response or a transfer. + Timeout = 5, + /// A transfer started but did not complete. + Transfer = 6, + /// The server session ended mid-command, so nothing this run saw (or did + /// not see) means anything. Retry rather than believe it. + SessionLost = 7, +} + +impl Exit { + #[must_use] + pub const fn code(self) -> u8 { + self as u8 + } +} + +impl From for std::process::ExitCode { + fn from(exit: Exit) -> Self { + Self::from(exit.code()) + } +} + +/// A failure carrying the exit code it should produce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliError { + pub exit: Exit, + pub message: String, +} + +impl CliError { + pub fn new(exit: Exit, message: impl Into) -> Self { + Self { + exit, + message: message.into(), + } + } + + pub fn usage(message: impl Into) -> Self { + Self::new(Exit::Usage, message) + } + + pub fn connection(message: impl Into) -> Self { + Self::new(Exit::Connection, message) + } + + pub fn timeout(message: impl Into) -> Self { + Self::new(Exit::Timeout, message) + } + + pub fn transfer(message: impl Into) -> Self { + Self::new(Exit::Transfer, message) + } + + pub fn no_results(message: impl Into) -> Self { + Self::new(Exit::NoResults, message) + } + + pub fn session_lost(message: impl Into) -> Self { + Self::new(Exit::SessionLost, message) + } +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for CliError {} + +pub type CliResult = Result; + +/// One line of machine-readable output. +pub trait Record: Serialize { + /// Tab-separated rendering. Fields must already be free of tabs and + /// newlines — use [`sanitize`]. + fn text(&self) -> String; +} + +/// Strip the field and record separators out of peer-supplied text so one +/// record always occupies exactly one line with stable columns. +#[must_use] +pub fn sanitize(value: &str) -> String { + value.replace(['\t', '\r', '\n'], " ") +} + +/// Where records go and whether progress is shown. +#[derive(Clone)] +pub struct Out { + json: bool, + quiet: bool, + color: bool, +} + +impl Out { + #[must_use] + pub fn new(json: bool, quiet: bool) -> Self { + Self { + json, + quiet, + color: std::env::var_os("NO_COLOR").is_none() + && std::io::stderr().is_terminal(), + } + } + + /// Write one record to stdout. + pub fn emit(&self, record: &R) { + let line = if self.json { + serde_json::to_string(record) + .expect("a record is plain data and always serializes") + } else { + record.text() + }; + Self::write_line(&line); + } + + /// Progress and narration. Suppressed by `--quiet`, never on stdout. + pub fn status(&self, message: &str) { + if !self.quiet { + let _ = writeln!(std::io::stderr(), "{message}"); + } + } + + /// A problem worth reporting that is not fatal. Shown even when quiet, + /// because silently doing less than asked is worse than a stray line. + pub fn warn(&self, message: &str) { + let _ = if self.color { + writeln!(std::io::stderr(), "\x1b[33mwarning:\x1b[0m {message}") + } else { + writeln!(std::io::stderr(), "warning: {message}") + }; + } + + pub fn error(&self, message: &str) { + let _ = if self.color { + writeln!(std::io::stderr(), "\x1b[31merror:\x1b[0m {message}") + } else { + writeln!(std::io::stderr(), "error: {message}") + }; + } + + // a closed downstream (`| head -1`) is a normal end for a + // pipeline, so exit quietly instead of letting println! panic on EPIPE. + fn write_line(line: &str) { + if writeln!(std::io::stdout(), "{line}").is_err() { + std::process::exit(Exit::Ok.code().into()); + } + } +} + +/// One file found by `search`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SearchRecord { + pub user: String, + pub path: String, + pub size: u64, + pub bitrate: Option, + pub duration: Option, + pub free_slot: bool, + /// The peer's free upload slots as it reported them, for a picker that + /// wants to rank rather than just filter. + pub slots: u8, + pub speed: u32, +} + +impl Record for SearchRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + sanitize(&self.user), + self.size, + self.bitrate + .map_or_else(|| "-".to_string(), |b| b.to_string()), + sanitize(&self.path) + ) + } +} + +/// One file in a peer's share listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BrowseRecord { + pub user: String, + pub directory: String, + pub path: String, + pub size: u64, + pub bitrate: Option, + pub duration: Option, +} + +impl Record for BrowseRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + sanitize(&self.user), + self.size, + sanitize(&self.path) + ) + } +} + +/// The outcome of one transfer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DownloadRecord { + pub user: String, + pub path: String, + pub size: u64, + pub file: String, +} + +impl Record for DownloadRecord { + fn text(&self) -> String { + sanitize(&self.file) + } +} + +/// One public chat room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoomRecord { + pub room: String, + pub users: u32, +} + +impl Record for RoomRecord { + fn text(&self) -> String { + format!("{}\t{}", self.users, sanitize(&self.room)) + } +} + +/// Something that happened in a room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoomEventRecord { + #[serde(rename = "type")] + pub kind: &'static str, + pub room: String, + pub user: String, + pub message: String, +} + +impl Record for RoomEventRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + self.kind, + sanitize(&self.room), + sanitize(&self.user), + sanitize(&self.message) + ) + } +} + +/// One incoming private message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrivateMessageRecord { + pub timestamp: u32, + pub user: String, + pub message: String, +} + +impl Record for PrivateMessageRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + self.timestamp, + sanitize(&self.user), + sanitize(&self.message) + ) + } +} + +/// One upload being served to a peer, reported by `serve` as it changes. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct UploadRecord { + pub user: String, + pub path: String, + pub size: u64, + pub bytes_sent: u64, + pub speed: f64, + /// `queued`, `uploading`, `completed`, `cancelled`, or `failed`. + pub status: String, + /// Why it failed, when it did. + pub reason: Option, +} + +impl Record for UploadRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}/{}\t{}", + self.status, + sanitize(&self.user), + self.bytes_sent, + self.size, + sanitize(&self.path) + ) + } +} + +/// Who we are and what this session offers the network. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WhoamiRecord { + pub user: String, + pub server: String, + pub listening: bool, + pub listen_port: u16, + pub shared_folders: u32, + pub shared_files: u32, + pub download_dir: String, + /// Seconds of privileges left on this account, or `null` when the server + /// did not answer in time. Zero means an ordinary account. + pub privilege_seconds: Option, +} + +impl Record for WhoamiRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + sanitize(&self.user), + sanitize(&self.server), + self.shared_folders, + self.shared_files + ) + } +} + +/// 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. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRecord { + pub user: String, + /// `online`, `away`, or `offline`. + pub status: Option, + pub privileged: Option, + pub average_speed: Option, + pub shared_files: Option, + pub shared_folders: Option, +} + +impl Record for UserRecord { + fn text(&self) -> String { + let unknown = || "-".to_string(); + format!( + "{}\t{}\t{}\t{}", + sanitize(&self.user), + self.status.clone().unwrap_or_else(unknown), + self.average_speed.map_or_else(unknown, |v| v.to_string()), + self.shared_files.map_or_else(unknown, |v| v.to_string()) + ) + } +} + +/// One member of a chat room, with the server's description of them. +/// +/// The statistics come from the reply that named the members, and are `None` +/// for a server that sent none — so a consumer never reads "shares nothing" +/// from a missing answer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoomMemberRecord { + pub room: String, + pub user: String, + /// `online`, `away`, or `offline`. + pub status: Option, + pub average_speed: Option, + pub shared_files: Option, + pub shared_folders: Option, + pub slots_full: Option, + pub country: Option, +} + +impl Record for RoomMemberRecord { + /// The username alone, unchanged by the statistics added alongside it: + /// the text line is a roster a caller pipes into other commands, and the + /// statistics are there for `--json` to carry. + fn text(&self) -> String { + sanitize(&self.user) + } +} + +/// One user this session is watching, with whatever the server has already +/// said about them. The status is `None` until the watch reply lands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WatchedUserRecord { + pub user: String, + /// `online`, `away`, or `offline`. + pub status: Option, +} + +impl Record for WatchedUserRecord { + fn text(&self) -> String { + format!( + "{}\t{}", + sanitize(&self.user), + self.status.clone().unwrap_or_else(|| "-".to_string()) + ) + } +} + +/// One configured share folder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ShareRecord { + pub directory: String, + /// False when the folder is configured but missing on disk. + pub usable: bool, +} + +impl Record for ShareRecord { + fn text(&self) -> String { + format!( + "{}\t{}", + if self.usable { "ok" } else { "missing" }, + sanitize(&self.directory) + ) + } +} + +/// What the share index currently holds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ShareStatusRecord { + pub folders: u32, + pub files: u32, + pub directories: Vec, +} + +impl Record for ShareStatusRecord { + fn text(&self) -> String { + format!("{}\t{}", self.folders, self.files) + } +} + +/// One standing search on the wishlist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WishRecord { + pub query: String, +} + +impl Record for WishRecord { + fn text(&self) -> String { + sanitize(&self.query) + } +} + +/// One configuration setting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConfigRecord { + pub key: String, + pub value: Option, +} + +impl Record for ConfigRecord { + fn text(&self) -> String { + format!( + "{}\t{}", + sanitize(&self.key), + sanitize(self.value.as_deref().unwrap_or_default()) + ) + } +} + +/// What happened to one agent's copy of the skill. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkillRecord { + /// The agent the directory belongs to, or `custom` for a `--dir`. + pub agent: String, + pub path: String, + /// `installed` (written), `unchanged` (already identical), `outdated` + /// (present but different, reported by `list`), `removed`, or `absent`. + pub action: String, +} + +impl Record for SkillRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + sanitize(&self.action), + sanitize(&self.agent), + sanitize(&self.path) + ) + } +} + +/// What happened to one shell's completion script. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CompletionRecord { + /// `bash`, `zsh`, or `fish`. + pub shell: String, + pub path: String, + /// `installed` (written), `unchanged` (already identical), `removed`, or + /// `absent`. + pub action: String, +} + +impl Record for CompletionRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + sanitize(&self.action), + sanitize(&self.shell), + sanitize(&self.path) + ) + } +} + +/// The token a remote client authenticates with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenRecord { + pub token: String, +} + +impl Record for TokenRecord { + fn text(&self) -> String { + sanitize(&self.token) + } +} + +/// What a running daemon is and what its session is doing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DaemonStatusRecord { + pub user: String, + pub server: String, + pub version: String, + pub protocol: u32, + pub listening: bool, + pub listen_port: u16, + pub shared_folders: u32, + pub shared_files: u32, + pub download_dir: String, + /// `displaced` or `disconnected` when the server session has ended. + pub session_loss: Option, + /// Control connections currently attached. + pub clients: usize, + pub uptime_secs: u64, +} + +impl Record for DaemonStatusRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}\t{}", + sanitize(&self.user), + sanitize(&self.server), + self.clients, + self.uptime_secs + ) + } +} + +/// The result of probing automatic port mapping. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortmapRecord { + pub ok: bool, + pub backend: String, + pub external: Option, + pub port: u16, +} + +impl Record for PortmapRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + if self.ok { "ok" } else { "failed" }, + sanitize(&self.backend), + self.external.as_deref().unwrap_or("-") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn search_record() -> SearchRecord { + SearchRecord { + user: "bob".into(), + path: "@@dir\\01 - Track.mp3".into(), + size: 4096, + bitrate: Some(320), + duration: Some(210), + free_slot: true, + slots: 1, + speed: 1000, + } + } + + #[test] + fn exit_codes_are_stable() { + assert_eq!(Exit::Ok.code(), 0); + assert_eq!(Exit::Failure.code(), 1); + assert_eq!(Exit::Usage.code(), 2); + assert_eq!(Exit::Connection.code(), 3); + assert_eq!(Exit::NoResults.code(), 4); + assert_eq!(Exit::Timeout.code(), 5); + assert_eq!(Exit::Transfer.code(), 6); + assert_eq!(Exit::SessionLost.code(), 7); + } + + #[test] + fn sanitize_keeps_records_on_one_line_with_stable_columns() { + assert_eq!(sanitize("a\tb\nc\r\nd"), "a b c d"); + assert_eq!( + sanitize("plain (name) [2024].mp3"), + "plain (name) [2024].mp3" + ); + } + + #[test] + fn search_text_is_user_size_bitrate_path() { + let text = search_record().text(); + let fields: Vec<&str> = text.split('\t').collect(); + assert_eq!(fields, ["bob", "4096", "320", "@@dir\\01 - Track.mp3"]); + } + + #[test] + fn unknown_bitrate_renders_as_a_dash() { + let record = SearchRecord { + bitrate: None, + ..search_record() + }; + assert_eq!(record.text().split('\t').nth(2), Some("-")); + } + + #[test] + fn search_json_carries_every_field() { + let json = serde_json::to_value(search_record()).unwrap(); + assert_eq!(json["user"], "bob"); + assert_eq!(json["size"], 4096); + assert_eq!(json["bitrate"], 320); + assert_eq!(json["duration"], 210); + assert_eq!(json["free_slot"], true); + assert_eq!(json["slots"], 1); + assert_eq!(json["speed"], 1000); + assert_eq!(json["path"], "@@dir\\01 - Track.mp3"); + } + + #[test] + fn a_tab_in_a_filename_cannot_forge_a_column() { + let record = SearchRecord { + path: "evil\tname.mp3".into(), + ..search_record() + }; + assert_eq!(record.text().split('\t').count(), 4); + } + + #[test] + fn browse_text_is_user_size_path() { + let record = BrowseRecord { + user: "bob".into(), + directory: "@@music".into(), + path: "@@music\\a.flac".into(), + size: 12, + bitrate: Some(320), + duration: None, + }; + assert_eq!(record.text(), "bob\t12\t@@music\\a.flac"); + } + + #[test] + fn download_text_is_just_the_local_path() { + let record = DownloadRecord { + user: "bob".into(), + path: "@@music\\a.flac".into(), + size: 12, + file: "/music/a.flac".into(), + }; + assert_eq!(record.text(), "/music/a.flac"); + } + + #[test] + fn room_text_leads_with_the_user_count() { + let record = RoomRecord { + room: "indie rock".into(), + users: 42, + }; + assert_eq!(record.text(), "42\tindie rock"); + } + + #[test] + fn room_event_type_is_first_and_serializes_as_type() { + let record = RoomEventRecord { + kind: "message", + room: "lobby".into(), + user: "bob".into(), + message: "hi".into(), + }; + assert_eq!(record.text(), "message\tlobby\tbob\thi"); + let json = serde_json::to_value(&record).unwrap(); + assert_eq!(json["type"], "message"); + } + + #[test] + fn portmap_text_reports_status_backend_and_address() { + let ok = PortmapRecord { + ok: true, + backend: "upnp".into(), + external: Some("1.2.3.4:2234".into()), + port: 2234, + }; + assert_eq!(ok.text(), "ok\tupnp\t1.2.3.4:2234"); + let failed = PortmapRecord { + ok: false, + backend: "none".into(), + external: None, + port: 2234, + }; + assert_eq!(failed.text(), "failed\tnone\t-"); + } +} diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs new file mode 100644 index 0000000..376f48b --- /dev/null +++ b/soulseek-rs/src/persist/config.rs @@ -0,0 +1,812 @@ +use color_eyre::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Optional settings read from `config.toml`. Every field is optional so a +/// partial file (or none at all) is valid; unknown keys are ignored so newer +/// configs still load in older builds. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FileConfig { + pub username: Option, + pub server: Option, + pub listener_port: Option, + pub disable_listener: Option, + /// Serve children in the distributed search network: other peers hang + /// from this client and every search it receives is passed down to them. + /// Off by default — it costs a socket and the network's whole search + /// stream per child, and it needs the listener. + pub accept_children: Option, + pub download_dir: Option, + /// Single shared folder (also what `--shared-dir` sets). Prefer + /// `shared_dirs` for multiple; both may be combined. + pub shared_dir: Option, + /// Multiple shared folders. An explicitly empty list disables sharing. + pub shared_dirs: Option>, + pub max_concurrent_downloads: Option, + pub search_timeout: Option, + /// Command whose stdout is the password (headless fallback, like mutt's + /// `password_cmd`). Never store the password itself in the file. + pub password_cmd: Option, + /// A daemon to control instead of opening a session of our own — + /// `host:port`, or a Unix socket path. Unset means "use the local one if + /// there is one", which is what a daemon on this machine wants. + pub daemon: Option, + /// The token for a remote daemon. Not a secret about *this* account, and + /// useless without reaching the host, so unlike a password it is + /// reasonable to keep here rather than behind a command. + pub daemon_token: Option, + /// Standing searches the server lets us repeat once per wishlist interval. + /// A TOML array rather than a `config set` key, because a query may itself + /// contain a comma. + pub wishlist: Option>, + pub use_username_dirs: Option, +} + +impl FileConfig { + /// Every setting `config get`/`set` accepts, in the order `config list` + /// prints them. Keys match the TOML field names exactly, so what a script + /// reads back is what it would have written by hand. + pub const KEYS: &'static [&'static str] = &[ + "username", + "server", + "listener_port", + "disable_listener", + "accept_children", + "download_dir", + "shared_dirs", + "max_concurrent_downloads", + "search_timeout", + "password_cmd", + "daemon", + "daemon_token", + "use_username_dirs", + ]; + + /// The value of `key`, or `None` when it is unset or unknown. Lists are + /// rendered comma-separated, matching what [`Self::set`] accepts. + #[must_use] + pub fn get(&self, key: &str) -> Option { + match key { + "username" => self.username.clone(), + "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(); + (!dirs.is_empty()).then(|| dirs.join(",")) + } + "max_concurrent_downloads" => { + self.max_concurrent_downloads.map(|v| v.to_string()) + } + "search_timeout" => self.search_timeout.map(|v| v.to_string()), + "password_cmd" => self.password_cmd.clone(), + "daemon" => self.daemon.clone(), + "daemon_token" => self.daemon_token.clone(), + "use_username_dirs" => { + self.use_username_dirs.map(|v| v.to_string()) + } + _ => None, + } + } + + /// Set `key` to `value`, or clear it when `value` is empty. + /// + /// # Errors + /// Returns a message naming the problem when the key is unknown or the + /// value does not parse as that setting's type. + pub fn set( + &mut self, + key: &str, + value: &str, + ) -> std::result::Result<(), String> { + let value = value.trim(); + let clear = value.is_empty(); + let text = |target: &mut Option| { + *target = (!clear).then(|| value.to_string()); + }; + match key { + "username" => text(&mut self.username), + "server" => text(&mut self.server), + "download_dir" => text(&mut self.download_dir), + "password_cmd" => text(&mut self.password_cmd), + "daemon" => text(&mut self.daemon), + "daemon_token" => text(&mut self.daemon_token), + "listener_port" => { + self.listener_port = parse_opt(value, clear, key)?; + } + "max_concurrent_downloads" => { + self.max_concurrent_downloads = parse_opt(value, clear, key)?; + } + "search_timeout" => { + self.search_timeout = parse_opt(value, clear, key)?; + } + "disable_listener" => { + self.disable_listener = if clear { + None + } else { + Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "disable_listener wants true or false, got \ + '{other}'" + )); + } + }) + }; + } + "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 + .split(',') + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .map(String::from) + .collect() + }); + // The single-folder spelling would otherwise shadow the list. + self.shared_dir = None; + } + "use_username_dirs" => { + self.use_username_dirs = Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "use_username_dirs wants true or false, got '{other}'" + )); + } + }); + } + other => { + return Err(format!( + "unknown setting '{other}' — try one of: {}", + Self::KEYS.join(", ") + )); + } + } + Ok(()) + } + + /// The standing searches, in the order they were added. + #[must_use] + pub fn wishes(&self) -> Vec { + self.wishlist.clone().unwrap_or_default() + } + + /// Add `query` to the wishlist. Returns whether it changed anything: a + /// blank query and a case-insensitive repeat are both refused, so adding + /// the same wish twice cannot fill the file with duplicates the server + /// would then be asked about twice per interval. + pub fn add_wish(&mut self, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() || self.find_wish(query).is_some() { + return false; + } + self.wishlist + .get_or_insert_with(Vec::new) + .push(query.to_string()); + true + } + + /// Remove `query`, matched the same way [`Self::add_wish`] compares. + /// Returns whether it was there. + pub fn remove_wish(&mut self, query: &str) -> bool { + let Some(index) = self.find_wish(query.trim()) else { + return false; + }; + self.wishlist.get_or_insert_with(Vec::new).remove(index); + true + } + + /// The stored wish matching `query`, compared the way [`Self::add_wish`] + /// compares. Callers that need to act on a named wish go through this so + /// "the same wish" means one thing everywhere. + #[must_use] + pub fn wish(&self, query: &str) -> Option<&String> { + let index = self.find_wish(query.trim())?; + self.wishlist.as_ref()?.get(index) + } + + fn find_wish(&self, query: &str) -> Option { + let wanted = query.to_lowercase(); + self.wishlist + .as_ref()? + .iter() + .position(|wish| wish.to_lowercase() == wanted) + } + + /// Load from `path`; a missing file is an empty config, a malformed file + /// is an error (silently ignoring a typo'd config would be worse). + pub fn load(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(e) => { + return Err(color_eyre::eyre::eyre!( + "Cannot read {}: {e}", + path.display() + )); + } + }; + toml::from_str(&text).map_err(|e| { + color_eyre::eyre::eyre!("Malformed {}: {e}", path.display()) + }) + } + + /// Save to `path`, creating parent directories as needed. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, toml::to_string_pretty(self)?)?; + Ok(()) + } +} + +/// Parse a setting that is not a string, turning a bad value into a message +/// naming the key rather than a bare parse error. +fn parse_opt( + value: &str, + clear: bool, + key: &str, +) -> std::result::Result, String> { + if clear { + return Ok(None); + } + value + .parse::() + .map(Some) + .map_err(|_| format!("{key} does not accept '{value}'")) +} + +/// Fully-resolved settings after layering CLI (which already includes env via +/// clap) over the config file over built-in defaults. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + pub username: Option, + pub server: String, + pub listener_port: u16, + pub disable_listener: bool, + pub accept_children: bool, + pub download_dir: String, + pub shared_dirs: Vec, + pub use_username_dirs: bool, + pub max_concurrent_downloads: usize, + pub search_timeout: u64, + pub password_cmd: Option, + pub daemon: Option, + pub daemon_token: Option, +} + +pub const DEFAULT_SERVER: &str = "server.slsknet.org:2416"; +pub const DEFAULT_LISTENER_PORT: u16 = 2234; +/// How many downloads run at once by default. +/// +/// Downstream is the abundant direction, and a Soulseek transfer is paced by +/// the sending peer — usually a few hundred KiB/s — so filling a modern link +/// takes many transfers at once, not faster ones. Twenty at a typical peer rate +/// is tens of MiB/s, and costs twenty threads and sockets, which is nothing. +pub const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 20; +pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10; + +/// Layer CLI/env values over the config file over defaults. +/// +/// The listener has an explicit form in both directions: `--listener` beats +/// `--no-listener` (and `SOULSEEK_NO_LISTENER`), which beats the file's +/// `disable_listener`, so a config file can always be overridden per run. +#[must_use] +pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { + let download_dir = cli + .download_dir + .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 + .server + .clone() + .or_else(|| file.server.clone()) + .unwrap_or_else(|| DEFAULT_SERVER.to_string()), + listener_port: cli + .listener_port + .or(file.listener_port) + .unwrap_or(DEFAULT_LISTENER_PORT), + 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), + use_username_dirs: file.use_username_dirs.unwrap_or(false), + max_concurrent_downloads: cli + .max_concurrent_downloads + .or(file.max_concurrent_downloads) + .unwrap_or(DEFAULT_MAX_CONCURRENT_DOWNLOADS), + search_timeout: cli + .search_timeout + .or(file.search_timeout) + .unwrap_or(DEFAULT_SEARCH_TIMEOUT), + password_cmd: cli + .password_cmd + .clone() + .or_else(|| file.password_cmd.clone()), + daemon: cli.daemon.clone().or_else(|| file.daemon.clone()), + daemon_token: cli + .daemon_token + .clone() + .or_else(|| file.daemon_token.clone()), + } +} + +/// Sharing follows the Soulseek convention of sharing what you download: +/// with nothing configured, the download folder is shared. Configuring any +/// of `--shared-dir` / `shared_dir` / `shared_dirs` replaces that default +/// (their non-empty values are combined), and an explicitly empty value +/// (`shared_dir = ""` or `shared_dirs = []`) disables sharing entirely. +fn resolve_shared_dirs( + cli: &crate::cli::Cli, + file: &FileConfig, + download_dir: &str, +) -> Vec { + if cli.shared_dir.is_empty() + && file.shared_dir.is_none() + && file.shared_dirs.is_none() + { + return vec![download_dir.to_string()]; + } + let mut dirs: Vec = Vec::new(); + let singles = cli.shared_dir.iter().chain(file.shared_dir.iter()); + for dir in singles.chain(file.shared_dirs.iter().flatten()) { + let dir = dir.trim(); + if !dir.is_empty() && !dirs.iter().any(|d| d == dir) { + dirs.push(dir.to_string()); + } + } + dirs +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + + fn bare_cli() -> Cli { + Cli::default() + } + + #[test] + fn defaults_apply_when_cli_and_file_are_empty() { + let resolved = resolve(&bare_cli(), &FileConfig::default()); + assert_eq!(resolved.server, DEFAULT_SERVER); + assert_eq!(resolved.listener_port, DEFAULT_LISTENER_PORT); + assert_eq!( + resolved.max_concurrent_downloads, + DEFAULT_MAX_CONCURRENT_DOWNLOADS + ); + assert_eq!(resolved.search_timeout, DEFAULT_SEARCH_TIMEOUT); + assert!(!resolved.disable_listener); + assert_eq!(resolved.username, None); + } + + #[test] + fn default_download_dir_is_a_soulseek_folder_under_downloads() { + let resolved = resolve(&bare_cli(), &FileConfig::default()); + let path = std::path::Path::new(&resolved.download_dir); + assert!(path.is_absolute(), "must not rely on ~ expansion"); + assert_eq!(path.file_name().and_then(|n| n.to_str()), Some("Soulseek")); + assert_eq!( + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()), + Some("Downloads") + ); + } + + #[test] + fn sharing_defaults_to_the_download_dir() { + let resolved = resolve(&bare_cli(), &FileConfig::default()); + assert_eq!(resolved.shared_dirs, vec![resolved.download_dir.clone()]); + } + + #[test] + fn sharing_follows_a_customized_download_dir() { + let file = FileConfig { + download_dir: Some("/music".into()), + ..FileConfig::default() + }; + let resolved = resolve(&bare_cli(), &file); + assert_eq!(resolved.shared_dirs, vec!["/music".to_string()]); + } + + #[test] + fn empty_shared_dir_disables_sharing() { + let file = FileConfig { + shared_dir: Some(String::new()), + ..FileConfig::default() + }; + let resolved = resolve(&bare_cli(), &file); + assert!(resolved.shared_dirs.is_empty()); + } + + #[test] + fn empty_shared_dirs_list_disables_sharing() { + let file = FileConfig { + shared_dirs: Some(Vec::new()), + ..FileConfig::default() + }; + let resolved = resolve(&bare_cli(), &file); + assert!(resolved.shared_dirs.is_empty()); + } + + #[test] + fn shared_dirs_list_replaces_the_default_share() { + let file = FileConfig { + shared_dirs: Some(vec!["/a".into(), "/b".into()]), + ..FileConfig::default() + }; + let resolved = resolve(&bare_cli(), &file); + assert_eq!( + resolved.shared_dirs, + vec!["/a".to_string(), "/b".to_string()] + ); + } + + #[test] + fn cli_shared_dir_combines_with_file_list_and_dedupes() { + let mut cli = bare_cli(); + cli.shared_dir = vec!["/a".into()]; + let file = FileConfig { + shared_dirs: Some(vec!["/a".into(), "/b".into()]), + ..FileConfig::default() + }; + let resolved = resolve(&cli, &file); + assert_eq!( + resolved.shared_dirs, + vec!["/a".to_string(), "/b".to_string()] + ); + } + + #[test] + fn repeated_cli_shared_dirs_are_all_kept() { + let mut cli = bare_cli(); + cli.shared_dir = vec!["/a".into(), "/b".into()]; + let resolved = resolve(&cli, &FileConfig::default()); + assert_eq!( + resolved.shared_dirs, + vec!["/a".to_string(), "/b".to_string()] + ); + } + + #[test] + fn an_empty_cli_shared_dir_disables_sharing() { + let mut cli = bare_cli(); + cli.shared_dir = vec![String::new()]; + let resolved = resolve(&cli, &FileConfig::default()); + assert!(resolved.shared_dirs.is_empty()); + } + + #[test] + fn no_listener_flag_disables_the_listener() { + let mut cli = bare_cli(); + cli.no_listener = true; + assert!(resolve(&cli, &FileConfig::default()).disable_listener); + } + + #[test] + fn listener_flag_overrides_the_config_file() { + let file = FileConfig { + disable_listener: Some(true), + ..FileConfig::default() + }; + assert!(resolve(&bare_cli(), &file).disable_listener); + + let mut cli = bare_cli(); + cli.listener = true; + assert!(!resolve(&cli, &file).disable_listener); + } + + #[test] + fn listener_flag_wins_over_no_listener() { + let mut cli = bare_cli(); + cli.listener = true; + cli.no_listener = true; + 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 { + username: Some("alice".into()), + 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, + max_concurrent_downloads: Some(2), + search_timeout: Some(30), + password_cmd: Some("pass show slsk".into()), + daemon: Some("nas.local:5030".into()), + daemon_token: Some("deadbeef".into()), + wishlist: None, + use_username_dirs: Some(false), + }; + let resolved = resolve(&bare_cli(), &file); + assert_eq!(resolved.username.as_deref(), Some("alice")); + assert_eq!(resolved.server, "localhost:2242"); + assert_eq!(resolved.listener_port, 4321); + assert!(resolved.disable_listener); + assert_eq!(resolved.download_dir, "/music"); + assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]); + assert_eq!(resolved.daemon.as_deref(), Some("nas.local:5030")); + assert_eq!(resolved.daemon_token.as_deref(), Some("deadbeef")); + assert_eq!(resolved.max_concurrent_downloads, 2); + assert_eq!(resolved.search_timeout, 30); + assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk")); + } + + #[test] + fn cli_values_override_file_values() { + let mut cli = bare_cli(); + cli.username = Some("cli-user".into()); + cli.server = Some("cli-server:1".into()); + cli.listener_port = Some(1111); + cli.download_dir = Some("/cli-dl".into()); + let file = FileConfig { + username: Some("file-user".into()), + server: Some("file-server:2".into()), + listener_port: Some(2222), + download_dir: Some("/file-dl".into()), + ..FileConfig::default() + }; + let resolved = resolve(&cli, &file); + assert_eq!(resolved.username.as_deref(), Some("cli-user")); + assert_eq!(resolved.server, "cli-server:1"); + assert_eq!(resolved.listener_port, 1111); + assert_eq!(resolved.download_dir, "/cli-dl"); + } + + #[test] + fn every_advertised_key_round_trips_through_get_and_set() { + let mut config = FileConfig::default(); + for key in FileConfig::KEYS { + assert!(config.get(key).is_none(), "{key} should start unset"); + } + + for (key, value) in [ + ("username", "alice"), + ("server", "localhost:2242"), + ("listener_port", "4321"), + ("disable_listener", "true"), + ("download_dir", "/music"), + ("shared_dirs", "/a,/b"), + ("max_concurrent_downloads", "3"), + ("search_timeout", "30"), + ("password_cmd", "pass show slsk"), + ] { + config.set(key, value).expect("should accept"); + assert_eq!( + config.get(key).as_deref(), + Some(value), + "{key} should read back what was written" + ); + } + assert_eq!(config.shared_dirs, Some(vec!["/a".into(), "/b".into()])); + } + + #[test] + fn an_empty_value_clears_a_setting() { + let mut config = FileConfig { + username: Some("alice".into()), + listener_port: Some(2234), + ..FileConfig::default() + }; + config.set("username", "").unwrap(); + config.set("listener_port", "").unwrap(); + assert_eq!(config.username, None); + assert_eq!(config.listener_port, None); + } + + #[test] + fn a_value_of_the_wrong_type_is_rejected_by_name() { + let mut config = FileConfig::default(); + let error = config.set("listener_port", "not-a-port").unwrap_err(); + assert!(error.contains("listener_port"), "got {error}"); + assert!(config.get("listener_port").is_none(), "must not be set"); + + let error = config.set("disable_listener", "maybe").unwrap_err(); + assert!(error.contains("true or false"), "got {error}"); + } + + #[test] + fn an_unknown_key_lists_the_ones_that_exist() { + let mut config = FileConfig::default(); + let error = config.set("colour", "blue").unwrap_err(); + assert!(error.contains("unknown setting"), "got {error}"); + assert!(error.contains("username"), "should suggest real keys"); + assert!(config.get("colour").is_none()); + } + + #[test] + fn setting_the_share_list_drops_the_single_folder_spelling() { + // Both spellings surviving would make the effective share set depend + // on resolution order rather than on what was just written. + let mut config = FileConfig { + shared_dir: Some("/old".into()), + ..FileConfig::default() + }; + config.set("shared_dirs", "/new").unwrap(); + assert_eq!(config.shared_dir, None); + assert_eq!(config.shared_dirs, Some(vec!["/new".to_string()])); + } + + #[test] + fn a_set_value_survives_a_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut config = FileConfig::default(); + config.set("download_dir", "/music").unwrap(); + config.set("max_concurrent_downloads", "2").unwrap(); + config.save(&path).unwrap(); + + let loaded = FileConfig::load(&path).unwrap(); + assert_eq!(loaded.get("download_dir").as_deref(), Some("/music")); + assert_eq!( + loaded.get("max_concurrent_downloads").as_deref(), + Some("2") + ); + } + + #[test] + fn missing_file_loads_as_empty_config() { + let dir = tempfile::tempdir().unwrap(); + let config = FileConfig::load(&dir.path().join("config.toml")).unwrap(); + assert_eq!(config, FileConfig::default()); + } + + #[test] + fn save_then_load_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + let config = FileConfig { + username: Some("alice".into()), + server: Some("localhost:2242".into()), + listener_port: Some(2234), + max_concurrent_downloads: Some(3), + ..FileConfig::default() + }; + config.save(&path).unwrap(); + assert_eq!(FileConfig::load(&path).unwrap(), config); + } + + #[test] + fn a_wish_is_added_once_however_it_is_spelled() { + let mut config = FileConfig::default(); + assert!(config.add_wish("Aphex Twin")); + assert!(!config.add_wish("aphex twin"), "a repeat is not a new wish"); + assert!(!config.add_wish(" Aphex Twin "), "surrounding space too"); + assert_eq!(config.wishes(), ["Aphex Twin"]); + } + + #[test] + fn an_empty_wish_is_refused() { + let mut config = FileConfig::default(); + assert!(!config.add_wish(" ")); + assert!(config.wishes().is_empty()); + } + + #[test] + fn removing_a_wish_ignores_case_and_reports_whether_it_was_there() { + let mut config = FileConfig::default(); + config.add_wish("Boards of Canada"); + assert!(config.remove_wish("BOARDS OF CANADA")); + assert!(config.wishes().is_empty()); + assert!(!config.remove_wish("never added")); + } + + #[test] + fn wishes_keep_the_order_they_were_added_in() { + let mut config = FileConfig::default(); + for wish in ["first", "second", "third"] { + config.add_wish(wish); + } + config.remove_wish("second"); + assert_eq!(config.wishes(), ["first", "third"]); + } + + #[test] + fn a_wishlist_survives_a_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut config = FileConfig::default(); + config.add_wish("autechre, incunabula"); + config.add_wish("plaid"); + config.save(&path).unwrap(); + assert_eq!( + FileConfig::load(&path).unwrap().wishes(), + ["autechre, incunabula", "plaid"], + "a comma inside a wish must survive the round trip" + ); + } + + #[test] + fn unknown_keys_and_partial_files_are_tolerated() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "username = \"bob\"\nfuture_option = true\n") + .unwrap(); + let config = FileConfig::load(&path).unwrap(); + assert_eq!(config.username.as_deref(), Some("bob")); + assert_eq!(config.server, None); + } + + #[test] + fn malformed_file_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "username = [unclosed").unwrap(); + assert!(FileConfig::load(&path).is_err()); + } +} diff --git a/soulseek-rs/src/persist/mod.rs b/soulseek-rs/src/persist/mod.rs new file mode 100644 index 0000000..1c92b6d --- /dev/null +++ b/soulseek-rs/src/persist/mod.rs @@ -0,0 +1,11 @@ +//! Persistence for the TUI binary: config file, secrets, and state. +//! +//! Everything here is deliberately outside `soulseek-rs-lib` (which stays +//! zero-dependency); the lib keeps receiving plain values via +//! `ClientSettings`. + +pub mod config; +pub mod paths; +pub mod secret; +pub mod snapshot; +pub mod state; diff --git a/soulseek-rs/src/persist/paths.rs b/soulseek-rs/src/persist/paths.rs new file mode 100644 index 0000000..b002f57 --- /dev/null +++ b/soulseek-rs/src/persist/paths.rs @@ -0,0 +1,76 @@ +#[cfg(not(target_os = "macos"))] +use directories::ProjectDirs; +use std::path::PathBuf; + +/// Platform-appropriate locations for the config file and state directory. +/// Overridable via `SOULSEEK_CONFIG_DIR` / `SOULSEEK_STATE_DIR` (used by the +/// e2e tests, and handy for portable installs). +#[must_use] +pub fn config_file() -> Option { + if let Ok(dir) = std::env::var("SOULSEEK_CONFIG_DIR") { + return Some(PathBuf::from(dir).join("config.toml")); + } + config_dir().map(|d| d.join("config.toml")) +} + +#[must_use] +pub fn state_dir() -> Option { + if let Ok(dir) = std::env::var("SOULSEEK_STATE_DIR") { + return Some(PathBuf::from(dir)); + } + data_dir().map(|d| d.join("state")) +} + +// macOS follows the XDG layout (~/.config, ~/.local/share) instead of +// Application Support, by request. Other platforms keep the crate defaults. +#[cfg(target_os = "macos")] +fn config_dir() -> Option { + xdg_dir("XDG_CONFIG_HOME", ".config") +} + +#[cfg(target_os = "macos")] +fn data_dir() -> Option { + xdg_dir("XDG_DATA_HOME", ".local/share") +} + +#[cfg(target_os = "macos")] +fn xdg_dir(env: &str, fallback: &str) -> Option { + let base = match std::env::var_os(env) { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => directories::UserDirs::new()?.home_dir().join(fallback), + }; + Some(base.join("soulseek-rs")) +} + +#[cfg(not(target_os = "macos"))] +fn config_dir() -> Option { + project_dirs().map(|d| d.config_dir().to_path_buf()) +} + +#[cfg(not(target_os = "macos"))] +fn data_dir() -> Option { + project_dirs().map(|d| d.data_dir().to_path_buf()) +} + +#[cfg(not(target_os = "macos"))] +fn project_dirs() -> Option { + ProjectDirs::from("", "", "soulseek-rs") +} + +/// Default download (and shared) folder: a `Soulseek` subfolder of the +/// platform Downloads directory (XDG on Linux, known-folder on Windows), +/// so Soulseek files don't clutter the Downloads root. +#[must_use] +pub fn default_download_dir() -> String { + let user_dirs = directories::UserDirs::new(); + let downloads = user_dirs + .as_ref() + .and_then(|dirs| dirs.download_dir().map(std::path::Path::to_path_buf)) + .or_else(|| { + user_dirs + .as_ref() + .map(|dirs| dirs.home_dir().join("Downloads")) + }) + .unwrap_or_else(|| std::path::PathBuf::from("Downloads")); + downloads.join("Soulseek").display().to_string() +} diff --git a/soulseek-rs/src/persist/secret.rs b/soulseek-rs/src/persist/secret.rs new file mode 100644 index 0000000..7d41f1a --- /dev/null +++ b/soulseek-rs/src/persist/secret.rs @@ -0,0 +1,243 @@ +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. +/// +/// `Send + Sync` because the daemon holds one behind an `Arc` and answers +/// requests on a thread per connection. +pub trait SecretStore: Send + Sync { + fn get(&self, username: &str) -> Result>; + fn set(&self, username: &str, password: &str) -> Result<()>; + /// Forget this account's password. Succeeds when there was nothing + /// stored: the caller wants it gone, and it is. + fn delete(&self, username: &str) -> Result<()>; +} + +/// OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret +/// Service) under service name `soulseek-rs`, account = Soulseek username. +pub struct KeyringStore; + +const SERVICE: &str = "soulseek-rs"; + +impl SecretStore for KeyringStore { + fn get(&self, username: &str) -> Result> { + let entry = keyring::Entry::new(SERVICE, username) + .map_err(|e| color_eyre::eyre::eyre!("keyring: {e}"))?; + match entry.get_password() { + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")), + } + } + + fn set(&self, username: &str, password: &str) -> Result<()> { + keyring::Entry::new(SERVICE, username) + .and_then(|entry| entry.set_password(password)) + .map_err(|e| color_eyre::eyre::eyre!("keyring: {e}")) + } + + fn delete(&self, username: &str) -> Result<()> { + match keyring::Entry::new(SERVICE, username) + .and_then(|entry| entry.delete_credential()) + { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")), + } + } +} + +/// Change the account's password and keep the local copy in step, so the next +/// start still logs in. +/// +/// One rule in one place: whoever owns the login owns the stored copy. A +/// window or a command borrowing a daemon's session owns neither, and the +/// daemon writes its own host's store when it serves the request. +/// +/// `Ok` means the request reached the server, not that the server took it — +/// Soulseek answers a password change with nothing at all — and carries the +/// store's complaint when the change went out but the local copy did not. +pub fn change_password( + session: &dyn SessionApi, + secrets: &dyn SecretStore, + password: &str, +) -> soulseek_rs::Result> { + session.change_password(password)?; + if session.daemon_endpoint().is_some() { + return Ok(None); + } + Ok(secrets + .set(&session.username(), password) + .err() + .map(|e| e.to_string())) +} + +/// Resolve the password with precedence: CLI/env > keychain > `password_cmd`. +/// +/// `cli_password` already has env merged in by clap. Returns `None` when no +/// source has one — the caller shows the login screen. +/// +/// Keychain errors are downgraded to "not found": on headless boxes without +/// a secret service the fallbacks must still work. +pub fn resolve_password( + cli_password: Option<&str>, + username: Option<&str>, + password_cmd: Option<&str>, + store: &dyn SecretStore, +) -> Option { + if let Some(password) = cli_password { + return Some(password.to_string()); + } + if let Some(username) = username + && let Ok(Some(password)) = store.get(username) + { + return Some(password); + } + password_cmd.and_then(run_password_cmd) +} + +/// Run `password_cmd` through the platform shell and return trimmed stdout. +fn run_password_cmd(cmd: &str) -> Option { + let output = if cfg!(windows) { + std::process::Command::new("cmd").args(["/C", cmd]).output() + } else { + std::process::Command::new("sh").args(["-c", cmd]).output() + } + .ok()?; + if !output.status.success() { + return None; + } + let password = String::from_utf8(output.stdout).ok()?; + let password = password.trim_end_matches(['\r', '\n']); + (!password.is_empty()).then(|| password.to_string()) +} + +/// An in-memory store for tests, so no test touches the machine's keychain. +#[cfg(test)] +#[derive(Default)] +pub struct MemoryStore { + secrets: std::sync::Mutex>, + /// Answer every read as if there were no secret service at all. + pub fail: bool, +} + +#[cfg(test)] +impl SecretStore for MemoryStore { + fn get(&self, username: &str) -> Result> { + if self.fail { + return Err(color_eyre::eyre::eyre!("no secret service")); + } + Ok(self + .secrets + .lock() + .expect("not poisoned") + .get(username) + .cloned()) + } + + fn set(&self, username: &str, password: &str) -> Result<()> { + self.secrets + .lock() + .expect("not poisoned") + .insert(username.into(), password.into()); + Ok(()) + } + + fn delete(&self, username: &str) -> Result<()> { + self.secrets.lock().expect("not poisoned").remove(username); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type FakeStore = MemoryStore; + + #[test] + fn cli_password_wins_over_everything() { + let store = FakeStore::default(); + store.set("alice", "from-keyring").unwrap(); + let got = resolve_password( + Some("from-cli"), + Some("alice"), + Some("echo from-cmd"), + &store, + ); + assert_eq!(got.as_deref(), Some("from-cli")); + } + + #[test] + fn keyring_used_when_no_cli_password() { + let store = FakeStore::default(); + store.set("alice", "from-keyring").unwrap(); + let got = resolve_password(None, Some("alice"), None, &store); + assert_eq!(got.as_deref(), Some("from-keyring")); + } + + #[cfg(unix)] + #[test] + fn password_cmd_used_when_keyring_has_nothing() { + let store = FakeStore::default(); + let got = resolve_password( + None, + Some("alice"), + Some("echo from-cmd"), + &store, + ); + assert_eq!(got.as_deref(), Some("from-cmd")); + } + + #[cfg(unix)] + #[test] + fn keyring_failure_falls_through_to_password_cmd() { + let store = FakeStore { + fail: true, + ..FakeStore::default() + }; + let got = resolve_password( + None, + Some("alice"), + Some("echo from-cmd"), + &store, + ); + assert_eq!(got.as_deref(), Some("from-cmd")); + } + + #[test] + fn none_when_no_source_has_a_password() { + let store = FakeStore::default(); + 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 { + fail: true, + ..FakeStore::default() + }; + assert_eq!(resolve_password(None, None, None, &store), None); + } + + #[cfg(unix)] + #[test] + fn failing_password_cmd_yields_none() { + let store = FakeStore::default(); + assert_eq!( + resolve_password(None, Some("alice"), Some("false"), &store), + None + ); + } +} diff --git a/soulseek-rs/src/persist/snapshot.rs b/soulseek-rs/src/persist/snapshot.rs new file mode 100644 index 0000000..554f3c8 --- /dev/null +++ b/soulseek-rs/src/persist/snapshot.rs @@ -0,0 +1,298 @@ +//! Capture the persistable slice of [`AppState`] (and restore the pure +//! parts of it). Live handles (channels, cancel flags) never leave the +//! process; only plain data goes to disk. + +use super::state::{PersistedDownload, PersistedMessage}; +use crate::models::{ + AppState, ChatMessage, MessageDirection, SearchEntry, SearchStatus, +}; + +/// How much private-message history survives a restart. +/// a flat cap on the whole log rather than per conversation; make +/// it per-peer if one chatty contact starts evicting everyone else. +const MESSAGE_HISTORY_LIMIT: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Snapshot { + pub downloads: Vec, + pub queries: Vec, + pub rooms: Vec, + pub messages: Vec, + /// Unread private-message count, so the badge survives a restart. Room + /// unread is deliberately not persisted: room chat logs are not saved, so + /// a restored room count would flag messages that no longer exist. + pub unread_messages: usize, +} + +impl Snapshot { + /// Extract what should survive a restart. Downloads that are not yet + /// `Completed` (including failed/timed-out ones) are marked incomplete + /// so the next start can re-enqueue them. + #[must_use] + pub fn capture(state: &AppState) -> Self { + let downloads = state + .downloads + .iter() + .filter_map(|entry| PersistedDownload::capture(&entry.download)) + .collect(); + + let mut queries: Vec = Vec::new(); + for entry in &state.searches { + if !queries.contains(&entry.query) { + queries.push(entry.query.clone()); + } + } + + let rooms = state + .rooms + .open + .iter() + .map(|room| room.name.clone()) + .collect(); + + // Keep the newest messages; older ones fall off the end. + let start = state.messages.len().saturating_sub(MESSAGE_HISTORY_LIMIT); + let messages = state.messages[start..] + .iter() + .map(|message| PersistedMessage { + peer: message.peer.clone(), + outgoing: message.direction == MessageDirection::Outgoing, + text: message.text.clone(), + at: message.at, + }) + .collect(); + + Self { + downloads, + queries, + rooms, + messages, + unread_messages: state.unread_messages, + } + } +} + +/// Rebuild the private-message log from disk. +pub fn restore_messages(state: &mut AppState, messages: &[PersistedMessage]) { + for message in messages { + state.messages.push(ChatMessage { + direction: if message.outgoing { + MessageDirection::Outgoing + } else { + MessageDirection::Incoming + }, + peer: message.peer.clone(), + text: message.text.clone(), + at: message.at, + }); + } +} + +/// Rebuild search history entries from persisted query strings. Restored +/// searches are inert (completed, no results) — selecting one shows an +/// empty result set until it is re-run. +pub fn restore_searches(state: &mut AppState, queries: &[String]) { + for query in queries { + state.searches.push(SearchEntry { + query: query.clone(), + status: SearchStatus::Completed, + results: Vec::new(), + known_files: 0, + owned: false, + start_time: std::time::Instant::now(), + cancel_flag: std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::DownloadEntry; + use soulseek_rs::DownloadStatus; + use soulseek_rs::types::{Download, DownloadMetadata}; + use std::sync::atomic::AtomicBool; + use std::sync::{Arc, mpsc}; + use std::time::Instant; + + fn download(filename: &str, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "peer".into(), + filename: filename.into(), + token: 7, + size: 42, + download_directory: "/music".into(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + fn search(query: &str) -> SearchEntry { + SearchEntry { + query: query.into(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: false, + start_time: Instant::now(), + cancel_flag: Arc::new(AtomicBool::new(false)), + } + } + + #[test] + fn capture_maps_download_status_to_completed_flag() { + let mut state = AppState::new(); + state + .downloads + .push(download("done.mp3", DownloadStatus::Completed)); + state + .downloads + .push(download("queued.mp3", DownloadStatus::Queued)); + state.downloads.push(download( + "failed.mp3", + DownloadStatus::Failed(Some("nope".into())), + )); + + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot + .downloads + .iter() + .map(|d| (d.filename.as_str(), d.completed)) + .collect::>(), + vec![ + ("done.mp3", true), + ("queued.mp3", false), + ("failed.mp3", false) + ] + ); + assert_eq!(snapshot.downloads[0].username, "peer"); + assert_eq!(snapshot.downloads[0].size, 42); + assert_eq!(snapshot.downloads[0].download_directory, "/music"); + } + + #[test] + fn a_cancelled_download_is_not_captured() { + let mut state = AppState::new(); + state + .downloads + .push(download("gone.mp3", DownloadStatus::Cancelled)); + state + .downloads + .push(download("queued.mp3", DownloadStatus::Queued)); + + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot + .downloads + .iter() + .map(|d| d.filename.as_str()) + .collect::>(), + vec!["queued.mp3"], + "a cancel must not come back as a retry after a restart" + ); + } + + #[test] + fn capture_takes_queries_in_order_without_duplicates() { + let mut state = AppState::new(); + state.searches.push(search("beatles")); + state.searches.push(search("miles davis")); + state.searches.push(search("beatles")); + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot.queries, + vec!["beatles".to_string(), "miles davis".to_string()] + ); + } + + #[test] + fn capture_carries_the_unread_count() { + let mut state = AppState::new(); + state.unread_messages = 3; + assert_eq!(Snapshot::capture(&state).unread_messages, 3); + } + + #[test] + fn capture_takes_open_room_names() { + let mut state = AppState::new(); + state.rooms.focus_or_open("indie"); + state.rooms.focus_or_open("jazz"); + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot.rooms, + vec!["indie".to_string(), "jazz".to_string()] + ); + } + + #[test] + fn restore_searches_creates_inert_completed_entries() { + let mut state = AppState::new(); + restore_searches( + &mut state, + &["beatles".to_string(), "miles davis".to_string()], + ); + assert_eq!(state.searches.len(), 2); + assert_eq!(state.searches[0].query, "beatles"); + assert_eq!(state.searches[0].status, SearchStatus::Completed); + assert!(state.searches[0].results.is_empty()); + } + + #[test] + fn messages_round_trip_through_a_snapshot() { + let mut state = AppState::new(); + state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: "alice".into(), + text: "hey".into(), + at: chrono::Local::now(), + }); + state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: "alice".into(), + text: "hey yourself".into(), + at: chrono::Local::now(), + }); + + let snapshot = Snapshot::capture(&state); + let mut restored = AppState::new(); + restore_messages(&mut restored, &snapshot.messages); + + assert_eq!(Snapshot::capture(&restored).messages, snapshot.messages); + assert_eq!(restored.messages[1].text, "hey yourself"); + assert_eq!(restored.messages[1].direction, MessageDirection::Outgoing); + assert_eq!(restored.active_chat_peer(), Some("alice")); + } + + #[test] + fn capture_keeps_only_the_newest_messages() { + let mut state = AppState::new(); + for i in 0..MESSAGE_HISTORY_LIMIT + 10 { + state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: "alice".into(), + text: i.to_string(), + at: chrono::Local::now(), + }); + } + let snapshot = Snapshot::capture(&state); + assert_eq!(snapshot.messages.len(), MESSAGE_HISTORY_LIMIT); + assert_eq!(snapshot.messages[0].text, "10", "oldest are dropped"); + } + + #[test] + fn capture_of_restored_searches_round_trips() { + let mut state = AppState::new(); + restore_searches(&mut state, &["beatles".to_string()]); + let snapshot = Snapshot::capture(&state); + assert_eq!(snapshot.queries, vec!["beatles".to_string()]); + } +} diff --git a/soulseek-rs/src/persist/state.rs b/soulseek-rs/src/persist/state.rs new file mode 100644 index 0000000..ffb6c54 --- /dev/null +++ b/soulseek-rs/src/persist/state.rs @@ -0,0 +1,296 @@ +//! Versioned JSON state files (downloads, search queries, open rooms, +//! private-message history). +//! +//! Each file is an envelope `{ "version": N, "data": ... }`. State is +//! disposable: a missing, corrupt, or newer-than-known file loads as empty +//! rather than failing startup. Writes are atomic (tmp file + rename) so a +//! crash never leaves a torn file. + +use color_eyre::Result; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use soulseek_rs::{DownloadStatus, types::Download}; +use std::path::{Path, PathBuf}; + +/// The shape every file is written in today. Bump it — and convert the older +/// shapes in `load` — when one of them changes. +const VERSION: u32 = 0; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +pub struct PersistedDownload { + pub username: String, + pub filename: String, + pub size: u64, + pub download_directory: String, + pub completed: bool, +} + +impl PersistedDownload { + #[must_use] + pub fn capture(download: &Download) -> Option { + if matches!(download.status, DownloadStatus::Cancelled) { + return None; + } + Some(Self { + username: download.username.clone(), + filename: download.filename.clone(), + size: download.size, + download_directory: download.download_directory.clone(), + completed: matches!(download.status, DownloadStatus::Completed), + }) + } +} + +/// One line of private-message history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +pub struct PersistedMessage { + pub peer: String, + /// True when we sent it, false when the peer did. + pub outgoing: bool, + pub text: String, + pub at: chrono::DateTime, +} + +pub struct StateStore { + dir: PathBuf, +} + +impl StateStore { + #[must_use] + pub const fn new(dir: PathBuf) -> Self { + Self { dir } + } + + #[must_use] + pub fn load_downloads(&self) -> Vec { + load(&self.dir.join("downloads.json")) + } + + pub fn save_downloads( + &self, + downloads: &[PersistedDownload], + ) -> Result<()> { + save(&self.dir.join("downloads.json"), VERSION, &downloads) + } + + #[must_use] + pub fn load_search_queries(&self) -> Vec { + load(&self.dir.join("searches.json")) + } + + pub fn save_search_queries(&self, queries: &[String]) -> Result<()> { + save(&self.dir.join("searches.json"), VERSION, &queries) + } + + #[must_use] + pub fn load_rooms(&self) -> Vec { + load(&self.dir.join("rooms.json")) + } + + pub fn save_rooms(&self, rooms: &[String]) -> Result<()> { + save(&self.dir.join("rooms.json"), VERSION, &rooms) + } + + #[must_use] + pub fn load_messages(&self) -> Vec { + load(&self.dir.join("messages.json")) + } + + pub fn save_messages(&self, messages: &[PersistedMessage]) -> Result<()> { + save(&self.dir.join("messages.json"), VERSION, &messages) + } + + /// The unread private-message count, so the badge survives a restart. + #[must_use] + pub fn load_unread(&self) -> usize { + load(&self.dir.join("unread.json")) + } + + pub fn save_unread(&self, unread: usize) -> Result<()> { + save(&self.dir.join("unread.json"), VERSION, &unread) + } +} + +/// Load `data` from an envelope file. Missing, corrupt, or newer-than-known +/// files all yield `T::default()`. +fn load(path: &Path) -> T { + let Ok(text) = std::fs::read_to_string(path) else { + return T::default(); + }; + let Ok(envelope) = serde_json::from_str::(&text) else { + set_aside(path, "corrupt"); + return T::default(); + }; + let version = envelope + .get("version") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX); + if version > u64::from(VERSION) { + set_aside(path, "from a newer version"); + return T::default(); + } + let data = envelope.get("data").cloned().unwrap_or(Value::Null); + serde_json::from_value(data).unwrap_or_else(|_| { + set_aside(path, "unreadable"); + T::default() + }) +} + +/// Rename an unusable state file to `.bak` so a later save doesn't +/// overwrite it and the user can still recover or inspect it. +fn set_aside(path: &Path, why: &str) { + let bak = path.with_extension("json.bak"); + // Windows rename fails when the target exists; drop the old backup. + let _ = std::fs::remove_file(&bak); + match std::fs::rename(path, &bak) { + Ok(()) => soulseek_rs::warn!( + "State file {} is {why}; moved to {}", + path.display(), + bak.display() + ), + Err(e) => soulseek_rs::warn!( + "State file {} is {why} (backup failed: {e})", + path.display() + ), + } +} + +fn save(path: &Path, version: u32, data: &T) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let envelope = serde_json::json!({ "version": version, "data": data }); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&envelope)?)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (tempfile::TempDir, StateStore) { + let dir = tempfile::tempdir().expect("a temporary state directory"); + let store = StateStore::new(dir.path().join("state")); + (dir, store) + } + + fn sample_download() -> PersistedDownload { + PersistedDownload { + username: "peer".into(), + filename: "@@abc\\music\\song.mp3".into(), + size: 123, + download_directory: "/music".into(), + completed: false, + } + } + + #[test] + fn missing_files_load_as_empty() { + let (_tmp, store) = store(); + assert_eq!(store.load_downloads(), vec![]); + assert_eq!(store.load_search_queries(), Vec::::new()); + assert_eq!(store.load_rooms(), Vec::::new()); + } + + #[test] + fn downloads_round_trip() { + let (_tmp, store) = store(); + let downloads = vec![sample_download()]; + store.save_downloads(&downloads).unwrap(); + assert_eq!(store.load_downloads(), downloads); + } + + #[test] + fn messages_round_trip_including_the_timestamp() { + let (_tmp, store) = store(); + let messages = vec![PersistedMessage { + peer: "alice".into(), + outgoing: true, + text: "hey".into(), + at: chrono::Local::now(), + }]; + store.save_messages(&messages).unwrap(); + assert_eq!(store.load_messages(), messages); + } + + #[test] + fn unread_count_round_trips_and_defaults_to_zero() { + let (_tmp, store) = store(); + // Missing file → no unread, so a fresh install shows no badge. + assert_eq!(store.load_unread(), 0); + store.save_unread(4).unwrap(); + assert_eq!(store.load_unread(), 4); + } + + #[test] + fn searches_and_rooms_round_trip() { + let (_tmp, store) = store(); + store.save_search_queries(&["beatles".into()]).unwrap(); + store.save_rooms(&["indie".into(), "jazz".into()]).unwrap(); + assert_eq!(store.load_search_queries(), vec!["beatles".to_string()]); + assert_eq!( + store.load_rooms(), + vec!["indie".to_string(), "jazz".to_string()] + ); + } + + #[test] + fn corrupt_file_loads_as_empty_and_is_kept_as_bak() { + let (tmp, store) = store(); + let path = tmp.path().join("state").join("downloads.json"); + std::fs::create_dir_all(path.parent().expect("a parent")) + .expect("the state directory"); + std::fs::write(&path, "{ not json").expect("the corrupt file"); + assert_eq!(store.load_downloads(), vec![]); + // The bad file is set aside, not left to be overwritten. + assert!(!path.exists()); + let bak = path.with_extension("json.bak"); + assert_eq!( + std::fs::read_to_string(bak).expect("the .bak"), + "{ not json" + ); + } + + #[test] + fn a_second_corrupt_file_replaces_the_previous_bak() { + let (tmp, store) = store(); + let path = tmp.path().join("state").join("downloads.json"); + std::fs::create_dir_all(path.parent().expect("a parent")) + .expect("the state directory"); + std::fs::write(path.with_extension("json.bak"), "old bak") + .expect("the previous .bak"); + std::fs::write(&path, "{ newer corruption").expect("the corrupt file"); + assert_eq!(store.load_downloads(), vec![]); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.bak")) + .expect("the .bak"), + "{ newer corruption" + ); + } + + #[test] + fn file_from_a_newer_build_loads_as_empty_and_is_kept_as_bak() { + let (tmp, store) = store(); + let path = tmp.path().join("state").join("rooms.json"); + std::fs::create_dir_all(path.parent().expect("a parent")) + .expect("the state directory"); + std::fs::write(&path, r#"{"version": 99, "data": ["x"]}"#) + .expect("the newer-version file"); + assert_eq!(store.load_rooms(), Vec::::new()); + assert!(!path.exists()); + assert!(path.with_extension("json.bak").exists()); + } + + #[test] + fn save_is_atomic_no_tmp_file_left_behind() { + let (tmp, store) = store(); + store.save_rooms(&["indie".into()]).unwrap(); + let entries: Vec<_> = std::fs::read_dir(tmp.path().join("state")) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(entries, vec!["rooms.json".to_string()]); + } +} diff --git a/soulseek-rs/src/port_mapping/mod.rs b/soulseek-rs/src/port_mapping/mod.rs new file mode 100644 index 0000000..f8e67b6 --- /dev/null +++ b/soulseek-rs/src/port_mapping/mod.rs @@ -0,0 +1,649 @@ +//! Best-effort automatic port mapping so a client behind a home router becomes +//! reachable for incoming peer connections (browse/download of firewalled peers +//! and the server-brokered connect-back both need this). +//! +//! Tries UPnP-IGD (via the `igd-next` crate) first, then hand-rolled NAT-PMP +//! (see [`nat_pmp`]). Everything is best-effort: any failure is logged and the +//! program continues (the user can still reach non-firewalled peers, and can +//! port-forward manually). The mapping is renewed on a timer and removed on +//! shutdown. + +mod nat_pmp; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use igd_next::{PortMappingProtocol, SearchOptions, search_gateway}; +use soulseek_rs::utils::logger::{self, LogLevel}; + +const MAPPING_DESCRIPTION: &str = "soulseek-rs"; +/// Lease we request; both backends renew at half this interval. +const LEASE_SECS: u32 = nat_pmp::RECOMMENDED_LIFETIME; +/// Interactive NAT-PMP retransmit budget (~3.75s worst case), not the full 9. +const NATPMP_ATTEMPTS: u32 = 4; + +/// A live port mapping and its renewal thread. Dropping it stops renewal and +/// removes the mapping (best-effort). +pub struct PortMapper { + stop: Arc, + done: Receiver<()>, +} + +impl PortMapper { + /// Spawn a background best-effort mapper for TCP `port`. Returns immediately; + /// discovery/mapping happens on the thread so startup never blocks. + #[must_use] + pub fn spawn(port: u16) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let (done_tx, done) = mpsc::channel(); + let _ = thread::Builder::new().name("port-mapper".into()).spawn( + move || { + run(port, &thread_stop); + let _ = done_tx.send(()); + }, + ); + Self { stop, done } + } +} + +impl Drop for PortMapper { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + // Wait briefly for the thread to remove its mapping, but NEVER freeze + // shutdown: if it's still in a blocking discovery/mapping call (which + // doesn't observe `stop`), detach it and let the finite lease expire. + let _ = self.done.recv_timeout(Duration::from_secs(1)); + } +} + +/// The outcome of a port-mapping probe: a verdict a script can branch on +/// plus the advice a human needs when it fails. +pub struct Probe { + pub ok: bool, + /// `upnp`, `natpmp`, or `none`. + pub backend: &'static str, + /// External `address:port` the mapping exposes, when the backend reports + /// one. + pub external: Option, + pub advice: String, +} + +/// Synchronously probe whether automatic port mapping works on this network: +/// try to map `port` (UPnP then NAT-PMP), then remove the probe mapping. +/// Backs the `portmap` command so a user can verify reachability on their +/// actual router. +#[must_use] +pub fn probe(port: u16) -> Probe { + let Some(local_ip) = local_lan_ip() else { + return Probe { + ok: false, + backend: "none", + external: None, + advice: "Could not determine this machine's LAN IP — are you \ + connected to a network?" + .to_string(), + }; + }; + + match map_once(local_ip, port) { + Some((backend, _lease)) => { + let (name, external) = match &backend { + Backend::Upnp { gateway, .. } => ( + "upnp", + gateway + .get_external_ip() + .ok() + .map(|ip| format!("{ip}:{port}")), + ), + Backend::NatPmp { .. } => ("natpmp", None), + }; + remove(&backend); // clean up the probe mapping + Probe { + ok: true, + backend: name, + external, + advice: format!( + "Automatic port mapping works. TCP {port} was opened on \ + your router, so firewalled peers and the server can \ + reach you. The app does this automatically while it runs." + ), + } + } + None => Probe { + ok: false, + backend: "none", + external: None, + advice: format!( + "No UPnP/NAT-PMP router responded for TCP {port}. To \ + browse/download firewalled peers, forward TCP {port} to this \ + machine on your router (or enable UPnP there)." + ), + }, + } +} + +/// Which backend created the active mapping, carrying what's needed to renew and +/// remove it. +enum Backend { + Upnp { + gateway: Box, + local_addr: SocketAddr, + external_port: u16, + /// The lease that the router accepted (0 == permanent). + lease: u32, + }, + NatPmp { + gateway: SocketAddr, + internal_port: u16, + }, +} + +fn run(port: u16, stop: &AtomicBool) { + let Some(local_ip) = local_lan_ip() else { + warn( + "could not determine this machine's LAN IP; skipping automatic \ + port mapping (you may need to forward the port manually)", + ); + return; + }; + + let Some((backend, lease)) = map_once(local_ip, port) else { + warn(&format!( + "automatic port mapping unavailable (no UPnP/NAT-PMP router \ + found for port {port}); browsing/downloading firewalled peers \ + may fail unless you forward TCP {port} on your router" + )); + return; + }; + + manage(&backend, lease, port, stop); +} + +/// Renew `backend`'s mapping until `stop`, then remove it. Renews at half the +/// granted lease, floored at 30s so a pathologically short lease can't spin us +/// into a hot re-map loop. +fn manage(backend: &Backend, lease: u32, port: u16, stop: &AtomicBool) { + let renew_after = Duration::from_secs(u64::from((lease / 2).max(30))); + loop { + if sleep_until(renew_after, stop) { + break; // asked to stop + } + if !renew(backend, port) { + // Renewal failed; the lease will expire on its own. Stop trying. + warn("failed to renew the port mapping; it may expire"); + return; + } + } + remove(backend); +} + +/// Attempt UPnP then NAT-PMP once. Returns the backend and granted lease seconds. +fn map_once(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> { + if let Some(result) = map_upnp(local_ip, port) { + return Some(result); + } + map_natpmp(port) +} + +fn map_upnp(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> { + let options = SearchOptions { + bind_addr: SocketAddr::new(local_ip, 0), + timeout: Some(Duration::from_secs(5)), + ..SearchOptions::default() + }; + let gateway = search_gateway(options).ok()?; + let local_addr = SocketAddr::new(local_ip, port); + map_via_gateway(gateway, local_addr, port) +} + +/// Map `port` on an already-discovered IGD `gateway`. Split out from discovery +/// so it can be exercised against a mock IGD in tests. +fn map_via_gateway( + gateway: igd_next::Gateway, + local_addr: SocketAddr, + port: u16, +) -> Option<(Backend, u32)> { + // We advertise our listen port verbatim to the Soulseek server, so the + // EXTERNAL port must equal it. If the exact port can't be mapped (e.g. + // another LAN device already claimed it), a different external port would + // be unreachable for our advertised address, so we give up rather than map + // a useless port. Some IGDs only accept permanent leases, so if the timed + // lease is refused, retry with lease 0 (permanent) before giving up. + let lease = match gateway.add_port( + PortMappingProtocol::TCP, + port, + local_addr, + LEASE_SECS, + MAPPING_DESCRIPTION, + ) { + Ok(()) => LEASE_SECS, + Err(_) => match gateway.add_port( + PortMappingProtocol::TCP, + port, + local_addr, + 0, + MAPPING_DESCRIPTION, + ) { + Ok(()) => 0, + Err(e) => { + warn(&format!( + "UPnP found a router but couldn't map port {port} ({e}); \ + try a different --listener-port or forward it manually" + )); + return None; + } + }, + }; + let external = gateway + .get_external_ip() + .map_or_else(|_| "?".to_string(), |ip| ip.to_string()); + info(&format!( + "UPnP: mapped external {external}:{port} -> local {port}" + )); + Some(( + Backend::Upnp { + gateway: Box::new(gateway), + local_addr, + external_port: port, + lease, + }, + // A permanent (0) lease needs no renewal timer; use the requested lease + // for the renewal cadence otherwise. + if lease == 0 { LEASE_SECS } else { lease }, + )) +} + +fn map_natpmp(port: u16) -> Option<(Backend, u32)> { + let gateway_ip = nat_pmp::default_gateway()?; + let gateway = + SocketAddr::new(IpAddr::V4(gateway_ip), nat_pmp::NAT_PMP_PORT); + let response = + nat_pmp::map_tcp(gateway, port, port, LEASE_SECS, NATPMP_ATTEMPTS) + .ok()?; + // We advertise our listen port verbatim, so a different external port would + // be unreachable for peers. Keep the mapping but warn loudly. + if response.external_port != port { + warn(&format!( + "NAT-PMP granted external port {} instead of {port}; peers may not \ + reach you — try forwarding TCP {port} manually", + response.external_port + )); + } + info(&format!( + "NAT-PMP: mapped external port {} -> local {port} (lease {}s)", + response.external_port, response.lifetime + )); + Some(( + Backend::NatPmp { + gateway, + internal_port: port, + }, + response.lifetime, + )) +} + +fn renew(backend: &Backend, port: u16) -> bool { + match backend { + Backend::Upnp { + gateway, + local_addr, + external_port, + lease, + } => gateway + .add_port( + PortMappingProtocol::TCP, + *external_port, + *local_addr, + *lease, + MAPPING_DESCRIPTION, + ) + .is_ok(), + Backend::NatPmp { + gateway, + internal_port, + } => nat_pmp::map_tcp( + *gateway, + *internal_port, + port, + LEASE_SECS, + NATPMP_ATTEMPTS, + ) + .is_ok(), + } +} + +fn remove(backend: &Backend) { + match backend { + Backend::Upnp { + gateway, + external_port, + .. + } => { + let _ = + gateway.remove_port(PortMappingProtocol::TCP, *external_port); + } + Backend::NatPmp { + gateway, + internal_port, + } => { + let _ = nat_pmp::unmap_tcp(*gateway, *internal_port, 2); + } + } +} + +/// Sleep for `dur`, checking `stop` every 500ms. Returns `true` if `stop` was +/// signalled (so the caller should exit promptly). +fn sleep_until(dur: Duration, stop: &AtomicBool) -> bool { + let deadline = Instant::now() + dur; + while Instant::now() < deadline { + if stop.load(Ordering::SeqCst) { + return true; + } + thread::sleep(Duration::from_millis(500)); + } + stop.load(Ordering::SeqCst) +} + +/// The LAN IP of the interface that routes to the internet, found by asking the +/// OS which local address a UDP socket would use to reach a public address. No +/// packets are sent (UDP `connect` only sets the default route). +fn local_lan_ip() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80)).ok()?; + let ip = socket.local_addr().ok()?.ip(); + // A loopback/unspecified address can't be a forward target. + if ip.is_loopback() || ip.is_unspecified() { + None + } else { + Some(ip) + } +} + +fn info(message: &str) { + logger::log(LogLevel::Info, &format!("[port-mapping] {message}")); +} + +fn warn(message: &str) { + logger::log(LogLevel::Warn, &format!("[port-mapping] {message}")); +} + +#[cfg(test)] +impl PortMapper { + /// Spawn a mapper that uses ONLY the NAT-PMP backend against an injected + /// gateway (no UPnP, no routing-table discovery), so the full spawn → map → + /// renew → cleanup-on-drop lifecycle can be driven against a mock gateway. + fn spawn_natpmp_test(port: u16, gateway: SocketAddr) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let (done_tx, done) = mpsc::channel(); + let _ = thread::Builder::new() + .name("port-mapper-test".into()) + .spawn(move || { + if let Ok(response) = nat_pmp::map_tcp( + gateway, + port, + port, + LEASE_SECS, + NATPMP_ATTEMPTS, + ) { + let backend = Backend::NatPmp { + gateway, + internal_port: port, + }; + manage(&backend, response.lifetime, port, &thread_stop); + } + let _ = done_tx.send(()); + }); + Self { stop, done } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::Duration as StdDuration; + + /// A mock NAT-PMP gateway that answers every map-TCP request with the same + /// granted values and forwards each raw request to the test. + fn mock_gateway() -> (SocketAddr, mpsc::Receiver>) { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = socket.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut buf = [0u8; 64]; + // Answer many requests (initial map + cleanup unmap + any renews). + loop { + let (n, from) = match socket.recv_from(&mut buf) { + Ok(received) => received, + // Windows reports a reply that met an already-closed + // client socket on the next receive; the gateway is fine. + Err(e) + if e.kind() == std::io::ErrorKind::ConnectionReset => + { + continue; + } + Err(_) => break, + }; + let request = buf[..n].to_vec(); + let internal = u16::from_be_bytes([request[4], request[5]]); + let lifetime = u32::from_be_bytes([ + request[8], + request[9], + request[10], + request[11], + ]); + // Response: version 0, opcode 130, result 0, echo internal port + // and grant the SAME external port + requested lifetime. + let mut reply = vec![0u8, 130, 0, 0]; + reply.extend_from_slice(&7u32.to_be_bytes()); // epoch + reply.extend_from_slice(&internal.to_be_bytes()); + reply.extend_from_slice(&internal.to_be_bytes()); + reply.extend_from_slice(&lifetime.to_be_bytes()); + if tx.send(request).is_err() { + break; + } + let _ = socket.send_to(&reply, from); + } + }); + (addr, rx) + } + + #[test] + fn service_maps_on_start_and_unmaps_on_drop() { + let (gateway, requests) = mock_gateway(); + let mapper = PortMapper::spawn_natpmp_test(2234, gateway); + + // The first request must be a map with our full requested lifetime. + let map_req = requests + .recv_timeout(StdDuration::from_secs(3)) + .expect("mapper should send an initial map request"); + assert_eq!(u16::from_be_bytes([map_req[4], map_req[5]]), 2234); + assert_eq!( + u32::from_be_bytes([ + map_req[8], + map_req[9], + map_req[10], + map_req[11] + ]), + LEASE_SECS, + "initial map should request the full lease" + ); + + // Dropping the mapper must remove the mapping (a lifetime-0 request). + // A map the gateway was slow to answer is sent again, and that resend + // can arrive first. + drop(mapper); + let unmapped = std::iter::from_fn(|| { + requests.recv_timeout(StdDuration::from_secs(3)).ok() + }) + .any(|request| { + u32::from_be_bytes([ + request[8], + request[9], + request[10], + request[11], + ]) == 0 + }); + assert!(unmapped, "cleanup should request lifetime 0"); + } + + /// A minimal in-process UPnP IGD: an HTTP server that answers every SOAP + /// control request with a canned envelope containing the Add/Delete/GetIP + /// response elements, and forwards each request body to the test. + fn mock_igd() -> (SocketAddr, mpsc::Receiver) { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + // Read headers, then Content-Length bytes of body. + let body = loop { + let n = match stream.read(&mut chunk) { + Ok(0) | Err(_) => break String::new(), + Ok(n) => n, + }; + buf.extend_from_slice(&chunk[..n]); + let text = String::from_utf8_lossy(&buf); + if let Some(hdr_end) = text.find("\r\n\r\n") { + let len = text + .lines() + .find_map(|l| { + l.strip_prefix("Content-Length: ").or_else( + || l.strip_prefix("content-length: "), + ) + }) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + if buf.len() >= hdr_end + 4 + len { + break text[hdr_end + 4..].to_string(); + } + } + }; + if tx.send(body).is_err() { + break; + } + let soap = r#" + + + + +203.0.113.7 + +"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{soap}", + soap.len() + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + (addr, rx) + } + + fn igd_control_schema() -> std::collections::HashMap> { + let mut schema = std::collections::HashMap::new(); + schema.insert( + "AddPortMapping".to_string(), + [ + "NewRemoteHost", + "NewExternalPort", + "NewProtocol", + "NewInternalPort", + "NewInternalClient", + "NewEnabled", + "NewPortMappingDescription", + "NewLeaseDuration", + ] + .iter() + .map(ToString::to_string) + .collect(), + ); + schema.insert( + "DeletePortMapping".to_string(), + ["NewRemoteHost", "NewExternalPort", "NewProtocol"] + .iter() + .map(ToString::to_string) + .collect(), + ); + schema + } + + #[test] + fn upnp_maps_a_port_against_a_mock_igd() { + let (igd_addr, requests) = mock_igd(); + let gateway = igd_next::Gateway { + addr: igd_addr, + root_url: format!("http://{igd_addr}/rootDesc.xml"), + control_url: "/ctl".to_string(), + control_schema_url: format!("http://{igd_addr}/scpd.xml"), + control_schema: igd_control_schema(), + }; + + let local_addr = + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 2234); + let result = map_via_gateway(gateway, local_addr, 2234); + + let (backend, _lease) = + result.expect("mapping against a healthy IGD should succeed"); + match backend { + Backend::Upnp { external_port, .. } => { + assert_eq!( + external_port, 2234, + "must map the exact listen port" + ); + } + Backend::NatPmp { .. } => panic!("expected a UPnP backend"), + } + + // The IGD must have received an AddPortMapping SOAP call quoting our + // external and internal port and the LAN client address. + let body = requests + .recv_timeout(StdDuration::from_secs(3)) + .expect("IGD should receive an AddPortMapping request"); + assert!( + body.contains("2234"), + "request should map external port 2234: {body}" + ); + assert!( + body.contains("2234"), + "request should target internal port 2234: {body}" + ); + assert!( + body.contains("192.168.1.50"), + "request should point at the LAN client: {body}" + ); + } + + #[test] + fn drop_does_not_hang_when_gateway_is_unresponsive() { + // A bound-but-silent gateway: the mapper thread blocks in the NAT-PMP + // retransmit budget (~3.75s). Dropping the mapper must NOT wait that + // long — it detaches after a bounded grace period. + let silent = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = silent.local_addr().unwrap(); + let mapper = PortMapper::spawn_natpmp_test(2234, addr); + // Ensure the thread is mid-transaction before we drop. + thread::sleep(StdDuration::from_millis(100)); + + let start = Instant::now(); + drop(mapper); + let elapsed = start.elapsed(); + assert!( + elapsed < StdDuration::from_millis(1500), + "drop blocked for {elapsed:?}; it should detach promptly" + ); + drop(silent); + } +} diff --git a/soulseek-rs/src/port_mapping/nat_pmp.rs b/soulseek-rs/src/port_mapping/nat_pmp.rs new file mode 100644 index 0000000..1fb0f5f --- /dev/null +++ b/soulseek-rs/src/port_mapping/nat_pmp.rs @@ -0,0 +1,349 @@ +//! Hand-rolled NAT-PMP (RFC 6886) over `std::net::UdpSocket`, no dependencies. +//! +//! The gateway address is always passed in as a `SocketAddr`, so tests point it +//! at an in-process mock gateway on `127.0.0.1`. Only [`default_gateway`] touches +//! the OS routing table. + +use std::io; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::Duration; + +/// The well-known NAT-PMP gateway UDP port. +pub const NAT_PMP_PORT: u16 = 5351; + +const PROTO_VERSION: u8 = 0; +const OP_MAP_TCP: u8 = 2; +const RESPONSE_FLAG: u8 = 128; // response opcode == request opcode + 128 +/// RFC-recommended mapping lifetime (2 hours). +pub const RECOMMENDED_LIFETIME: u32 = 7200; + +#[derive(Debug)] +pub enum NatPmpError { + Io(io::Error), + ShortResponse, + BadVersion(u8), + BadOpcode(u8), + /// A non-zero RFC 6886 result code (1..=5). + ResultCode(u16), + /// Every retransmission timed out. + NoResponse, +} + +impl std::fmt::Display for NatPmpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "network error: {e}"), + Self::ShortResponse => write!(f, "truncated gateway response"), + Self::BadVersion(v) => write!(f, "unexpected version {v}"), + Self::BadOpcode(o) => write!(f, "unexpected opcode {o}"), + Self::ResultCode(c) => write!(f, "gateway result code {c}"), + Self::NoResponse => write!(f, "no response from gateway"), + } + } +} + +impl From for NatPmpError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +/// A successful TCP mapping. The gateway may grant a different external port or +/// a shorter lifetime than requested, so callers must use these values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MapResponse { + pub internal_port: u16, + pub external_port: u16, + pub lifetime: u32, +} + +/// Request (or, with `lifetime == 0`, remove) a TCP port mapping from the +/// NAT-PMP gateway at `gateway`. `attempts` bounds the retransmissions; RFC 6886 +/// allows up to 9 (~64s), but an interactive tool should pass a smaller number +/// so a non-NAT-PMP router doesn't stall for a minute. +/// +/// # Errors +/// Returns a [`NatPmpError`] on I/O failure, a malformed/short reply, a non-zero +/// gateway result code, or if no reply arrives within `attempts` retransmits. +pub fn map_tcp( + gateway: SocketAddr, + internal_port: u16, + suggested_external: u16, + lifetime: u32, + attempts: u32, +) -> Result { + let mut request = Vec::with_capacity(12); + request.push(PROTO_VERSION); + request.push(OP_MAP_TCP); + request.extend_from_slice(&[0, 0]); // reserved + request.extend_from_slice(&internal_port.to_be_bytes()); + request.extend_from_slice(&suggested_external.to_be_bytes()); + request.extend_from_slice(&lifetime.to_be_bytes()); + + let response = transact(gateway, &request, OP_MAP_TCP, attempts)?; + if response.len() < 16 { + return Err(NatPmpError::ShortResponse); + } + Ok(MapResponse { + internal_port: u16::from_be_bytes([response[8], response[9]]), + external_port: u16::from_be_bytes([response[10], response[11]]), + lifetime: u32::from_be_bytes([ + response[12], + response[13], + response[14], + response[15], + ]), + }) +} + +/// Remove a TCP mapping for `internal_port` (external port 0, lifetime 0). +/// +/// # Errors +/// As [`map_tcp`]. +pub fn unmap_tcp( + gateway: SocketAddr, + internal_port: u16, + attempts: u32, +) -> Result<(), NatPmpError> { + map_tcp(gateway, internal_port, 0, 0, attempts).map(|_| ()) +} + +/// Send `request` to `gateway` and return the validated response body, retrying +/// with RFC 6886's 250ms exponential backoff up to `attempts` times. +fn transact( + gateway: SocketAddr, + request: &[u8], + expected_op: u8, + attempts: u32, +) -> Result, NatPmpError> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + socket.connect(gateway)?; + + let mut timeout = Duration::from_millis(250); + let mut buf = [0u8; 32]; + for _ in 0..attempts.max(1) { + socket.send(request)?; + socket.set_read_timeout(Some(timeout))?; + match socket.recv(&mut buf) { + Ok(n) => { + let response = &buf[..n]; + if response.len() < 4 { + return Err(NatPmpError::ShortResponse); + } + if response[0] != PROTO_VERSION { + return Err(NatPmpError::BadVersion(response[0])); + } + if response[1] != expected_op + RESPONSE_FLAG { + return Err(NatPmpError::BadOpcode(response[1])); + } + let result = u16::from_be_bytes([response[2], response[3]]); + if result != 0 { + return Err(NatPmpError::ResultCode(result)); + } + return Ok(response.to_vec()); + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + timeout = timeout.saturating_mul(2); + } + Err(e) => return Err(NatPmpError::Io(e)), + } + } + Err(NatPmpError::NoResponse) +} + +/// Best-effort discovery of the IPv4 default-gateway address from the OS routing +/// table. Supports macOS and Linux; returns `None` elsewhere or on any parse +/// failure (callers should treat NAT-PMP as unavailable then). +#[must_use] +pub fn default_gateway() -> Option { + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("route") + .args(["-n", "get", "default"]) + .output() + .ok()?; + parse_macos_default_gateway(&String::from_utf8_lossy(&out.stdout)) + } + #[cfg(target_os = "linux")] + { + let text = std::fs::read_to_string("/proc/net/route").ok()?; + parse_linux_default_gateway(&text) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + None + } +} + +/// Parse the gateway IP from `route -n get default` output (macOS). +#[cfg(any(target_os = "macos", test))] +#[must_use] +fn parse_macos_default_gateway(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim().strip_prefix("gateway:")?.trim().parse().ok() + }) +} + +/// Parse the IPv4 default gateway from the contents of `/proc/net/route` +/// (Linux). Fields are whitespace-separated; the default route has Destination +/// `00000000` and stores the Gateway as a little-endian hex u32. A `00000000` +/// (unspecified) gateway is a directly-connected route with no next hop, so it +/// is skipped rather than returned as `0.0.0.0`. +#[cfg(any(target_os = "linux", test))] +#[must_use] +fn parse_linux_default_gateway(contents: &str) -> Option { + contents.lines().skip(1).find_map(|line| { + let mut fields = line.split_whitespace(); + let _iface = fields.next()?; + let destination = fields.next()?; + let gateway = fields.next()?; + if destination != "00000000" { + return None; + } + let raw = u32::from_str_radix(gateway, 16).ok()?; + (raw != 0).then(|| Ipv4Addr::from(raw.to_le_bytes())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::thread; + + /// A minimal in-process NAT-PMP gateway for tests. It receives one request, + /// hands the raw bytes back to the test, and replies with `reply`. + fn mock_gateway(reply: Vec) -> (SocketAddr, mpsc::Receiver>) { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = socket.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut buf = [0u8; 64]; + if let Ok((n, from)) = socket.recv_from(&mut buf) { + let _ = tx.send(buf[..n].to_vec()); + let _ = socket.send_to(&reply, from); + } + }); + (addr, rx) + } + + fn map_reply( + result: u16, + internal: u16, + external: u16, + lifetime: u32, + ) -> Vec { + let mut r = vec![PROTO_VERSION, OP_MAP_TCP + RESPONSE_FLAG]; + r.extend_from_slice(&result.to_be_bytes()); + r.extend_from_slice(&123u32.to_be_bytes()); // epoch + r.extend_from_slice(&internal.to_be_bytes()); + r.extend_from_slice(&external.to_be_bytes()); + r.extend_from_slice(&lifetime.to_be_bytes()); + r + } + + #[test] + fn map_tcp_sends_correct_request_and_parses_response() { + // Gateway grants a DIFFERENT external port and a SHORTER lease than asked. + let (gateway, requests) = mock_gateway(map_reply(0, 2234, 40000, 3600)); + let resp = map_tcp(gateway, 2234, 2234, 7200, 3) + .expect("mapping should succeed"); + + // The request bytes must match the RFC 6886 map-TCP layout. + let req = requests.recv().unwrap(); + assert_eq!(req.len(), 12); + assert_eq!(req[0], 0, "version"); + assert_eq!(req[1], 2, "opcode TCP"); + assert_eq!(&req[2..4], &[0, 0], "reserved"); + assert_eq!(u16::from_be_bytes([req[4], req[5]]), 2234, "internal port"); + assert_eq!( + u16::from_be_bytes([req[6], req[7]]), + 2234, + "suggested external" + ); + assert_eq!( + u32::from_be_bytes([req[8], req[9], req[10], req[11]]), + 7200, + "lifetime" + ); + + // The response's granted values (not the requested ones) are returned. + assert_eq!(resp.external_port, 40000); + assert_eq!(resp.lifetime, 3600); + assert_eq!(resp.internal_port, 2234); + } + + #[test] + fn map_tcp_surfaces_gateway_result_code() { + // Result code 2 = "not authorized/refused" per RFC 6886. + let (gateway, _req) = mock_gateway(map_reply(2, 2234, 0, 0)); + match map_tcp(gateway, 2234, 2234, 7200, 3) { + Err(NatPmpError::ResultCode(2)) => {} + other => panic!("expected ResultCode(2), got {other:?}"), + } + } + + #[test] + fn map_tcp_times_out_when_gateway_is_silent() { + // Bind a socket but never reply: the mapper must give up, not hang. + let dead = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = dead.local_addr().unwrap(); + drop(dead); // nothing listening now + match map_tcp(addr, 2234, 2234, 7200, 2) { + Err(NatPmpError::NoResponse | NatPmpError::Io(_)) => {} + other => panic!("expected timeout/io error, got {other:?}"), + } + } + + #[test] + fn parses_macos_default_gateway() { + let output = " route to: default\ndestination: default\n mask: default\n gateway: 192.168.1.254\n interface: en0\n"; + assert_eq!( + parse_macos_default_gateway(output), + Some(Ipv4Addr::new(192, 168, 1, 254)) + ); + // No default route → None. + assert_eq!(parse_macos_default_gateway("interface: en0\n"), None); + } + + #[test] + fn parses_linux_default_gateway_and_skips_unspecified() { + // Header + a directly-connected default (gateway 00000000) that must be + // skipped + the real default route (192.168.0.1 => 0100A8C0 LE). + let contents = "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n\ + tun0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\n\ + eth0\t00000000\t0100A8C0\t0003\t0\t0\t100\t00000000\n\ + eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\n"; + assert_eq!( + parse_linux_default_gateway(contents), + Some(Ipv4Addr::new(192, 168, 0, 1)) + ); + + // Only a gateway-less default route → None (not 0.0.0.0). + let only_direct = "Iface\tDestination\tGateway\tFlags\n\ + tun0\t00000000\t00000000\t0001\n"; + assert_eq!(parse_linux_default_gateway(only_direct), None); + } + + #[test] + fn unmap_sends_zero_lifetime_and_port() { + let (gateway, requests) = mock_gateway(map_reply(0, 2234, 0, 0)); + unmap_tcp(gateway, 2234, 3).expect("unmap should succeed"); + let req = requests.recv().unwrap(); + assert_eq!( + u16::from_be_bytes([req[6], req[7]]), + 0, + "external port 0 to delete" + ); + assert_eq!( + u32::from_be_bytes([req[8], req[9], req[10], req[11]]), + 0, + "lifetime 0 to delete" + ); + } +} diff --git a/soulseek-rs/src/remote/mirror.rs b/soulseek-rs/src/remote/mirror.rs new file mode 100644 index 0000000..d7439f0 --- /dev/null +++ b/soulseek-rs/src/remote/mirror.rs @@ -0,0 +1,355 @@ +//! The client-side copy of what the daemon drained. +//! +//! A remote session cannot ask the daemon to `take_*` on its behalf — that is +//! destructive, and with two clients attached the first to ask would steal the +//! other's events. So the daemon pushes, this mirror accumulates, and +//! `take_*` empties the mirror instead. From the caller's side the semantics +//! are identical: each event is delivered once, to each client. + +use crate::daemon::proto::{ + BrowseEvent, DownloadStatusEvent, Event, RoomEventDto, SessionLossEvent, + UploadInfoDto, UserMessageDto, +}; +use soulseek_rs::{ + DownloadStatus, RoomEvent, SessionLoss, SharedDirectory, UploadInfo, + UserMessage, +}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::mpsc::Sender; + +/// How many undrained events of one kind a client may accumulate. +/// +/// Every attached client is sent everything, including events for work another +/// client asked for. A command that only drains room events would otherwise +/// grow its private-message buffer for as long as it runs. Dropping the oldest +/// is right for a buffer nobody is reading: the newest state is the useful one. +const BUFFER_LIMIT: usize = 4096; + +/// Everything a pushed event can land in, behind one lock. +/// +/// One lock rather than several: it is taken for the length of a `Vec::push` +/// on an event thread that has nothing else to do, and a single lock cannot +/// deadlock against itself. +#[derive(Default)] +struct Buffers { + rooms: Vec, + messages: Vec, + uploads: Vec, + browses: HashMap>, + /// Where each in-flight transfer's status goes, keyed the way the daemon + /// names it. Registered by `download`, dropped when the transfer ends. + downloads: HashMap<(String, String), Sender>, + loss: Option, +} + +#[derive(Default)] +pub struct Mirror(Mutex); + +impl Mirror { + /// File one pushed event. Anything unparseable is dropped with a warning + /// rather than killing the connection: a newer daemon may legitimately + /// send a shape this client does not know yet. + pub fn apply(&self, event: Event, params: serde_json::Value) { + let Ok(mut buffers) = self.0.lock() else { + return; + }; + match event { + Event::Room => { + if let Ok(room) = parse::(event, params) { + push(&mut buffers.rooms, room.into()); + } + } + Event::Message => { + if let Ok(message) = parse::(event, params) { + push(&mut buffers.messages, message.into()); + } + } + Event::Upload => { + if let Ok(upload) = parse::(event, params) { + push(&mut buffers.uploads, upload.into()); + } + } + Event::Browse => { + if let Ok(browse) = parse::(event, params) { + if buffers.browses.len() >= BUFFER_LIMIT { + buffers.browses.clear(); + } + buffers.browses.insert( + browse.username, + browse + .directories + .into_iter() + .map(Into::into) + .collect(), + ); + } + } + Event::DownloadStatus => { + if let Ok(update) = parse::(event, params) + { + Self::route(&mut buffers, update); + } + } + Event::SessionLoss => { + if let Ok(loss) = parse::(event, params) { + // The first loss is the one worth keeping: a displaced + // session also drops its socket a moment later. + buffers.loss.get_or_insert_with(|| loss.loss.into()); + } + } + } + } + + /// Deliver a transfer update to whoever is waiting on it, forgetting the + /// transfer once it can produce nothing further. + fn route(buffers: &mut Buffers, update: DownloadStatusEvent) { + let key = (update.username, update.filename); + let status = DownloadStatus::from(update.status); + let terminal = status.is_terminal(); + let Some(sender) = buffers.downloads.get(&key) else { + return; + }; + // A caller that dropped its receiver is not an error: it stopped + // caring, which is exactly what `remove_download` does. + let delivered = sender.send(status).is_ok(); + if terminal || !delivered { + buffers.downloads.remove(&key); + } + } + + /// Stop tracking every transfer, so anything blocked on one is told the + /// connection is gone. + /// + /// Dropping the senders is the signal: a caller waiting on its receiver + /// sees a disconnect immediately instead of sitting out its full transfer + /// timeout and then reporting a misleading "gave up". + pub fn abandon_downloads(&self) { + if let Ok(mut buffers) = self.0.lock() { + buffers.downloads.clear(); + } + } + + /// Register where a transfer's updates should go before asking for it, so + /// an update that lands immediately is not dropped on the floor. + pub fn expect_download( + &self, + username: &str, + filename: &str, + sender: Sender, + ) { + if let Ok(mut buffers) = self.0.lock() { + buffers + .downloads + .insert((username.to_string(), filename.to_string()), sender); + } + } + + pub fn forget_download(&self, username: &str, filename: &str) { + if let Ok(mut buffers) = self.0.lock() { + buffers + .downloads + .remove(&(username.to_string(), filename.to_string())); + } + } + + pub fn take_rooms(&self) -> Vec { + self.take(|buffers| std::mem::take(&mut buffers.rooms)) + } + + pub fn take_messages(&self) -> Vec { + self.take(|buffers| std::mem::take(&mut buffers.messages)) + } + + pub fn take_uploads(&self) -> Vec { + self.take(|buffers| std::mem::take(&mut buffers.uploads)) + } + + pub fn take_browse(&self, username: &str) -> Option> { + self.0.lock().ok()?.browses.remove(username) + } + + pub fn loss(&self) -> Option { + self.0.lock().ok()?.loss + } + + fn take(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T { + self.0 + .lock() + .map_or_else(|_| T::default(), |mut b| drain(&mut b)) + } +} + +/// Append, dropping the oldest once the buffer is at its limit. +fn push(buffer: &mut Vec, item: T) { + if buffer.len() >= BUFFER_LIMIT { + buffer.remove(0); + } + buffer.push(item); +} + +fn parse( + event: Event, + params: serde_json::Value, +) -> Result { + serde_json::from_value(params).map_err(|e| { + soulseek_rs::warn!("ignoring an unreadable {event} event: {e}"); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::proto::DownloadStatusDto; + use serde_json::json; + + #[test] + fn a_room_event_is_delivered_once_and_then_gone() { + let mirror = Mirror::default(); + mirror.apply(Event::Room, json!({ "event": "left", "room": "lobby" })); + + assert_eq!( + mirror.take_rooms(), + [RoomEvent::Left { + room: "lobby".into() + }] + ); + assert!( + mirror.take_rooms().is_empty(), + "draining twice must not replay what was already handed over" + ); + } + + #[test] + fn a_browse_result_is_keyed_by_peer() { + let mirror = Mirror::default(); + mirror.apply( + Event::Browse, + json!({ + "username": "bob", + "directories": [{ + "name": "@@x", + "files": [{ "name": "a.mp3", "size": 42, "attributes": [[0, 320]] }], + }], + }), + ); + + assert!(mirror.take_browse("sue").is_none()); + let listing = mirror.take_browse("bob").expect("bob answered"); + assert_eq!(listing[0].name, "@@x"); + assert_eq!(listing[0].files[0].name, "a.mp3"); + assert_eq!(listing[0].files[0].attribute(0), Some(320)); + assert!(mirror.take_browse("bob").is_none(), "taken means taken"); + } + + #[test] + fn a_transfer_update_reaches_the_caller_waiting_on_it() { + let mirror = Mirror::default(); + let (sender, receiver) = std::sync::mpsc::channel(); + mirror.expect_download("bob", "a.mp3", sender); + + mirror.apply( + Event::DownloadStatus, + serde_json::to_value(DownloadStatusEvent { + username: "bob".into(), + filename: "a.mp3".into(), + status: DownloadStatusDto::Queued, + }) + .unwrap(), + ); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Queued))); + } + + #[test] + fn a_finished_transfer_stops_being_tracked() { + let mirror = Mirror::default(); + let (sender, receiver) = std::sync::mpsc::channel(); + mirror.expect_download("bob", "a.mp3", sender); + + for status in [DownloadStatusDto::Completed, DownloadStatusDto::Queued] + { + mirror.apply( + Event::DownloadStatus, + serde_json::to_value(DownloadStatusEvent { + username: "bob".into(), + filename: "a.mp3".into(), + status, + }) + .unwrap(), + ); + } + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Completed))); + assert!( + receiver.try_recv().is_err(), + "a completed transfer must not keep receiving updates" + ); + } + + #[test] + fn an_update_for_an_unknown_transfer_is_harmless() { + let mirror = Mirror::default(); + mirror.apply( + Event::DownloadStatus, + serde_json::to_value(DownloadStatusEvent { + username: "nobody".into(), + filename: "x.mp3".into(), + status: DownloadStatusDto::Completed, + }) + .unwrap(), + ); + } + + #[test] + fn the_first_session_loss_is_the_one_reported() { + let mirror = Mirror::default(); + assert!(mirror.loss().is_none()); + mirror.apply(Event::SessionLoss, json!({ "loss": "displaced" })); + mirror.apply(Event::SessionLoss, json!({ "loss": "disconnected" })); + assert_eq!( + mirror.loss(), + Some(SessionLoss::Displaced), + "being displaced is the reason worth reporting; the dropped socket \ + is its consequence" + ); + } + + #[test] + fn an_undrained_buffer_stops_growing_at_its_limit() { + // A client is sent every event, including ones for work it never asked + // for; a command that drains only rooms must not grow without bound. + let mirror = Mirror::default(); + for _ in 0..(BUFFER_LIMIT + 50) { + mirror.apply( + Event::Room, + json!({ "event": "left", "room": "lobby" }), + ); + } + assert_eq!(mirror.take_rooms().len(), BUFFER_LIMIT); + } + + #[test] + fn abandoning_transfers_wakes_whoever_is_waiting_on_them() { + let mirror = Mirror::default(); + let (sender, receiver) = std::sync::mpsc::channel(); + mirror.expect_download("bob", "a.mp3", sender); + + mirror.abandon_downloads(); + + assert!( + matches!(receiver.recv(), Err(std::sync::mpsc::RecvError)), + "a caller blocked on a transfer must be told the daemon is gone, \ + not left to time out" + ); + } + + #[test] + fn an_unreadable_event_is_ignored_rather_than_fatal() { + // A newer daemon may send a shape this client does not know. Dropping + // one event beats dropping the connection. + let mirror = Mirror::default(); + mirror.apply(Event::Room, json!({ "event": "invented" })); + assert!(mirror.take_rooms().is_empty()); + } +} diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs new file mode 100644 index 0000000..6339eb1 --- /dev/null +++ b/soulseek-rs/src/remote/mod.rs @@ -0,0 +1,987 @@ +//! Talking to a daemon as if it were a local client. +//! +//! [`RemoteSession`] implements the same [`SessionApi`] the commands and the +//! TUI already use, so nothing above the seam knows whether the session is in +//! this process or another one. Request/response methods are RPCs; the +//! destructive `take_*` drains read from a mirror the daemon feeds by pushing. + +mod mirror; + +use crate::api::{SessionApi, SessionSearch}; +use crate::daemon::proto::{ + Ack, AuthParams, AuthResult, CODE_APPLICATION, ChatMessageDto, + DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, + DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, + MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, + 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; +use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats}; +use soulseek_rs::{ + DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, + SharedDirectory, SoulseekRs, UploadInfo, UserInfo, UserMessage, +}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// How long to wait for the daemon to answer one request. Every method is +/// either a lock-and-read or a message onto an already-open socket, so a +/// daemon that has not answered by now is wedged, not busy. +const CALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Where a daemon is listening. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Endpoint { + /// A Unix socket path: local, and authenticated by its permissions. + Socket(PathBuf), + /// `host:port`: needs the token. + Tcp(String), +} + +impl Endpoint { + /// Read what the user wrote. The distinction is unambiguous because a + /// socket path always has a separator and `host:port` never does. + #[must_use] + pub fn parse(value: &str) -> Self { + if value.contains(std::path::MAIN_SEPARATOR) || value.contains('/') { + return Self::Socket(PathBuf::from(value)); + } + Self::Tcp(value.to_string()) + } + + /// Whether the transport itself proves who we are, in which case no token + /// has to be found. + const fn self_authenticating(&self) -> bool { + matches!(self, Self::Socket(_)) + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Socket(path) => write!(f, "{}", path.display()), + Self::Tcp(address) => f.write_str(address), + } + } +} + +/// Calls awaiting a reply, keyed by JSON-RPC id. +type Answer = std::result::Result; + +#[derive(Default)] +struct Pending(Mutex>>); + +impl Pending { + fn register(&self, id: u64) -> Receiver { + let (sender, receiver) = channel(); + if let Ok(mut pending) = self.0.lock() { + pending.insert(id, sender); + } + receiver + } + + fn settle(&self, id: u64, outcome: Answer) { + let sender = self.0.lock().ok().and_then(|mut p| p.remove(&id)); + if let Some(sender) = sender { + let _ = sender.send(outcome); + } + } + + fn abandon(&self, id: u64) { + if let Ok(mut pending) = self.0.lock() { + pending.remove(&id); + } + } + + /// Fail everything still waiting. Called when the reader stops, so callers + /// blocked on a dead connection are told rather than left to time out. + fn fail_all(&self) { + let Ok(mut pending) = self.0.lock() else { + return; + }; + for (_, sender) in pending.drain() { + let _ = sender.send(Err(RpcError::new( + CODE_APPLICATION, + "the daemon closed the connection", + ))); + } + } +} + +/// The half of a connection that can be closed from another thread. +/// +/// Dropping the write half is not enough to end the reader: it is blocked in +/// `read` on its own cloned handle, so the socket would stay open and the +/// daemon would keep a connection — and a hub subscription — for a client that +/// has gone. An explicit shutdown is what makes a dropped session actually +/// disconnect. +enum Closer { + Tcp(TcpStream), + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), +} + +impl Closer { + fn close(&self) { + let _ = match self { + Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both), + #[cfg(unix)] + Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both), + }; + } +} + +/// One connection to a daemon, presented as a session. +pub struct RemoteSession { + outgoing: Mutex>, + closer: Closer, + pending: Arc, + mirror: Arc, + next_id: AtomicU64, + username: String, + /// Where this session was reached, so the UI can say so. + endpoint: String, + /// Set when the reader thread stops, so a call fails immediately rather + /// than waiting out its timeout against a socket nobody is reading. + closed: Arc, +} + +impl RemoteSession { + /// Connect and authenticate. + /// + /// `token` is required on TCP and optional on a Unix socket, where the + /// socket's permissions have already answered the question. + pub fn connect(endpoint: &Endpoint, token: Option) -> Result { + let (read, write, closer) = open(endpoint)?; + + let pending = Arc::new(Pending::default()); + let mirror = Arc::new(Mirror::default()); + let hung_up = Arc::new(AtomicBool::new(false)); + { + let pending = Arc::clone(&pending); + let mirror = Arc::clone(&mirror); + let hung_up = Arc::clone(&hung_up); + std::thread::spawn(move || { + read_loop(read, &pending, &mirror); + hung_up.store(true, Ordering::Relaxed); + // Everything waiting on this connection has to be told, not + // left to discover it by timing out: calls in flight, and + // transfers whose progress only ever arrived as events. + pending.fail_all(); + mirror.abandon_downloads(); + }); + } + + let mut session = Self { + outgoing: Mutex::new(write), + closer, + pending, + mirror, + next_id: AtomicU64::new(1), + username: String::new(), + endpoint: endpoint.to_string(), + closed: hung_up, + }; + + let token = if endpoint.self_authenticating() { + // Offering a token here can only hurt: the socket's permissions + // have already answered the question, and a stale one on disk + // would turn a connection that should succeed into a refusal. + None + } else { + Some(token.ok_or_else(|| { + network( + "a remote daemon needs a token; see `soulseek-rs daemon \ + token`", + ) + })?) + }; + let authenticated: AuthResult = session.request( + Method::Auth, + AuthParams { + token, + protocol: PROTOCOL_VERSION, + }, + )?; + if authenticated.protocol != PROTOCOL_VERSION { + return Err(network(format!( + "the daemon speaks protocol {} and this client speaks \ + {PROTOCOL_VERSION}; upgrade whichever is older", + authenticated.protocol + ))); + } + + session.username = authenticated.username; + Ok(session) + } + + /// Send a request and wait for its reply. + fn request( + &self, + method: Method, + params: P, + ) -> Result { + let value = self.call(method, params)?; + serde_json::from_value(value).map_err(|e| { + network(format!( + "the daemon's answer to {method} was unreadable: {e}" + )) + }) + } + + fn call( + &self, + method: Method, + params: P, + ) -> Result { + if self.closed.load(Ordering::Relaxed) { + return Err(SoulseekRs::ConnectionClosed); + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let request = Request { + jsonrpc: "2.0".to_string(), + id: Some(serde_json::json!(id)), + method: method.wire_name().to_string(), + params: serde_json::to_value(params) + .map_err(|e| network(format!("cannot encode {method}: {e}")))?, + }; + let line = serde_json::to_string(&request) + .map_err(|e| network(format!("cannot encode {method}: {e}")))?; + + let answer = self.pending.register(id); + { + let mut outgoing = + self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?; + if writeln!(outgoing, "{line}").is_err() + || outgoing.flush().is_err() + { + self.pending.abandon(id); + return Err(SoulseekRs::ConnectionClosed); + } + } + + match answer.recv_timeout(CALL_TIMEOUT) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(network(error.message)), + Err(_) => { + self.pending.abandon(id); + Err(SoulseekRs::Timeout) + } + } + } + + /// A call whose reply carries nothing worth reading. + fn tell( + &self, + method: Method, + params: P, + ) -> Result<()> { + self.call(method, params).map(|_| ()) + } + + /// A call whose reply is a yes or a no. A daemon that cannot answer is a + /// "no": every caller of these already reads false as "nothing to do". + fn ask(&self, method: Method, params: P) -> bool { + self.request::(method, params) + .is_ok_and(|ack| ack.ok) + } + + fn status(&self) -> Option { + self.request(Method::DaemonStatus, ()).ok() + } + + fn shares(&self) -> SharesStatus { + self.request(Method::SharesStatusOf, ()) + .unwrap_or(SharesStatus { + folders: 0, + files: 0, + directories: Vec::new(), + }) + } + + fn transfer(username: &str, filename: &str) -> TransferRef { + TransferRef { + username: username.to_string(), + filename: filename.to_string(), + } + } + + /// Queue a transfer and hand back a channel that behaves like the library's. + /// + /// The daemon owns the real transfer; this mints the channel the caller + /// expects and registers it with the mirror, so pushed updates arrive on it + /// exactly as a local client's would. + fn start_download( + &self, + filename: String, + username: String, + size: u64, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)> { + let (sender, receiver) = channel(); + // Register before asking: a transfer that fails instantly would + // otherwise report into a mirror that is not listening yet. + self.mirror + .expect_download(&username, &filename, sender.clone()); + + let started: DownloadStarted = self + .request( + Method::DownloadStart, + DownloadStartParams { + username: username.clone(), + filename: filename.clone(), + size, + metadata: (&metadata).into(), + }, + ) + .inspect_err(|_| { + self.mirror.forget_download(&username, &filename); + })?; + + Ok((rebuild(started.download, sender), receiver)) + } + + /// One parameterless request, for the control commands that only need to + /// ask a daemon something once. + pub fn ask_for(&self, method: Method) -> Result { + self.request(method, ()) + } +} + +/// Turn the daemon's view of a transfer back into the library's, with the +/// caller's own channel spliced in. +fn rebuild(dto: DownloadDto, sender: Sender) -> Download { + let metadata = dto.metadata.into(); + Download { + username: dto.username, + filename: dto.filename, + token: dto.token, + size: dto.size, + download_directory: dto.download_directory, + status: dto.status.into(), + sender, + queue_position: dto.queue_position, + metadata, + } +} + +fn network(message: impl Into) -> SoulseekRs { + SoulseekRs::InvalidMessage(message.into()) +} + +type Halves = (Box, Box, Closer); + +fn open(endpoint: &Endpoint) -> Result { + match endpoint { + Endpoint::Tcp(address) => { + let stream = TcpStream::connect(address).map_err(|e| { + network(format!("cannot reach the daemon at {address}: {e}")) + })?; + let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; + let closer = Closer::Tcp( + stream.try_clone().map_err(SoulseekRs::NetworkError)?, + ); + Ok((Box::new(read), Box::new(stream), closer)) + } + Endpoint::Socket(path) => connect_socket(path), + } +} + +#[cfg(unix)] +fn connect_socket(path: &Path) -> Result { + let stream = + std::os::unix::net::UnixStream::connect(path).map_err(|e| { + network(format!( + "cannot reach the daemon at {}: {e}", + path.display() + )) + })?; + let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; + let closer = + Closer::Unix(stream.try_clone().map_err(SoulseekRs::NetworkError)?); + Ok((Box::new(read), Box::new(stream), closer)) +} + +#[cfg(not(unix))] +fn connect_socket(path: &Path) -> Result { + Err(network(format!( + "this platform has no Unix sockets, so {} cannot be a daemon address; \ + use host:port", + path.display() + ))) +} + +impl Drop for RemoteSession { + fn drop(&mut self) { + // Tell the daemon we are gone, so it can release the connection and + // stop fanning events at a client that is not there. + self.closer.close(); + } +} + +/// Route everything the daemon sends: replies to the call waiting for them, +/// events to the mirror. +fn read_loop( + read: Box, + pending: &Pending, + mirror: &Mirror, +) { + for line in BufReader::new(read).lines() { + let Ok(line) = line else { + return; + }; + if line.trim().is_empty() { + continue; + } + let Ok(response) = serde_json::from_str::(&line) else { + soulseek_rs::warn!("ignoring an unreadable line from the daemon"); + continue; + }; + + if let Some(method) = response.method.as_deref() { + if let Some(event) = Event::from_wire(method) { + mirror.apply(event, response.params.unwrap_or_default()); + } + continue; + } + + let Some(id) = response.id.as_ref().and_then(serde_json::Value::as_u64) + else { + continue; + }; + match (response.result, response.error) { + (Some(result), _) => pending.settle(id, Ok(result)), + (None, Some(error)) => pending.settle(id, Err(error)), + (None, None) => pending.settle(id, Ok(serde_json::Value::Null)), + } + } +} + +impl SessionApi for RemoteSession { + fn username(&self) -> String { + self.username.clone() + } + + fn daemon_endpoint(&self) -> Option { + Some(self.endpoint.clone()) + } + + fn listen_port(&self) -> Option { + self.status().and_then(|status| status.listen_port) + } + + fn session_loss(&self) -> Option { + // The pushed event is the prompt answer; the status call also covers a + // loss that happened before this client attached. + self.mirror.loss().or_else(|| { + self.status() + .and_then(|status| status.session_loss) + .map(Into::into) + }) + } + + fn search_with_cancel( + &self, + query: &str, + timeout: Duration, + cancel: Option>, + ) -> Result> { + self.tell( + Method::SearchStart, + QueryParams { + query: query.to_string(), + }, + )?; + // The window is spent here rather than in the daemon: one client's + // search must not stop the daemon answering everybody else. + soulseek_rs::Client::collect_for(timeout, cancel); + Ok(self.get_search_results(query)) + } + + fn get_search_results(&self, key: &str) -> Vec { + self.request::<_, SearchResults>( + Method::SearchResults, + QueryParams { + query: key.to_string(), + }, + ) + .map(|found| found.results.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + fn all_searches(&self) -> Vec { + self.request::<_, Searches>(Method::SearchList, ()) + .map(|listed| { + listed + .searches + .into_iter() + .map(|search| SessionSearch { + query: search.query, + files: search.files, + started_secs_ago: Some(search.started_secs_ago), + }) + .collect() + }) + .unwrap_or_default() + } + + fn forget_search(&self, query: &str) -> bool { + self.ask( + Method::SearchForget, + QueryParams { + query: query.to_string(), + }, + ) + } + + fn get_search_results_count(&self, key: &str) -> usize { + // The summary's file count, not the full set: callers poll this to + // ask whether anything has arrived, and the set can be megabytes. + self.all_searches() + .into_iter() + .find(|search| search.query == key) + .map_or(0, |search| search.files) + } + + fn try_get_search_results(&self, key: &str) -> Option> { + Some(self.get_search_results(key)) + } + + fn start_wishlist_search(&self, query: &str) -> Result<()> { + self.tell( + Method::SearchWishlist, + QueryParams { + query: query.to_string(), + }, + ) + } + + fn wishlist_interval(&self) -> Duration { + self.request::<_, IntervalSeconds>(Method::SearchWishlistInterval, ()) + .map_or(Duration::from_mins(12), |interval| { + Duration::from_secs(interval.seconds) + }) + } + + fn download( + &self, + filename: String, + username: String, + size: u64, + _download_directory: String, + // the daemon's own download directory wins — a remote caller + // cannot see the filesystem the bytes land on. Plumb a per-transfer + // directory through if a client ever needs to sort into subfolders. + ) -> Result<(Download, Receiver)> { + self.start_download( + filename, + username, + size, + DownloadMetadata::default(), + ) + } + + fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + _download_directory: String, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)> { + self.start_download(filename, username, size, metadata) + } + + fn get_all_downloads(&self) -> Vec { + let Ok(listed) = self.request::<_, Downloads>(Method::DownloadList, ()) + else { + return Vec::new(); + }; + listed + .downloads + .into_iter() + .map(|dto| { + // Nothing reads this sender: a listing covers other clients' + // transfers too, and the ones this client started already have + // a live channel from `download`. + let (sender, _) = channel(); + rebuild(dto, sender) + }) + .collect() + } + + fn pause_download(&self, username: &str, filename: &str) -> bool { + self.ask(Method::DownloadPause, Self::transfer(username, filename)) + } + + fn resume_download(&self, username: &str, filename: &str) -> bool { + self.ask(Method::DownloadResume, Self::transfer(username, filename)) + } + + fn remove_queued_download(&self, username: &str, filename: &str) -> bool { + self.ask( + Method::DownloadRemoveQueued, + Self::transfer(username, filename), + ) + } + + fn remove_download(&self, username: &str, filename: &str) -> bool { + self.mirror.forget_download(username, filename); + self.ask(Method::DownloadRemove, Self::transfer(username, filename)) + } + + fn cancel_download(&self, username: &str, filename: &str) -> bool { + self.ask(Method::DownloadCancel, Self::transfer(username, filename)) + } + + fn uploads(&self) -> Vec { + self.request::<_, Uploads>(Method::UploadList, ()) + .map(|listed| listed.uploads.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + fn take_upload_events(&self) -> Vec { + self.mirror.take_uploads() + } + + fn cancel_upload(&self, username: &str, filename: &str) -> bool { + self.ask(Method::UploadCancel, Self::transfer(username, filename)) + } + + fn set_upload_slots(&self, slots: usize) { + let _ = self.tell(Method::UploadSlots, SlotsParams { slots }); + } + + fn check_privileges(&self) -> Result<()> { + self.tell(Method::PrivilegesCheck, ()) + } + + fn own_privilege_seconds(&self) -> Option { + self.request::<_, Seconds>(Method::PrivilegesOwn, ()) + .ok()? + .seconds + } + + fn request_room_list(&self) -> Result<()> { + self.tell(Method::RoomListRequest, ()) + } + + fn join_room(&self, room: &str) -> Result<()> { + self.tell( + Method::RoomJoin, + RoomRef { + room: room.to_string(), + }, + ) + } + + fn leave_room(&self, room: &str) -> Result<()> { + self.tell( + Method::RoomLeave, + RoomRef { + room: room.to_string(), + }, + ) + } + + fn say_in_room(&self, room: &str, message: &str) -> Result<()> { + self.tell( + Method::RoomSay, + SayParams { + room: room.to_string(), + message: message.to_string(), + }, + ) + } + + fn room_members(&self, room: &str) -> Vec { + self.request::<_, Members>( + Method::RoomMembers, + RoomRef { + room: room.to_string(), + }, + ) + .map(|members| members.users) + .unwrap_or_default() + } + + fn room_member_stats(&self, room: &str) -> Vec { + self.request::<_, MemberStats>( + Method::RoomMemberStats, + RoomRef { + room: room.to_string(), + }, + ) + .map(|stats| stats.members.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + fn take_room_events(&self) -> Vec { + self.mirror.take_rooms() + } + + fn send_private_message( + &self, + username: &str, + message: &str, + ) -> Result<()> { + self.tell( + Method::MessageSend, + MessageParams { + username: username.to_string(), + message: message.to_string(), + }, + ) + } + + fn take_private_messages(&self) -> Vec { + self.mirror.take_messages() + } + + fn browse_user(&self, username: &str) -> Result<()> { + self.tell( + Method::BrowseUser, + UserRef { + username: username.to_string(), + }, + ) + } + + fn take_browse_result( + &self, + username: &str, + ) -> Option> { + self.mirror.take_browse(username) + } + + fn request_user_info(&self, username: &str) -> Result<()> { + self.tell( + Method::UserRequest, + UserRef { + username: username.to_string(), + }, + ) + } + + fn watch_user(&self, username: &str) -> Result<()> { + self.tell( + Method::UserWatch, + UserRef { + username: username.to_string(), + }, + ) + } + + fn unwatch_user(&self, username: &str) -> Result<()> { + self.tell( + Method::UserUnwatch, + UserRef { + username: username.to_string(), + }, + ) + } + + fn watched_users(&self) -> Vec { + self.request::<_, Watched>(Method::UserWatched, ()) + .map(|watched| watched.users) + .unwrap_or_default() + } + + fn user_info(&self, username: &str) -> Option { + self.request::<_, UserResult>( + Method::UserInfoOf, + UserRef { + username: username.to_string(), + }, + ) + .ok()? + .user + .map(Into::into) + } + + fn message_history(&self) -> Vec { + self.request::<_, Messages>(Method::MessageHistory, ()) + .map(|history| history.messages) + .unwrap_or_default() + } + + fn shared_counts(&self) -> (u32, u32) { + let shares = self.shares(); + (shares.folders, shares.files) + } + + fn shared_directories(&self) -> Vec { + self.shares().directories + } + + // ponytail: a blocking round-trip that carries the whole index — a very + // large share is tens of MB of JSON before the next redraw. It is only + // ever a keypress, never a frame; if that stops being tolerable, push it + // as an event the way `browse.user` answers with `event.browse`. + fn shared_listing(&self) -> Vec { + self.request::<_, SharedListing>(Method::SharesFiles, ()) + .map(|listing| { + listing.directories.into_iter().map(Into::into).collect() + }) + .unwrap_or_default() + } + + fn set_shared_directories(&self, directories: Vec) -> Result<()> { + self.tell(Method::SharesSet, DirectoriesParams { directories }) + } + + 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)] +mod tests { + use super::*; + + #[test] + fn an_endpoint_tells_a_socket_path_from_a_host_and_port() { + assert_eq!( + Endpoint::parse("/run/soulseek/daemon.sock"), + Endpoint::Socket(PathBuf::from("/run/soulseek/daemon.sock")) + ); + assert_eq!( + Endpoint::parse("127.0.0.1:5030"), + Endpoint::Tcp("127.0.0.1:5030".to_string()) + ); + assert_eq!( + Endpoint::parse("daemon.example:5030"), + Endpoint::Tcp("daemon.example:5030".to_string()) + ); + } + + #[test] + fn only_a_socket_authenticates_itself() { + assert!(Endpoint::parse("/tmp/daemon.sock").self_authenticating()); + assert!(!Endpoint::parse("host:5030").self_authenticating()); + } + + #[test] + fn a_reply_settles_the_call_that_is_waiting_for_it() { + let pending = Pending::default(); + let answer = pending.register(7); + pending.settle(7, Ok(serde_json::json!({ "ok": true }))); + assert_eq!( + answer.recv().unwrap().unwrap(), + serde_json::json!({ "ok": true }) + ); + } + + #[test] + fn a_reply_for_an_abandoned_call_is_dropped_quietly() { + let pending = Pending::default(); + let answer = pending.register(7); + pending.abandon(7); + pending.settle(7, Ok(serde_json::Value::Null)); + assert!(answer.recv().is_err()); + } + + #[test] + fn a_closed_connection_fails_every_waiting_call() { + // Otherwise each blocked caller would sit out the full timeout against + // a socket nobody is reading. + let pending = Pending::default(); + let first = pending.register(1); + let second = pending.register(2); + pending.fail_all(); + assert!(first.recv().unwrap().is_err()); + assert!(second.recv().unwrap().is_err()); + } + + #[test] + fn the_reader_routes_replies_to_calls_and_events_to_the_mirror() { + let pending = Pending::default(); + let mirror = Mirror::default(); + let answer = pending.register(1); + + let lines = concat!( + r#"{"jsonrpc":"2.0","method":"event.room","params":{"event":"left","room":"lobby"}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#, + "\n", + ); + read_loop(Box::new(lines.as_bytes()), &pending, &mirror); + + assert_eq!( + answer.recv().unwrap().unwrap(), + serde_json::json!({ "ok": true }) + ); + assert_eq!( + mirror.take_rooms(), + [RoomEvent::Left { + room: "lobby".into() + }] + ); + } + + #[test] + fn an_error_reply_reaches_the_caller_as_an_error() { + let pending = Pending::default(); + let mirror = Mirror::default(); + let answer = pending.register(1); + + read_loop( + Box::new( + concat!( + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"nope","data":{"exit":4}}}"#, + "\n" + ) + .as_bytes(), + ), + &pending, + &mirror, + ); + + let error = answer.recv().unwrap().expect_err("an error reply"); + assert_eq!(error.message, "nope"); + assert_eq!(error.exit(), crate::output::Exit::NoResults); + } + + #[test] + fn a_junk_line_does_not_stop_the_reader() { + let pending = Pending::default(); + let mirror = Mirror::default(); + let answer = pending.register(1); + + read_loop( + Box::new( + concat!( + "not json at all\n", + r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#, + "\n" + ) + .as_bytes(), + ), + &pending, + &mirror, + ); + + assert!( + answer.recv().unwrap().is_ok(), + "one bad line must not cost the reply behind it" + ); + } +} diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs new file mode 100644 index 0000000..f3419cc --- /dev/null +++ b/soulseek-rs/src/run.rs @@ -0,0 +1,709 @@ +//! Wiring the command line to a session: which config, which credentials, and +//! whether this run opens a session of its own or borrows a daemon's. + +use crate::cli::{ + Cli, Commands, ConfigCommand, DaemonCommand, SharesCommand, WishCommand, + 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}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use std::{env, io}; + +pub fn run(mut cli: Cli, out: &Out) -> CliResult { + // Before anything opens a socket: every entry point that logs in — the + // TUI, `serve`, one-shot commands, the daemon — holds a socket per peer, + // and the soft limit a shell hands its children (256 on macOS) is gone + // after one busy search. The raise lived in the daemon first; the other + // entry points run the same peer machinery and crashed the same way. + match raise_open_file_limit() { + Ok(limit) if limit < WANTED_OPEN_FILES => out.warn(&format!( + "only {limit} open files allowed; a busy search wants \ + {WANTED_OPEN_FILES}. Raise it with `ulimit -n \ + {WANTED_OPEN_FILES}` first." + )), + Ok(_) => {} + Err(e) => { + out.warn(&format!("could not check the open-file limit: {e}")); + } + } + + match &cli.command { + Some(Commands::Completions(command)) => { + return crate::commands::completions::run(out, command); + } + Some(Commands::Man) => return crate::commands::completions::man(), + _ => {} + } + + let config_path = config_path(&cli); + let file_config = match &config_path { + Some(path) => crate::persist::config::FileConfig::load(path) + .map_err(|e| CliError::usage(e.to_string()))?, + None => crate::persist::config::FileConfig::default(), + }; + let resolved = crate::persist::config::resolve(&cli, &file_config); + + let Some(command) = cli.command.take() else { + // Without a terminal the TUI would panic on its first draw. A script + // that reached this point wanted a subcommand, so say that instead of + // crashing. + if !std::io::stdout().is_terminal() { + return Err(CliError::usage( + "the interactive interface needs a terminal; run a subcommand \ + for scripted use (see --help)", + )); + } + // A running daemon already holds a session; the TUI attaches to it + // rather than logging in a second time, which the server would answer + // by cutting the daemon off. + if let Some(endpoint) = daemon_endpoint(&cli, &resolved) { + return run_attached_tui(&resolved, &endpoint, config_path); + } + return run_default_tui(&cli, &resolved, config_path); + }; + + // Anything that only reads the network stack or the config file runs + // before credentials are demanded: asking a script for a login it does not + // need would be a poor trade for a `config get`. + let store = crate::commands::settings::Store { + path: config_path, + config: file_config, + }; + match command { + Commands::Portmap => { + return crate::commands::portmap(out, resolved.listener_port); + } + 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::Config(ConfigCommand::Path) => { + return crate::commands::settings::config_path(out, &store); + } + Commands::Config(ConfigCommand::List) => { + crate::commands::settings::config_list(out, &store); + return Ok(()); + } + Commands::Config(ConfigCommand::Get { ref key }) => { + return crate::commands::settings::config_get(out, &store, key); + } + Commands::Config(ConfigCommand::Set { ref key, ref value }) => { + return crate::commands::settings::config_set( + out, + &store, + key, + value, + || live_daemon(&cli, &resolved), + ); + } + Commands::Shares(SharesCommand::List) => { + return crate::commands::settings::shares_list( + out, + &store, + &resolved.shared_dirs, + ); + } + Commands::Shares(SharesCommand::Add { ref directory }) => { + return crate::commands::settings::shares_add( + out, + &store, + directory, + &live_daemon(&cli, &resolved), + ); + } + Commands::Shares(SharesCommand::Remove { ref directory }) => { + return crate::commands::settings::shares_remove( + out, + &store, + directory, + &live_daemon(&cli, &resolved), + ); + } + Commands::Wish(WishCommand::Add { ref query }) => { + return crate::commands::wish::add(out, &store, query); + } + Commands::Wish(WishCommand::Remove { ref query }) => { + return crate::commands::wish::remove(out, &store, query); + } + Commands::Wish(WishCommand::List) => { + return crate::commands::wish::list(out, &store); + } + // Controlling a daemon needs no account of our own: the daemon is + // already logged in, and demanding credentials a script never had + // would turn "is it running?" into a configuration error. + Commands::Daemon(crate::cli::DaemonArgs { + command: Some(ref command), + .. + }) => { + let endpoint = daemon_endpoint(&cli, &resolved); + let token = resolved.daemon_token; + return match command { + DaemonCommand::Token => crate::commands::daemon::token(out), + DaemonCommand::Status => crate::commands::daemon::status( + out, + endpoint.as_ref(), + token, + ), + DaemonCommand::Stop => { + crate::commands::daemon::stop(out, endpoint.as_ref(), token) + } + }; + } + _ => {} + } + + let ctx = context(&cli, &resolved, out)?; + match command { + 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) + } + Commands::Wish(WishCommand::Run(ref args)) => { + crate::commands::wish::run(&ctx, &store, args) + } + Commands::Daemon(ref args) => { + crate::daemon::run(&ctx, args.bind.as_deref(), args.upload_slots) + } + Commands::Serve(ref args) => { + let sweeper = args + .wishlist + .then(|| { + crate::commands::wish::Sweeper::new(store.config.wishes()) + }) + .flatten(); + if args.wishlist && sweeper.is_none() { + out.warn( + "--wishlist has nothing to look for; add one with \ + `wish add QUERY`", + ); + } + crate::commands::peer::serve(&ctx, args, sweeper) + } + other => crate::commands::run(&ctx, other), + } +} + +/// Which config file to read: an explicit `--config`, the platform default, +/// or none at all under `--no-config`. +fn config_path(cli: &Cli) -> Option { + if cli.no_config { + return None; + } + cli.config + .clone() + .or_else(crate::persist::paths::config_file) +} + +/// Assemble everything a one-shot command needs, demanding the credentials +/// the interactive login screen would otherwise ask for. +fn context( + cli: &Cli, + resolved: &crate::persist::config::Resolved, + out: &Out, +) -> CliResult { + let daemon = daemon_endpoint(cli, resolved); + // A run routed to a daemon needs no account of its own: the daemon is + // already logged in, and asking for credentials it will not use would + // stop a script that never had any. + let (username, password) = if daemon.is_some() { + (String::new(), String::new()) + } else { + ( + resolved.username.clone().ok_or_else(missing_username)?, + password(cli, resolved)?.ok_or_else(|| { + CliError::usage( + "no password: pass --password/--password-stdin, set \ + SOULSEEK_PASSWORD, store one in the OS keychain, or set \ + password_cmd", + ) + })?, + ) + }; + let (host, port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; + + // A daemon has its own server and its own filesystem. Asking it once, up + // front, is what keeps every command that reports a path or a server + // honest — `whoami` naming the right server, `download` naming the file + // where the bytes actually landed. + let described = daemon.as_ref().and_then(|endpoint| { + describe_daemon(endpoint, resolved.daemon_token.clone()) + }); + let server_address = described.as_ref().map_or_else( + || PeerAddress::new(host, port), + |status| { + parse_server_address(&status.server).map_or_else( + |_| PeerAddress::new(status.server.clone(), 0), + |(host, port)| PeerAddress::new(host, port), + ) + }, + ); + let download_dir = described.as_ref().map_or_else( + || resolved.download_dir.clone(), + |status| status.download_dir.clone(), + ); + + Ok(Ctx { + out: out.clone(), + settings: ClientSettings { + username, + password, + server_address, + 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, + use_username_dirs: resolved.use_username_dirs, + max_concurrent_downloads: resolved.max_concurrent_downloads, + search_timeout: Duration::from_secs(resolved.search_timeout), + daemon, + daemon_token: resolved.daemon_token.clone(), + session: std::sync::OnceLock::new(), + }) +} + +/// The daemon a config change should also reach, if one is running. +fn live_daemon( + cli: &Cli, + resolved: &crate::persist::config::Resolved, +) -> crate::commands::settings::LiveDaemon { + crate::commands::settings::LiveDaemon { + endpoint: daemon_endpoint(cli, resolved), + token: resolved.daemon_token.clone(), + } +} + +/// Ask a daemon what it is, so this run describes the session it will actually +/// use. A daemon that cannot be reached is left to `Session::open` to report, +/// which produces a better message than anything this early probe could. +fn describe_daemon( + endpoint: &crate::remote::Endpoint, + token: Option, +) -> Option { + let token = token.or_else(crate::daemon::stored_token); + crate::remote::RemoteSession::connect(endpoint, token) + .ok()? + .ask_for(crate::daemon::proto::Method::DaemonStatus) + .ok() +} + +/// Which daemon this run should use, if any. +/// +/// An address given explicitly always wins. Otherwise a daemon listening on +/// the local socket is used automatically — the convention Docker and +/// ssh-agent set, and what lets an agent run a command with no flags at all. +/// With nothing listening, the run opens its own session exactly as it always +/// has. +fn daemon_endpoint( + cli: &Cli, + resolved: &crate::persist::config::Resolved, +) -> Option { + if cli.no_daemon { + return None; + } + // Flag, then environment, then config file — `resolved` has already + // layered those — and otherwise a daemon on this machine, if one is up. + if let Some(address) = &resolved.daemon { + return Some(crate::remote::Endpoint::parse(address)); + } + crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket) +} + +/// Resolve the password: stdin beats the flag/env, which beats the keychain, +/// which beats `password_cmd`. +fn password( + cli: &Cli, + resolved: &crate::persist::config::Resolved, +) -> CliResult> { + if cli.password_stdin { + return password_from_stdin().map(Some); + } + Ok(crate::persist::secret::resolve_password( + cli.password.as_deref(), + resolved.username.as_deref(), + resolved.password_cmd.as_deref(), + &crate::persist::secret::KeyringStore, + )) +} + +/// Read a password from the first line of stdin, the way `docker login` does. +pub fn password_from_stdin() -> CliResult { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return Err(CliError::usage("no password on stdin")); + } + Ok(line.to_string()) +} + +/// What to say when nothing supplied a username. +#[must_use] +pub fn missing_username() -> CliError { + CliError::usage( + "no username: pass --username, set SOULSEEK_USERNAME, or put one in \ + config.toml", + ) +} + +/// Validate the configured shares. A bad entry is a warning, not a failure: +/// the client simply shares less. +fn shared_directories( + resolved: &crate::persist::config::Resolved, + out: &Out, +) -> Vec { + let mut valid = Vec::new(); + for dir in &resolved.shared_dirs { + match crate::directories::resolve_shared_directory(Some(dir)) { + Ok(Some(path)) => valid.push(path.display().to_string()), + Ok(None) => {} + Err(e) => out.warn(&format!("ignoring shared directory: {e}")), + } + } + valid +} + +/// Decide how much this run says on stderr. +/// +/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from +/// the environment is honoured, and otherwise logging stays at errors only so +/// stderr carries this program's own output and nothing else. +pub fn init_logging(cli: &Cli) { + let level = match cli.verbose { + 0 => (env::var_os("LOG_LEVEL").is_none() + && env::var_os("RUST_LOG").is_none()) + .then_some("ERROR"), + 1 => Some("WARN"), + 2 => Some("INFO"), + 3 => Some("DEBUG"), + _ => Some("TRACE"), + }; + // SAFETY: called before any threads are spawned. + unsafe { + if let Some(level) = level { + env::set_var("LOG_LEVEL", level); + } + if let Some(log_file) = &cli.log_file { + env::set_var("LOG_FILE", log_file.as_os_str()); + } + } +} + +/// Clear the screen, turn on mouse reporting and take over the terminal. +fn enter_terminal() -> ratatui::DefaultTerminal { + use ratatui::crossterm::{ + event::EnableMouseCapture, + execute, + terminal::{Clear, ClearType}, + }; + + let _ = + execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); + ratatui::init() +} + +/// 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. Logging out from the settings popup starts the round again. +fn run_default_tui( + cli: &Cli, + resolved: &crate::persist::config::Resolved, + config_path: Option, +) -> CliResult { + let out = Out::new(false, false); + let (server_host, server_port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; + + // Make sure the download folder exists up front: first-run defaults point + // download and shared at the same fresh Downloads/Soulseek folder, and + // shared-directory validation requires an existing path. + if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) { + out.warn(&format!( + "could not create download directory {}: {e}", + resolved.download_dir + )); + } + + let shared_directories = shared_directories(resolved, &out); + let secret_store = crate::persist::secret::KeyringStore; + 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, + password, + server_address: PeerAddress::new(server_host.clone(), server_port), + enable_listen, + listen_port, + shared_directories: shared_directories.clone(), + accept_children, + version: ClientVersion::REFERENCE_CLIENT, + }; + + // 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, + username.clone(), + initial_password.take(), + ); + + 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 bound_port = outcome.client.listen_port(); + let _port_mapper = + bound_port.map(crate::port_mapping::PortMapper::spawn); + + let store = crate::persist::paths::state_dir() + .map(crate::persist::state::StateStore::new); + + let listener_fallback = bound_port + .filter(|&bound| listen_port != 0 && bound != listen_port) + .map(|bound| (listen_port, bound)); + + let exit = launch_main_tui( + terminal, + Arc::new(outcome.client), + resolved.download_dir.clone(), + resolved.use_username_dirs, + Duration::from_secs(resolved.search_timeout), + store, + config_path.clone(), + listener_fallback, + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; + + if exit == TuiExit::Quit { + return Ok(()); + } + + // 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}")); + } + } +} + +/// 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. +/// +/// No login screen: the daemon is already logged in, so there is nothing to +/// ask for and nothing to store. Everything the UI shows comes back over the +/// socket, and what it persists stays daemon-side. +fn run_attached_tui( + resolved: &crate::persist::config::Resolved, + endpoint: &crate::remote::Endpoint, + config_path: Option, +) -> CliResult { + // The resolved token, not the flag alone: `daemon_token` in config.toml + // is the documented remote setup, and the flag/env are layered into it. + let token = resolved + .daemon_token + .clone() + .or_else(crate::daemon::stored_token); + let session = crate::remote::RemoteSession::connect(endpoint, token) + .map_err(|e| { + CliError::connection(format!( + "cannot use the daemon at {endpoint}: {e}" + )) + })?; + + // The settings form shows and edits the *daemon's* download folder — the + // one transfers actually land in — not this machine's configured default. + // The connect above just succeeded, so a daemon that cannot answer this + // is a real problem to report, not one to paper over with a local path. + let download_dir = session + .ask_for::( + crate::daemon::proto::Method::DaemonStatus, + ) + .map(|status| status.download_dir) + .map_err(|e| { + CliError::connection(format!( + "the daemon at {endpoint} did not report its status: {e}" + )) + })?; + + soulseek_rs::utils::logger::enable_buffering(); + let terminal = enter_terminal(); + + // 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), + download_dir, + resolved.use_username_dirs, + Duration::from_secs(resolved.search_timeout), + None, + config_path, + None, + ) + .map(|_| ()) + .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +} + +/// After a successful login, remember the username in config.toml and — when +/// it was typed into the form — the password in the OS keychain. Both are +/// best-effort: failing to persist must not break a working session. +fn persist_credentials( + outcome: &crate::ui::login::LoginOutcome, + config_path: Option<&Path>, + secret_store: &dyn crate::persist::secret::SecretStore, +) { + 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) + { + eprintln!("⚠️ Could not store password in keychain: {e}"); + } +} + +/// 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 +/// during a busy search, plus transfers, the share index, and control +/// connections. A shell hands its children a soft limit of 256 on macOS, +/// which a single search blows through. +const WANTED_OPEN_FILES: u64 = 8192; + +/// Ask the OS for enough open files to run a busy session, and report what we +/// actually got. +/// +/// Raising our own soft limit is what a service does: the limit a shell +/// inherits is sized for a shell. The hard limit is the ceiling, and asking +/// past it fails outright rather than clamping, so this asks for the smaller +/// of what we want and what we are allowed. +#[cfg(unix)] +fn raise_open_file_limit() -> std::io::Result { + // SAFETY: every call takes a pointer to a `rlimit` this function owns, and + // none of them retains it. + unsafe { + let mut limit = std::mem::zeroed::(); + if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + if limit.rlim_cur >= WANTED_OPEN_FILES { + return Ok(limit.rlim_cur); + } + + let previous = limit.rlim_cur; + let ceiling = if limit.rlim_max == libc::RLIM_INFINITY { + WANTED_OPEN_FILES + } else { + limit.rlim_max.min(WANTED_OPEN_FILES) + }; + + // macOS rejects an unlimited *hard* limit for open files, so raising + // the soft one while leaving `rlim_max` as it came back fails with + // EINVAL. Naming both is what it will accept. + for candidate in [ + libc::rlimit { + rlim_cur: ceiling, + rlim_max: limit.rlim_max, + }, + libc::rlimit { + rlim_cur: ceiling, + rlim_max: ceiling, + }, + ] { + if libc::setrlimit(libc::RLIMIT_NOFILE, &raw const candidate) == 0 { + return Ok(ceiling); + } + } + Ok(previous) + } +} + +#[cfg(not(unix))] +const fn raise_open_file_limit() -> std::io::Result { + Ok(WANTED_OPEN_FILES) +} diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs new file mode 100644 index 0000000..24eec71 --- /dev/null +++ b/soulseek-rs/src/ui/downloads.rs @@ -0,0 +1,234 @@ +use crate::cli::VERSION; +use crate::models::DownloadEntry; +use crate::ui::{ + BYTES_PER_MB, accent_style, dimmed_style, error_style, + format_bytes_progress, format_progress_bar, info_style, pane_block, + plain_title, primary_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use soulseek_rs::DownloadStatus; +use std::fmt::Write as _; + +/// Bytes transferred, bytes expected and combined speed across the downloads +/// that are actually running — or `None` when none are, so the caller can hide +/// the progress readout entirely. +/// +/// Only `InProgress` downloads count. A queued download has not started and a +/// failed one never will, so adding their size to the total just pins the bar +/// near zero for the rest of the session. +fn active_progress(downloads: &[DownloadEntry]) -> Option<(u64, u64, f64)> { + let mut transferred = 0; + let mut expected = 0; + let mut speed = 0.0; + let mut running = false; + + for entry in downloads { + if !matches!(entry.download.status, DownloadStatus::InProgress { .. }) { + continue; + } + running = true; + transferred += entry.download.bytes_downloaded(); + expected += entry.download.size; + speed += entry.download.speed_bytes_per_sec(); + } + + running.then_some((transferred, expected, speed)) +} + +/// Renders download statistics in a reusable way +/// The status bar. +/// +/// `daemon` names the session's owner when this window is attached to one. It +/// belongs in the title because a shared session behaves differently from a +/// private one: another window can add transfers, and closing this one leaves +/// them running. +pub fn render_download_stats( + frame: &mut Frame, + area: ratatui::layout::Rect, + downloads: &[DownloadEntry], + active_count: usize, + daemon: Option<&str>, + listener_fallback: Option<(u16, u16)>, +) { + let completed = downloads + .iter() + .filter(|d| matches!(d.download.status, DownloadStatus::Completed)) + .count(); + let failed = downloads + .iter() + .filter(|d| { + matches!( + d.download.status, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut + ) + }) + .count(); + let queued = downloads + .iter() + .filter(|d| matches!(d.download.status, DownloadStatus::Queued)) + .count(); + let paused = downloads + .iter() + .filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. })) + .count(); + + let active = active_progress(downloads); + + let mut title = match daemon { + Some(endpoint) => format!("Status · daemon {endpoint}"), + None => "Status".to_string(), + }; + if let Some((configured, bound)) = listener_fallback { + let _ = + write!(title, " · port {configured} in use, listening on {bound}"); + } + let block = pane_block(false).title(plain_title(&title, false)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + // Split into two equal 50% containers + let chunks = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(inner_area); + + let count = |value: usize, style: Style| { + Span::styled(value.to_string(), style.add_modifier(Modifier::BOLD)) + }; + let stats_line = Line::from(vec![ + Span::styled("soulseek-rs", primary_style()), + Span::styled(format!(" 🦀 v{VERSION}"), dimmed_style()), + Span::styled(" Downloads: ", dimmed_style()), + count(active_count, accent_style()), + Span::styled(" active, ", dimmed_style()), + count(completed, success_style()), + Span::styled(" completed, ", dimmed_style()), + count(failed, error_style()), + Span::styled(" failed, ", dimmed_style()), + count(queued, warning_style()), + Span::styled(" queued, ", dimmed_style()), + count(paused, info_style()), + Span::styled(" paused", dimmed_style()), + ]); + + let stats_paragraph = Paragraph::new(stats_line); + frame.render_widget(stats_paragraph, chunks[0]); + + // Nothing is transferring, so there is no progress to draw: leave the + // right-hand half blank instead of showing an idle 0 B / 0 B bar. + let Some((total_downloaded, total_size, total_speed)) = active else { + return; + }; + let overall_progress = if total_size > 0 { + (total_downloaded as f64 / total_size as f64 * 100.0) as u8 + } else { + 0 + }; + let progress_ratio = if total_size > 0 { + total_downloaded as f64 / total_size as f64 + } else { + 0.0 + }; + let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0; + + let right_width = chunks[1].width as usize; + let bar_width = right_width.saturating_sub(42).max(10); + let progress_bar = + format_progress_bar(progress_ratio, bar_width, overall_progress); + let data_str = format_bytes_progress(total_downloaded, total_size); + + let mut spans: Vec = Vec::new(); + + spans.extend(data_str.spans); + spans.push(Span::styled(" · ", dimmed_style())); + spans.push(Span::styled( + format!("{speed_mb}"), + warning_style().add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled(" MB/s", dimmed_style())); + spans.push(Span::styled(" · ", dimmed_style())); + spans.extend(progress_bar.spans); + + let progress_line = Line::from(spans); + let progress_paragraph = + Paragraph::new(progress_line).alignment(Alignment::Right); + frame.render_widget(progress_paragraph, chunks[1]); +} + +#[cfg(test)] +mod tests { + use super::{DownloadEntry, DownloadStatus, active_progress}; + use soulseek_rs::types::{Download, DownloadMetadata}; + + fn entry(size: u64, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = std::sync::mpsc::channel(); + DownloadEntry { + download: Download { + username: "peer".to_string(), + filename: "song.flac".to_string(), + token: 1, + size, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + #[test] + fn only_running_downloads_count_towards_the_bar() { + let downloads = vec![ + entry( + 100, + DownloadStatus::InProgress { + bytes_downloaded: 40, + total_bytes: 100, + speed_bytes_per_sec: 512.0, + }, + ), + // None of these may reach the bar: a queued file has not started, + // and a failed or timed-out one never will. Counting their size + // would drag the bar down for the rest of the session. + entry(9_000, DownloadStatus::Queued), + entry(9_000, DownloadStatus::Failed(None)), + entry(9_000, DownloadStatus::TimedOut), + ]; + + let (transferred, expected, speed) = + active_progress(&downloads).expect("one download is running"); + assert_eq!(transferred, 40); + assert_eq!(expected, 100, "queued and failed sizes must be excluded"); + assert!((speed - 512.0).abs() < f64::EPSILON); + } + + #[test] + fn nothing_running_hides_the_bar() { + assert!(active_progress(&[]).is_none(), "no downloads at all"); + + // Completed and paused transfers are not processing either, so the + // readout stays hidden rather than showing a stale bar. + let idle = vec![ + entry(100, DownloadStatus::Completed), + entry(100, DownloadStatus::Queued), + entry( + 100, + DownloadStatus::Paused { + bytes_downloaded: 50, + total_bytes: 100, + }, + ), + ]; + assert!(active_progress(&idle).is_none()); + } +} diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs new file mode 100644 index 0000000..6376ba0 --- /dev/null +++ b/soulseek-rs/src/ui/login.rs @@ -0,0 +1,712 @@ +//! First-run login/registration screen. +//! +//! Soulseek auto-registers unknown usernames on login, so a single form +//! covers both flows. The form itself ([`LoginForm`]) is a pure state +//! machine so it can be tested without a terminal; the IO loop +//! ([`run_login_flow`]) drives it against a real terminal and client. + +use crate::ui::{ + GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char, + info_style, mask, pane_block, primary_style, title_style, warning_style, +}; +use color_eyre::Result; +use ratatui::{ + DefaultTerminal, Frame, + crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, + }, + layout::{Constraint, Flex, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; +use soulseek_rs::{CancelHandle, Client, ClientSettings, SoulseekRs}; +use std::sync::mpsc::{Receiver, channel}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +const RESTART_FLOOR: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginField { + Username, + Password, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoginPhase { + /// User is typing into the form. + Editing, + /// A connect+login attempt is in flight. + Connecting, + /// The last attempt failed; the message is shown until the next key. + Failed(String), + Rejected(String), +} + +/// What the caller should do after feeding a key to the form. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginAction { + None, + /// Both fields are filled and the user pressed Enter or r. + Submit, + Stop, + /// User pressed Esc, q or Ctrl-C — abort the whole program. + Cancel, +} + +pub struct LoginForm { + pub username: String, + pub password: String, + pub focused: LoginField, + pub phase: LoginPhase, +} + +impl LoginForm { + pub fn new(username: Option) -> Self { + let username = username.unwrap_or_default(); + let focused = if username.is_empty() { + LoginField::Username + } else { + LoginField::Password + }; + Self { + username, + password: String::new(), + focused, + phase: LoginPhase::Editing, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return LoginAction::Cancel; + } + + match self.phase { + LoginPhase::Connecting => { + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit, + KeyCode::Esc => { + self.phase = LoginPhase::Editing; + LoginAction::Stop + } + KeyCode::Char('q') => LoginAction::Cancel, + _ => LoginAction::None, + }; + } + LoginPhase::Failed(_) => { + self.phase = LoginPhase::Editing; + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => self.submit(), + KeyCode::Esc => LoginAction::Cancel, + _ => LoginAction::None, + }; + } + LoginPhase::Rejected(_) => { + // Any key acknowledges the error; the password was wrong (or + // the name is taken), so make the user retype it. + self.phase = LoginPhase::Editing; + self.password.clear(); + self.focused = LoginField::Password; + if key.code == KeyCode::Esc { + return LoginAction::Cancel; + } + return LoginAction::None; + } + LoginPhase::Editing => {} + } + + match key.code { + KeyCode::Esc => LoginAction::Cancel, + KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => { + self.focused = match self.focused { + LoginField::Username => LoginField::Password, + LoginField::Password => LoginField::Username, + }; + LoginAction::None + } + KeyCode::Enter => self.submit(), + KeyCode::Backspace => { + self.focused_field_mut().pop(); + LoginAction::None + } + KeyCode::Char(c) => { + self.focused_field_mut().push(c); + LoginAction::None + } + _ => LoginAction::None, + } + } + + fn submit(&mut self) -> LoginAction { + if !self.username.is_empty() && !self.password.is_empty() { + self.phase = LoginPhase::Connecting; + return LoginAction::Submit; + } + // Move to the first empty field instead of submitting. + self.focused = if self.username.is_empty() { + LoginField::Username + } else { + LoginField::Password + }; + LoginAction::None + } + + const fn focused_field_mut(&mut self) -> &mut String { + match self.focused { + LoginField::Username => &mut self.username, + LoginField::Password => &mut self.password, + } + } +} + +/// A successful login: the connected client plus the credentials that worked. +pub struct LoginOutcome { + pub client: Client, + pub username: String, + pub password: String, + /// True when the password was typed into the form (as opposed to coming + /// from CLI/env/keychain) — only then do we offer to store it. + pub entered_via_form: bool, +} + +/// Drive the login screen until a login succeeds (`Some`) or the user +/// quits (`None`). When both credentials are already known an +/// attempt starts immediately and the form is only shown on failure. +pub fn run_login_flow( + terminal: &mut DefaultTerminal, + make_settings: &dyn Fn(String, String) -> ClientSettings, + initial_username: Option, + initial_password: Option, +) -> Result> { + let start = |form: &LoginForm, previous: Option>| { + let mut client = Client::with_settings(make_settings( + form.username.clone(), + form.password.clone(), + )); + let cancel = client.cancel_handle(); + Attempt::spawn(previous, cancel, move || { + let outcome = client + .connect() + .map_err(|e| { + LoginPhase::Failed(format!("Failed to connect: {e}")) + }) + .and_then(|()| verdict(client.login())); + outcome.map(|()| client) + }) + }; + let mut form = LoginForm::new(initial_username.clone()); + let mut attempt = match &initial_password { + Some(password) if !form.username.is_empty() => { + form.password.clone_from(password); + form.phase = LoginPhase::Connecting; + Some(start(&form, None)) + } + _ => None, + }; + let mut started = Instant::now(); + + loop { + if let Some(current) = &attempt { + let connecting = form.phase == LoginPhase::Connecting; + match current.result.try_recv() { + Ok(_) | Err(std::sync::mpsc::TryRecvError::Disconnected) + if !connecting => {} + Ok(Ok(client)) => { + let entered_via_form = initial_username.as_deref() + != Some(form.username.as_str()) + || initial_password.as_deref() + != Some(form.password.as_str()); + return Ok(Some(LoginOutcome { + client, + username: form.username, + password: form.password, + entered_via_form, + })); + } + Ok(Err(failure)) => form.phase = failure, + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + form.phase = + LoginPhase::Failed("Connection thread died".into()); + } + } + } + + let spinner = + get_spinner_char(started.elapsed().subsec_millis() as usize / 100); + terminal.draw(|frame| render(frame, &form, spinner))?; + + if poll(Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + let restarting = form.phase == LoginPhase::Connecting; + match form.handle_key(key) { + LoginAction::Cancel => return Ok(None), + LoginAction::Submit + if restarting && started.elapsed() < RESTART_FLOOR => {} + LoginAction::Submit => { + attempt = Some(start(&form, attempt.take())); + started = Instant::now(); + } + LoginAction::Stop => { + if let Some(current) = &attempt { + current.cancel.cancel(); + } + } + LoginAction::None => {} + } + } + } +} + +/// One login attempt, run on a background thread so the UI stays responsive. +struct Attempt { + cancel: CancelHandle, + thread: JoinHandle<()>, + result: Receiver>, +} + +impl Attempt { + fn spawn( + previous: Option, + cancel: CancelHandle, + work: impl FnOnce() -> Result + Send + 'static, + ) -> Self { + if let Some(previous) = &previous { + previous.cancel.cancel(); + } + let (tx, result) = channel(); + let own = cancel.clone(); + let thread = std::thread::spawn(move || { + if let Some(previous) = previous { + let _ = previous.thread.join(); + } + if own.is_cancelled() { + return; + } + let outcome = work(); + if !own.is_cancelled() { + let _ = tx.send(outcome); + } + }); + Self { + cancel, + thread, + result, + } + } +} + +fn verdict(login: soulseek_rs::Result) -> Result<(), LoginPhase> { + match login { + Ok(true) => Ok(()), + Ok(false) | Err(SoulseekRs::AuthenticationFailed) => { + Err(LoginPhase::Rejected( + "Login rejected: wrong password, or the username is taken" + .to_string(), + )) + } + Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))), + } +} + +fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) { + let area = centered(frame.area(), 52, 12); + frame.render_widget(Clear, area); + + let block = pane_block(true).title(Line::from(vec![ + Span::styled(" soulseek-rs", primary_style()), + Span::styled(" login ", title_style(true)), + ])); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Length(1), // username + Constraint::Length(1), // password + Constraint::Length(1), // spacer + Constraint::Length(2), // status / error + Constraint::Length(1), // spacer + Constraint::Length(2), // hints + ]) + .split(inner); + + let field = |label: &str, value: String, focused: bool| { + let marker = if focused { "› " } else { " " }; + let style = if focused { + info_style().add_modifier(Modifier::BOLD) + } else { + dimmed_style() + }; + Paragraph::new(Line::from(vec![ + Span::styled(format!("{marker}{label:<10}"), style), + Span::styled(value, primary_style()), + Span::styled( + if focused { GLYPH_CURSOR } else { "" }, + accent_style(), + ), + ])) + }; + + let editing = form.phase == LoginPhase::Editing; + frame.render_widget( + field( + "Username:", + form.username.clone(), + editing && form.focused == LoginField::Username, + ), + rows[0], + ); + frame.render_widget( + field( + "Password:", + mask(&form.password), + editing && form.focused == LoginField::Password, + ), + rows[1], + ); + + let status = match &form.phase { + LoginPhase::Editing => Paragraph::new(""), + LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( + format!("{spinner} Connecting…"), + warning_style(), + ))), + LoginPhase::Failed(message) | LoginPhase::Rejected(message) => { + Paragraph::new(Line::from(Span::styled( + message.clone(), + error_style(), + ))) + .wrap(ratatui::widgets::Wrap { trim: true }) + } + }; + frame.render_widget(status, rows[3]); + + let keys = match form.phase { + LoginPhase::Connecting => { + "Enter/r: restart · Esc: stop · q/Ctrl-C: quit" + } + LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit", + LoginPhase::Editing | LoginPhase::Rejected(_) => { + "Tab: switch · Enter: log in · Esc: quit" + } + }; + frame.render_widget( + Paragraph::new(format!( + "New usernames are registered automatically.\n{keys}" + )) + .style(dimmed_style()), + rows[5], + ); +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let [area] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([Constraint::Length(height)]) + .flex(Flex::Center) + .areas(area); + area +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_str(form: &mut LoginForm, text: &str) { + for c in text.chars() { + form.handle_key(key(KeyCode::Char(c))); + } + } + + fn connecting() -> LoginForm { + let mut form = LoginForm::new(Some("alice".into())); + type_str(&mut form, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + form + } + + #[test] + fn prefilled_username_focuses_password() { + let form = LoginForm::new(Some("alice".into())); + assert_eq!(form.username, "alice"); + assert_eq!(form.focused, LoginField::Password); + } + + #[test] + fn empty_form_focuses_username() { + let form = LoginForm::new(None); + assert_eq!(form.focused, LoginField::Username); + } + + #[test] + fn typing_fills_the_focused_field_and_tab_switches() { + let mut form = LoginForm::new(None); + type_str(&mut form, "bob"); + assert_eq!(form.username, "bob"); + form.handle_key(key(KeyCode::Tab)); + assert_eq!(form.focused, LoginField::Password); + type_str(&mut form, "hunter2"); + assert_eq!(form.password, "hunter2"); + form.handle_key(key(KeyCode::Tab)); + assert_eq!(form.focused, LoginField::Username); + } + + #[test] + fn backspace_deletes_from_focused_field() { + let mut form = LoginForm::new(None); + type_str(&mut form, "bob"); + form.handle_key(key(KeyCode::Backspace)); + assert_eq!(form.username, "bo"); + } + + #[test] + fn enter_on_username_moves_to_password() { + let mut form = LoginForm::new(None); + type_str(&mut form, "bob"); + let action = form.handle_key(key(KeyCode::Enter)); + assert_eq!(action, LoginAction::None); + assert_eq!(form.focused, LoginField::Password); + } + + #[test] + fn enter_submits_when_both_fields_filled() { + let mut form = LoginForm::new(Some("alice".into())); + type_str(&mut form, "hunter2"); + let action = form.handle_key(key(KeyCode::Enter)); + assert_eq!(action, LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + } + + #[test] + fn enter_with_empty_password_does_not_submit() { + let mut form = LoginForm::new(Some("alice".into())); + let action = form.handle_key(key(KeyCode::Enter)); + assert_eq!(action, LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + } + + #[test] + fn escape_cancels() { + let mut form = LoginForm::new(None); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel); + } + + #[test] + fn ctrl_c_quits_from_every_phase_without_typing() { + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + for phase in [ + LoginPhase::Editing, + LoginPhase::Connecting, + LoginPhase::Failed("unreachable".into()), + LoginPhase::Rejected("INVALIDPASS".into()), + ] { + let mut form = LoginForm::new(None); + form.phase = phase.clone(); + assert_eq!( + form.handle_key(ctrl_c), + LoginAction::Cancel, + "{phase:?}" + ); + assert_eq!(form.username, "", "{phase:?}"); + } + } + + #[test] + fn typing_is_ignored_while_connecting() { + let mut form = connecting(); + let action = form.handle_key(key(KeyCode::Char('x'))); + assert_eq!(action, LoginAction::None); + assert_eq!(form.password, "pw"); + assert_eq!(form.phase, LoginPhase::Connecting); + } + + #[test] + fn escape_while_connecting_stops_and_keeps_the_credentials() { + let mut form = connecting(); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + } + + #[test] + fn enter_or_r_while_connecting_restarts() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn q_while_connecting_quits() { + let mut form = connecting(); + assert_eq!( + form.handle_key(key(KeyCode::Char('q'))), + LoginAction::Cancel + ); + } + + #[test] + fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn other_key_after_a_failed_connection_edits_with_the_password_kept() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + } + + #[test] + fn escape_after_a_failed_connection_quits() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel); + } + + #[test] + fn key_after_rejection_returns_to_editing_and_clears_password() { + let mut form = connecting(); + form.phase = LoginPhase::Rejected("INVALIDPASS".into()); + let action = form.handle_key(key(KeyCode::Char('x'))); + assert_eq!(action, LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, ""); + } + + #[test] + fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() { + assert_eq!(verdict(Ok(true)), Ok(())); + for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] { + assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_)))); + } + for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)] + { + assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_)))); + } + } + + struct Held(&'static str, std::sync::mpsc::Sender); + + impl Drop for Held { + fn drop(&mut self) { + let _ = self.1.send(format!("released {}", self.0)); + } + } + + fn cancel_handle() -> CancelHandle { + Client::new("attempt", "pw").cancel_handle() + } + + fn next(seen: &Receiver) -> String { + seen.recv_timeout(Duration::from_secs(5)) + .expect("an attempt event") + } + + #[test] + fn a_restart_builds_nothing_until_the_attempt_it_replaces_has_let_go() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + std::thread::sleep(Duration::from_millis(100)); + Ok(Held("first", first_events)) + }); + assert_eq!(next(&seen), "started first"); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn a_finished_attempt_is_let_go_before_its_replacement_starts() { + let (events, seen) = channel(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + Ok(Held("first", first_events)) + }); + let deadline = Instant::now() + Duration::from_secs(5); + while !first.thread.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(first.thread.is_finished()); + + let _second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + + assert_eq!(next(&seen), "released first"); + assert_eq!(next(&seen), "started second"); + } + + #[test] + fn an_attempt_superseded_before_it_began_never_runs() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let first_events = events.clone(); + let first = Attempt::spawn(None, cancel_handle(), move || { + let _ = first_events.send("started first".to_string()); + let _ = gate.recv(); + Err::(LoginPhase::Failed("first".into())) + }); + assert_eq!(next(&seen), "started first"); + + let second_events = events.clone(); + let second = Attempt::spawn(Some(first), cancel_handle(), move || { + let _ = second_events.send("started second".to_string()); + Err(LoginPhase::Failed("second".into())) + }); + let _third = Attempt::spawn(Some(second), cancel_handle(), move || { + let _ = events.send("started third".to_string()); + Err(LoginPhase::Failed("third".into())) + }); + release.send(()).expect("release the first attempt"); + + assert_eq!(next(&seen), "started third"); + } + + #[test] + fn a_result_that_lands_after_a_stop_is_let_go_on_the_attempts_thread() { + let (events, seen) = channel(); + let (release, gate) = channel::<()>(); + let attempt = Attempt::spawn(None, cancel_handle(), move || { + let _ = events.send("started late".to_string()); + let _ = gate.recv(); + Ok(Held("late", events)) + }); + assert_eq!(next(&seen), "started late"); + + attempt.cancel.cancel(); + release.send(()).expect("release the attempt"); + + assert_eq!(next(&seen), "released late"); + } +} diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs new file mode 100644 index 0000000..ee0086c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -0,0 +1,574 @@ +use super::MainTui; +use super::input::{FilterEdit, edit_filter, jumped, list_jump}; +use crate::models::BrowseStatus; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::{ + collections::HashSet, + sync::mpsc::{self, Receiver, TryRecvError}, + thread, + time::{Duration, Instant}, +}; + +/// How long to wait for a browse response before showing a timeout notice. +const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); + +/// One full peer listing can satisfy several containing-folder requests. +pub(super) struct FolderFetch { + username: String, + folders: Vec, + pub(super) requested_at: Instant, + request: Option>>, +} + +/// Soulseek paths use remote separators, independently of the local OS. +pub(super) fn containing_folder(path: &str) -> Option<&str> { + let (parent, name) = path.rsplit_once('\\')?; + if parent.trim_matches('\\').is_empty() + || name.is_empty() + || path.contains('/') + || path.chars().any(char::is_control) + || path + .trim_start_matches('\\') + .split('\\') + .any(|part| matches!(part, "" | "." | "..")) + { + return None; + } + Some(parent) +} + +impl MainTui { + pub(super) fn download_containing_folder(&mut self) { + let Some(file) = self.highlighted_result() else { + return; + }; + let Some(folder) = containing_folder(&file.filename) else { + self.folder_download_status = + Some("Cannot download folder: invalid remote file path".into()); + return; + }; + let username = file.username.clone(); + let folder = folder.to_string(); + if username.trim().is_empty() { + self.folder_download_status = + Some("Cannot download folder: missing peer username".into()); + return; + } + self.folder_download_status = + Some(format!("Fetching folder {folder} from {username}…")); + if let Some(fetch) = self + .folder_fetches + .iter_mut() + .find(|fetch| fetch.username == username) + { + if !fetch.folders.contains(&folder) { + fetch.folders.push(folder); + } + return; + } + + let (sender, request) = mpsc::channel(); + let client = self.client.clone(); + let peer = username.clone(); + // SessionApi's browse works for both local and daemon sessions. The + // request itself can involve IPC, so even that runs off the UI thread. + if self.state.browse.tabs.iter().any(|tab| { + !tab.own + && tab.username == username + && tab.status == BrowseStatus::Loading + }) { + let _ = sender.send(Ok(())); + } else { + thread::spawn(move || { + let _ = sender + .send(client.browse_user(&peer).map_err(|e| e.to_string())); + }); + } + self.folder_fetches.push(FolderFetch { + username, + folders: vec![folder], + requested_at: Instant::now(), + request: Some(request), + }); + } + + 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; + } + // 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; + } + + // Tab management: switch between browsed users or close the active tab. + match key.code { + KeyCode::Tab => { + self.state.browse.next_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::BackTab => { + self.state.browse.prev_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::Char('w') => { + if !self.state.browse.close_active() { + self.state.show_browse = false; + } + 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() { + let _ = self.client.browse_user(&username); + } + 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; + } + _ => {} + } + + // 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; + }; + if browse.status != BrowseStatus::Loaded { + return; + } + let rows = browse.rows(); + if rows.is_empty() { + return; + } + let sel = browse.selected_row.min(rows.len() - 1); + (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, len, jump); + } + self.sync_browse_selection(); + return; + } + + // Downloads need `&self.client` free of the browse borrow. + match key.code { + KeyCode::Enter if !row.is_folder => { + self.queue_browse_files(vec![( + row.path.clone(), + row.size.unwrap_or(0), + )]); + return; + } + KeyCode::Char('d') => { + let files = if row.is_folder { + 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))] + }; + self.queue_browse_files(files); + return; + } + _ => {} + } + + // Navigation and expand/collapse mutate the browse state. + if let Some(browse) = self.state.browse.active_tab_mut() { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + browse.selected_row = sel.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + 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.set_expanded(&row.path, true); + } else if row.is_folder { + browse.selected_row = (sel + 1).min(len - 1); + } + } + KeyCode::Left | KeyCode::Char('h') => { + if row.is_folder && row.expanded { + 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). + browse.set_expanded(&row.path, !row.expanded); + } + _ => {} + } + } + + 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); + } + + /// 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.download_target().map(str::to_string) + else { + return; + }; + if files.is_empty() { + return; + } + self.queue_peer_files(username, files, self.download_dir.clone()); + } + + /// Shared browse queue: keep remote paths intact and let the session's + /// download machinery choose local names and enforce transfer limits. + fn queue_peer_files( + &mut self, + username: String, + files: Vec<(String, u64)>, + download_dir: String, + ) -> Receiver { + let sender = self.downloads_sender(); + let client = self.client.clone(); + let (status_sender, status_receiver) = mpsc::channel(); + thread::spawn(move || { + let mut queued = 0; + let mut failed = 0; + let mut last_error = String::new(); + for (path, size) in files { + match client.download( + path.clone(), + username.clone(), + size, + download_dir.clone(), + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + queued += 1; + } + Err(e) => { + failed += 1; + last_error = e.to_string(); + soulseek_rs::warn!( + "Failed to queue download {path}: {e}" + ); + } + } + } + let status = if failed == 0 { + format!("Queued {queued} files from {username}") + } else { + format!( + "Queued {queued} files from {username}; {failed} failed: {last_error}" + ) + }; + let _ = status_sender.send(status); + }); + status_receiver + } + + /// Request a user's shared files and open (or focus) their browse tab. + pub(super) fn start_browse(&mut self, username: String) { + let username = username.trim().to_string(); + if username.is_empty() { + return; + } + // Open/focus the tab; only (re)issue the request when it's new or a + // previous attempt timed out. + if self.state.browse.open(&username) + && !self.folder_fetches.iter().any(|f| f.username == username) + { + let _ = self.client.browse_user(&username); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// Open (or focus) the view of what this session shares, filled from the + /// index the network is served from. + pub(super) fn show_own_shares(&mut self) { + if self.state.browse.open_own(&self.client.username()) { + self.load_own_shares(); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// Put a fresh copy of the share index into the own tab, if it is open. + /// A re-index or an added folder would otherwise leave it showing what + /// the network saw a scan ago. + pub(super) fn load_own_shares(&mut self) { + // The early return matters: without an own tab there is nothing to + // fill, and against a daemon asking anyway is a whole index over the + // socket for nobody. + let Some(idx) = self.state.browse.own_index() else { + return; + }; + let listing = self.client.shared_listing(); + self.state.browse.tabs[idx].load(&listing); + self.sync_browse_selection(); + } + + /// The username of the highlighted search result (filter-aware). + pub(super) fn highlighted_result_owner(&self) -> Option { + self.highlighted_result().map(|f| f.username.clone()) + } + + /// Drain browse responses into any loading tabs, or time them out. + pub(super) fn poll_browse_result(&mut self) { + self.poll_folder_requests(); + // Which loading tabs are waiting, and for whom. + let loading: Vec<(usize, String, std::time::Instant)> = self + .state + .browse + .tabs + .iter() + .enumerate() + .filter(|(_, b)| b.status == BrowseStatus::Loading) + .map(|(i, b)| (i, b.username.clone(), b.requested_at)) + .collect(); + + // take_browse_result is destructive. Consume once per peer, then + // deliver the listing to both browse tabs and folder downloads. + let users: HashSet = loading + .iter() + .map(|(_, user, _)| user.clone()) + .chain(self.folder_fetches.iter().map(|f| f.username.clone())) + .collect(); + for username in users { + if let Some(directories) = self.client.take_browse_result(&username) + { + for (idx, _, _) in + loading.iter().filter(|(_, user, _)| *user == username) + { + let browse = &mut self.state.browse.tabs[*idx]; + browse.load(&directories); + if *idx == self.state.browse.active { + self.state.browse_table_state.select(Some(0)); + } + } + if let Some(index) = self + .folder_fetches + .iter() + .position(|f| f.username == username) + { + let fetch = self.folder_fetches.remove(index); + for folder in fetch.folders { + self.queue_containing_folder( + &username, + &folder, + &directories, + ); + } + } + } + } + for (idx, _, requested_at) in loading { + if requested_at.elapsed() > BROWSE_TIMEOUT + && let Some(browse) = self.state.browse.tabs.get_mut(idx) + && browse.status == BrowseStatus::Loading + { + browse.status = BrowseStatus::TimedOut; + } + } + } + + fn queue_containing_folder( + &mut self, + username: &str, + folder: &str, + directories: &[soulseek_rs::SharedDirectory], + ) { + let files = match files_in_folder(directories, folder) { + Ok(files) if !files.is_empty() => files, + Ok(_) => { + self.folder_download_status = + Some(format!("Folder {folder} from {username} is empty")); + return; + } + Err(error) => { + self.folder_download_status = Some(format!( + "Cannot download {folder} from {username}: {error}" + )); + return; + } + }; + self.folder_download_status = Some(format!( + "Queuing {} files from {username}: {folder}…", + files.len() + )); + let directory = crate::directories::download_dir_for( + &self.download_dir, + username, + self.use_username_dirs, + ); + let result = + self.queue_peer_files(username.to_string(), files, directory); + self.folder_queue_results.push(result); + } + + fn poll_folder_requests(&mut self) { + self.folder_queue_results + .retain(|result| match result.try_recv() { + Ok(status) => { + self.folder_download_status = Some(status); + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + self.folder_download_status = + Some("Folder download worker stopped".into()); + false + } + }); + self.folder_fetches.retain_mut(|fetch| { + if let Some(request) = &fetch.request { + let error = match request.try_recv() { + Ok(Ok(())) => { + fetch.request = None; + None + } + Ok(Err(error)) => Some(error), + Err(TryRecvError::Disconnected) => { + Some("request worker stopped".into()) + } + Err(TryRecvError::Empty) => None, + }; + if let Some(error) = error { + self.folder_download_status = Some(format!( + "Could not fetch folder from {}: {error}", + fetch.username + )); + return false; + } + } + if fetch.requested_at.elapsed() > BROWSE_TIMEOUT { + self.folder_download_status = Some(format!( + "Folder request to {} timed out; press D to retry", + fetch.username + )); + return false; + } + true + }); + } +} + +/// Only direct children of an exact directory match, never descendants or +/// prefix matches. Reject malformed entries rather than queue a partial folder. +fn files_in_folder( + directories: &[soulseek_rs::SharedDirectory], + folder: &str, +) -> Result, &'static str> { + let mut found = false; + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for dir in directories + .iter() + .filter(|dir| dir.name.trim_end_matches('\\') == folder) + { + found = true; + for file in &dir.files { + if file.name.is_empty() + || file.name.contains(['\\', '/']) + || file.name.chars().any(char::is_control) + || matches!(file.name.as_str(), "." | "..") + { + return Err("invalid file name in peer listing"); + } + let path = format!("{folder}\\{}", file.name); + if seen.insert(path.clone()) { + files.push((path, file.size)); + } + } + } + if !found { + return Err("folder not found in peer listing"); + } + Ok(files) +} diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs new file mode 100644 index 0000000..b71dfc8 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -0,0 +1,383 @@ +use super::MainTui; +use crate::directories::download_dir_for; +use crate::models::{DownloadEntry, FileDisplayData}; +use crate::ui::panes::{InfoSubject, selected_transfer}; +use soulseek_rs::types::UploadStatus; +use soulseek_rs::{DownloadStatus, types::Download}; +use std::{sync::mpsc, thread}; + +impl MainTui { + /// Channel for queued downloads, created on first use. + pub(super) fn downloads_sender( + &mut self, + ) -> mpsc::Sender<(Download, mpsc::Receiver)> { + if let Some(sender) = &self.state.downloads_sender_channel { + return sender.clone(); + } + let (sender, receiver) = mpsc::channel(); + self.state.downloads_receiver_channel = Some(receiver); + self.state.downloads_sender_channel = Some(sender.clone()); + sender + } + + /// Re-queue the selected download if it failed or timed out. + pub(super) fn retry_selected_download(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(entry) = self.state.downloads.get(index) else { + return; + }; + if !matches!( + entry.download.status, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut + ) { + return; + } + let filename = entry.download.filename.clone(); + let username = entry.download.username.clone(); + let size = entry.download.size; + let directory = entry.download.download_directory.clone(); + + let sender = self.downloads_sender(); + let client = self.client.clone(); + + // Drop the old failed entry from both the UI list and the client's + // download store; otherwise the stale entry (same md5-derived token and + // filename) shadows the retry and its completion is misrouted, leaving + // the retried download stuck showing "Queued". + self.state.downloads.remove(index); + self.select_download_after_removal(index); + let _ = self.client.remove_download(&username, &filename); + + thread::spawn(move || { + match client.download(filename.clone(), username, size, directory) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => soulseek_rs::warn!("Failed to retry {filename}: {e}"), + } + }); + } + + /// Remove all completed / failed / timed-out downloads from the list. + pub(super) fn clear_finished_downloads(&mut self) { + // Forget them in the session too. The list is a view of what the + // session holds, so dropping them only here would last until the next + // poll put them straight back. + for entry in &self.state.downloads { + if entry.download.is_finished() { + self.client.remove_download( + &entry.download.username, + &entry.download.filename, + ); + } + } + self.state + .downloads + .retain(|entry| !entry.download.is_finished()); + let len = self.state.downloads.len(); + if len == 0 { + self.state.downloads_table_state.select(None); + } else { + let selected = self + .state + .downloads_table_state + .selected() + .unwrap_or(0) + .min(len - 1); + self.state.downloads_table_state.select(Some(selected)); + } + } + + /// 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; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + match download.status { + DownloadStatus::InProgress { .. } => { + let _ = self + .client + .pause_download(&download.username, &download.filename); + } + DownloadStatus::Paused { .. } => { + let _ = self + .client + .resume_download(&download.username, &download.filename); + } + _ => {} + } + } + + /// Drop the selected transfer: a queued one leaves the queue, a finished + /// one leaves the list. + pub(super) fn remove_selected_download(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + let dismissed = match download.status { + DownloadStatus::Queued => self + .client + .remove_queued_download(&download.username, &download.filename), + // A finished transfer may exist only in this window (restored from + // an earlier run), so the session's "nothing to forget" is not a + // reason to keep the row. + DownloadStatus::Completed | DownloadStatus::Cancelled => { + self.client + .remove_download(&download.username, &download.filename); + true + } + _ => false, + }; + if !dismissed { + return; + } + + self.state.downloads.remove(index); + self.select_download_after_removal(index); + } + + fn select_download_after_removal(&mut self, removed_index: usize) { + if self.state.downloads.is_empty() { + self.state.downloads_table_state.select(None); + return; + } + + let next_index = removed_index.min(self.state.downloads.len() - 1); + self.state.downloads_table_state.select(Some(next_index)); + } + + pub(super) fn queue_selected_downloads(&mut self) { + let selected_files: Vec = self + .state + .results_selected_indices + .iter() + .filter_map(|&idx| self.state.results_items.get(idx)) + .cloned() + .collect(); + + if selected_files.is_empty() { + return; + } + + let sender = self.downloads_sender(); + let client = self.client.clone(); + let download_dir = self.download_dir.clone(); + let use_username_dirs = self.use_username_dirs; + + thread::spawn(move || { + for file in selected_files { + let metadata = soulseek_rs::types::DownloadMetadata { + bitrate: file.bitrate, + length_seconds: file.length_seconds, + peer_upload_speed: Some(file.speed), + peer_free_slots: Some(file.slots), + }; + match client.download_with_metadata( + file.filename.clone(), + file.username.clone(), + file.size, + download_dir_for( + &download_dir, + &file.username, + use_username_dirs, + ), + metadata, + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => { + soulseek_rs::warn!( + "Failed to start download for {}: {}", + file.filename, + e + ); + } + } + } + }); + + // Clear selection + self.state.results_selected_indices.clear(); + } + + /// The user behind the highlighted transfer, download or upload. + pub(super) fn selected_transfer_user(&self) -> Option { + match selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) { + Some(InfoSubject::Download(entry)) => { + Some(entry.download.username.clone()) + } + Some(InfoSubject::Upload(upload)) => Some(upload.username.clone()), + Some(InfoSubject::Result(_)) | None => None, + } + } + + pub(super) fn cancel_selected_transfer(&self) { + let selected = selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ); + match selected { + Some(InfoSubject::Download(entry)) + if !entry.download.is_finished() => + { + let download = &entry.download; + if !self + .client + .cancel_download(&download.username, &download.filename) + { + soulseek_rs::warn!( + "No download of {} from {} to cancel", + download.filename, + download.username + ); + } + } + Some(InfoSubject::Upload(upload)) + if upload.status == UploadStatus::InProgress => + { + let cancelled = self + .client + .cancel_upload(&upload.username, &upload.filename); + if !cancelled { + soulseek_rs::warn!( + "No in-progress upload of {} to {} to cancel", + upload.filename, + upload.username + ); + } + } + _ => {} + } + } + + pub(super) fn update_downloads(&mut self) { + if let Some(ref receiver) = self.state.downloads_receiver_channel { + while let Ok((download, download_receiver)) = receiver.try_recv() { + // A transfer of ours can already be listed: the session + // registers it the moment it is queued, so a frame between + // that and the worker handing it over here will have picked it + // up from the session. Take over that row rather than adding a + // second one for the same file. + let existing = self.state.downloads.iter_mut().find(|entry| { + entry.download.username == download.username + && entry.download.filename == download.filename + }); + match existing { + Some(entry) => { + entry.download = download; + entry.receiver = Some(download_receiver); + } + None => self.state.downloads.push(DownloadEntry { + download, + receiver: Some(download_receiver), + }), + } + } + } + + self.sync_downloads_with_session(); + + self.state.active_downloads_count = 0; + for download_entry in &mut self.state.downloads { + if let Some(ref receiver) = download_entry.receiver { + while let Ok(status) = receiver.try_recv() { + download_entry.download.status = status; + } + } + + if matches!( + download_entry.download.status, + DownloadStatus::InProgress { .. } + ) { + self.state.active_downloads_count += 1; + } + } + } + + /// Make the list show the session's transfers, not just this window's. + /// + /// The queue belongs to the session, so against a daemon it includes what + /// another client queued and what was still running before this window + /// opened. Transfers this window started keep their own status channel, + /// which reports sooner than a poll; everything else is refreshed from the + /// session each tick. + fn sync_downloads_with_session(&mut self) { + // Only worth doing when the session is shared. A window with a + // session of its own started every transfer in it, so there is + // nothing to learn — and the list also holds completed transfers + // restored from disk that the session has never heard of, which this + // reconciliation would take to mean "cancelled elsewhere" and delete. + if self.client.daemon_endpoint().is_none() { + return; + } + + let known = self.client.get_all_downloads(); + + for download in &known { + let existing = self.state.downloads.iter_mut().find(|entry| { + entry.download.username == download.username + && entry.download.filename == download.filename + }); + match existing { + // Ours: the channel is more current than this snapshot. + Some(entry) if entry.receiver.is_some() => {} + Some(entry) => entry.download.status = download.status.clone(), + None => self.state.downloads.push(DownloadEntry { + download: download.clone(), + receiver: None, + }), + } + } + + // Anything the session has forgotten is gone for every window. + self.state.downloads.retain(|entry| { + entry.receiver.is_some() + || known.iter().any(|download| { + download.username == entry.download.username + && download.filename == entry.download.filename + }) + }); + } +} diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs new file mode 100644 index 0000000..bcdc69e --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -0,0 +1,838 @@ +use super::MainTui; +use super::render::{MIN_PANE, shrink}; +use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout}; +use crate::ui::page_of; +use crate::ui::panes::{ + InfoSubject, name_end_offset, query_end_offset, selected_transfer, + transfer_name_end_offset, upload_display_name, +}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::layout::Position; +use ratatui::widgets::TableState; + +const NAME_SCROLL_STEP: isize = 8; + +impl MainTui { + /// 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); + } + + // The keys list closes on the key that opened it, or the usual two, + // and scrolls like any log for a terminal too short to show it all. + if self.state.show_help { + let view = &mut self.state.help_view; + match key.code { + KeyCode::Char('?' | 'q') | KeyCode::Esc => { + self.state.show_help = false; + } + KeyCode::Down | KeyCode::Char('j') => view.scroll(1), + KeyCode::Up | KeyCode::Char('k') => view.scroll(-1), + _ => { + scroll_log(view, key); + } + } + return; + } + + // Chat popup takes over navigation while open. + if self.state.show_messages { + return self.handle_chat_input(key); + } + + // An open popup owns the keyboard, so these come before pane routing. + if self.state.show_browse { + return self.handle_browse_input(key); + } + if self.state.show_rooms { + return self.handle_rooms_input(key); + } + if self.state.settings.is_some() { + return self.handle_settings_input(key); + } + + // Control combinations only ever page a list, so they never reach + // the letter keys below: ctrl-d is not d. + if key.modifiers.contains(KeyModifiers::CONTROL) { + self.navigate_focused_list(key); + return; + } + + // Filter mode in Results pane + if self.state.results_is_filtering + && self.state.focused_pane == FocusedPane::Results + { + return self.handle_filter_input(key); + } + + // Global shortcuts + match key.code { + KeyCode::Char('q') => { + self.state.exit = Some(crate::models::TuiExit::Quit); + return; + } + KeyCode::Char('?') => { + self.state.show_help = true; + self.state.help_view.to_oldest(); + return; + } + // A pane's number is its place in the legend order. + KeyCode::Char(digit @ '1'..='3') => { + let index = usize::from(digit as u8 - b'1'); + self.state.focus_pane(FocusedPane::ALL[index]); + return; + } + KeyCode::Tab => { + self.state.cycle_focus(true); + return; + } + KeyCode::BackTab => { + self.state.cycle_focus(false); + return; + } + KeyCode::Char('z') => { + self.state.toggle_zoom(); + return; + } + KeyCode::Char('w') => { + 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; + } + KeyCode::Char('s') => { + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Search; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + return; + } + KeyCode::Char('m') => { + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Message; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + return; + } + KeyCode::Char('i') => { + self.state.show_messages = true; + self.state.unread_messages = 0; + return; + } + // Chat rooms. In the Downloads pane `c` clears finished downloads, + // so only open chat from the other panes (like `b` is contextual). + KeyCode::Char('c') + if self.state.focused_pane != FocusedPane::Downloads => + { + self.start_rooms(); + return; + } + KeyCode::Char('o') => { + 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. + if self.state.focused_pane == FocusedPane::Results + && 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; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + return; + } + _ => {} + } + + // Pane-specific shortcuts + match self.state.focused_pane { + FocusedPane::Searches => self.handle_searches_input(key), + FocusedPane::Results => self.handle_results_input(key), + FocusedPane::Downloads => self.handle_downloads_input(key), + } + } + + /// Chat popup: composing captures typing, otherwise these are navigation + /// keys over the conversation list. + fn handle_chat_input(&mut self, key: KeyEvent) { + if self.state.chat_composing { + match key.code { + KeyCode::Enter => self.send_chat_message(), + KeyCode::Esc => { + self.state.chat_composing = false; + self.state.chat_input.clear(); + } + KeyCode::Backspace => { + self.state.chat_input.pop(); + } + KeyCode::Char(c) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT, + ) => + { + self.state.chat_input.push(c); + } + _ => {} + } + 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; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } + + 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); + } + KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => { + self.state.cycle_chat_peer(false); + } + KeyCode::Char('m') => { + self.state.show_messages = false; + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Message; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Enter if self.state.active_chat_peer().is_some() => { + self.state.chat_composing = true; + } + _ => {} + } + } + + fn handle_command_bar_input(&mut self, key: KeyEvent) { + self.state.command_bar_cursor_position = self + .state + .command_bar_input + .floor_char_boundary(self.state.command_bar_cursor_position); + + match key.code { + KeyCode::Enter => { + let input = self.state.command_bar_input.trim().to_string(); + if !input.is_empty() { + match self.state.command_bar_mode { + CommandBarMode::Search => self.start_search(input), + CommandBarMode::Message => { + self.send_message_from_input(&input); + } + CommandBarMode::Browse => self.start_browse(input), + } + } + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Esc => { + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Backspace => { + let cursor_position = self.state.command_bar_cursor_position; + if cursor_position > 0 { + let previous_position = self + .state + .command_bar_input + .floor_char_boundary(cursor_position.saturating_sub(1)); + self.state + .command_bar_input + .drain(previous_position..cursor_position); + self.state.command_bar_cursor_position = previous_position; + } + } + KeyCode::Delete => { + let cursor_position = self.state.command_bar_cursor_position; + if cursor_position < self.state.command_bar_input.len() { + let next_position = self + .state + .command_bar_input + .ceil_char_boundary(cursor_position + 1); + self.state + .command_bar_input + .drain(cursor_position..next_position); + } + } + KeyCode::Left => { + let previous = + self.state.command_bar_cursor_position.saturating_sub(1); + self.state.command_bar_cursor_position = + self.state.command_bar_input.floor_char_boundary(previous); + } + KeyCode::Right => { + let input = &self.state.command_bar_input; + let next = self.state.command_bar_cursor_position + 1; + self.state.command_bar_cursor_position = + input.ceil_char_boundary(next.min(input.len())); + } + KeyCode::Home => { + self.state.command_bar_cursor_position = 0; + } + KeyCode::End => { + self.state.command_bar_cursor_position = + self.state.command_bar_input.len(); + } + KeyCode::Char('a') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_cursor_position = 0; + } + KeyCode::Char('e') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_cursor_position = + self.state.command_bar_input.len(); + } + KeyCode::Char('u') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Char(c) + if !key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) => + { + let cursor_position = self.state.command_bar_cursor_position; + self.state.command_bar_input.insert(cursor_position, c); + self.state.command_bar_cursor_position = + cursor_position + c.len_utf8(); + } + _ => {} + } + } + + fn handle_filter_input(&mut self, key: KeyEvent) { + 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; + } + } + + /// The list the focus is on and how far one page moves it. + fn navigate_focused_list(&mut self, key: KeyEvent) -> bool { + let focused = self.state.focused_pane; + let page = self.page_size(); + let (table, len) = match focused { + FocusedPane::Searches => ( + &mut self.state.searches_table_state, + self.state.searches.len(), + ), + FocusedPane::Results => { + let len = self.visible_results().len(); + (&mut self.state.results_table_state, len) + } + FocusedPane::Downloads => ( + &mut self.state.downloads_table_state, + self.state.downloads.len() + self.state.uploads.len(), + ), + }; + navigate_list(key, table, len, page) + } + + /// 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 + /// header. + fn page_size(&self) -> usize { + page_of(self.state.pane_area(self.state.focused_pane), 1) + } + + fn handle_searches_input(&mut self, key: KeyEvent) { + 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) = selected { + self.state.selected_search_index = Some(selected); + if let Some(search) = self.state.searches.get(selected) { + self.state.results_items = search.results.clone(); + self.state.results_filtered_items = + search.results.clone(); + self.state.results_filtered_indices = + (0..search.results.len()).collect(); + self.state.results_selected_indices.clear(); + self.state.results_name_offset = 0; + self.state.results_table_state.select(Some(0)); + self.state.focus_pane(FocusedPane::Results); + } + } + } + KeyCode::Char('d') => { + 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(); + } + _ => {} + } + } + + fn handle_results_input(&mut self, key: KeyEvent) { + if self.navigate_focused_list(key) || self.scroll_focused_name(key) { + return; + } + match key.code { + KeyCode::Char(' ') => { + let index = self + .state + .results_table_state + .selected() + .and_then(|row| self.original_index(row)); + if let Some(index) = index + && !self.state.results_selected_indices.remove(&index) + { + self.state.results_selected_indices.insert(index); + } + } + KeyCode::Char('/') => self.state.results_is_filtering = true, + KeyCode::Char('a') => { + let indices: Vec = + if self.state.results_filter_query.is_empty() { + (0..self.state.results_items.len()).collect() + } else { + self.state.results_filtered_indices.clone() + }; + self.state.results_selected_indices.extend(indices); + } + KeyCode::Char('A') => { + self.state.results_selected_indices.clear(); + } + KeyCode::Enter => { + self.queue_selected_downloads(); + } + KeyCode::Char('D') => self.download_containing_folder(), + _ => {} + } + } + + /// The keys that scroll the focused list's long column sideways, the + /// same in every list: a file name in Results and Downloads, the query + /// in Searches. Says whether `key` was one of them. + fn scroll_focused_name(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Right | KeyCode::Char('l') => { + self.scroll_names(NAME_SCROLL_STEP); + } + KeyCode::Left | KeyCode::Char('h') => { + self.scroll_names(-NAME_SCROLL_STEP); + } + KeyCode::Char('0') => { + *self.state.name_offset_mut(self.state.focused_pane) = 0; + } + KeyCode::Char('$') => { + let end = self.highlighted_name_end(); + *self.state.name_offset_mut(self.state.focused_pane) = end; + } + _ => return false, + } + true + } + + fn scroll_names(&mut self, delta: isize) { + let end = self.highlighted_name_end(); + let offset = self.state.name_offset_mut(self.state.focused_pane); + let current = (*offset).min(end); + *offset = current.saturating_add_signed(delta).min(end); + } + + /// How far the highlighted row's long column can scroll before its end + /// is in view. Zero when nothing is highlighted or the pane is not drawn. + fn highlighted_name_end(&self) -> usize { + let pane = self.state.focused_pane; + let Some(area) = self.state.pane_area(pane) else { + return 0; + }; + match pane { + FocusedPane::Searches => self + .state + .searches_table_state + .selected() + .and_then(|row| self.state.searches.get(row)) + .map_or(0, |search| query_end_offset(&search.query, area)), + FocusedPane::Results => self + .highlighted_result() + .map_or(0, |file| name_end_offset(&file.filename, area)), + FocusedPane::Downloads => { + let shown = match selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) { + Some(InfoSubject::Download(entry)) => { + entry.download.filename.as_str() + } + Some(InfoSubject::Upload(upload)) => { + upload_display_name(upload) + } + Some(InfoSubject::Result(_)) | None => return 0, + }; + transfer_name_end_offset(shown, area) + } + } + } + + fn handle_downloads_input(&mut self, key: KeyEvent) { + // The pane lists downloads first, then uploads; navigation spans both. + if self.navigate_focused_list(key) || self.scroll_focused_name(key) { + return; + } + match key.code { + KeyCode::Char('x') => { + self.cancel_selected_transfer(); + } + KeyCode::Char('p') => { + self.toggle_selected_download_pause(); + } + KeyCode::Char('d') => { + self.remove_selected_download(); + } + KeyCode::Char('r') => { + self.retry_selected_download(); + } + KeyCode::Char('c') => { + self.clear_finished_downloads(); + } + KeyCode::Char('C') => { + self.clear_all_transfers(); + } + _ => {} + } + } + + pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + let at = Position::new(mouse.column, mouse.row); + // A divider wins over the panes it separates; grabbing one + // starts a drag, and the click alone already resizes. + if let Some(drag) = self.grabbed_divider(at) { + self.resize_drag = Some(drag); + self.apply_resize(drag, at); + return; + } + // The areas are from the last draw. A pane hidden since then, in the + // same batch of events, still has one, and is not there to click. + let hit = FocusedPane::ALL.into_iter().find(|pane| { + self.state.layout.is_visible(*pane) + && self + .state + .pane_area(*pane) + .is_some_and(|area| area.contains(at)) + }); + if let Some(pane) = hit { + self.state.focused_pane = pane; + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(drag) = self.resize_drag { + self.apply_resize( + drag, + Position::new(mouse.column, mouse.row), + ); + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.resize_drag = None; + } + _ => {} + } + } + + /// Which dragged divider, if any, the pointer is on. Nothing is grabbable + /// while a popup or a zoom covers the dividers. + fn grabbed_divider(&self, at: Position) -> Option { + if self.state.layout.zoomed + || self.state.show_help + || self.state.show_messages + || self.state.show_browse + || self.state.show_rooms + || self.state.settings.is_some() + { + return None; + } + if self + .state + .hsplit_divider + .is_some_and(|rect| rect.contains(at)) + { + return Some(ResizeDrag::Rows); + } + self.state + .vsplit_dividers + .iter() + .find(|(_, rect)| rect.contains(at)) + .map(|(pane, _)| ResizeDrag::Columns(*pane)) + } + + /// Move the divider under the pointer to where the pointer is, keeping + /// every pane at least [`MIN_PANE`] cells. + fn apply_resize(&mut self, drag: ResizeDrag, at: Position) { + match drag { + ResizeDrag::Rows => { + let Some(area) = self.state.content_area else { + return; + }; + // The dragged row is the pane's last row, so the height is + // everything from the top of the content down to it. + let height = at.y.saturating_sub(area.y).saturating_add(1); + self.state.layout.top_height = + Some(shrink(height, area.height)); + } + ResizeDrag::Columns(pane) => { + let Some(row) = self.state.row_area else { + return; + }; + let left = if pane == FocusedPane::Downloads + && self.state.layout.is_visible(FocusedPane::Searches) + { + // Downloads starts where Searches ends. + self.state + .searches_pane_area + .map_or(row.x, |area| area.x.saturating_add(area.width)) + } else { + row.x + }; + let width = at.x.saturating_sub(left).saturating_add(1); + // Info shares the row, so the drag may not crowd it (and any + // other visible pane) out of `MIN_PANE` cells. + let others = MIN_PANE + * u16::from( + self.state.layout.is_visible(FocusedPane::Searches) + && pane == FocusedPane::Downloads, + ) + + MIN_PANE; + let max = row.width.saturating_sub(others).max(MIN_PANE); + let width = width.max(MIN_PANE).min(max); + match pane { + FocusedPane::Searches => { + self.state.layout.searches_width = Some(width); + } + FocusedPane::Downloads => { + self.state.layout.downloads_width = Some(width); + } + FocusedPane::Results => {} + } + } + } + } +} + +/// What a divider does while the left button is held on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResizeDrag { + /// The border between Results and the row underneath: sets that row's height. + Rows, + /// The seam at the right of the named row pane: gives it that width and + /// takes it from the panes to its right. + Columns(FocusedPane), +} + +/// What a key did to a filter being typed. +#[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 { + 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 +/// (negative is up). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ListJump { + First, + Last, + Rows(isize), +} + +/// The keys every list and log answers alike for moving further than a row: +/// the two ends, a page, half a page. `page` is how many rows the list shows. +pub(super) fn list_jump(key: KeyEvent, page: usize) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let page = isize::try_from(page).unwrap_or(isize::MAX); + let half = (page / 2).max(1); + Some(match key.code { + KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First, + KeyCode::End | KeyCode::Char('G') if !ctrl => ListJump::Last, + KeyCode::PageDown => ListJump::Rows(page), + KeyCode::PageUp => ListJump::Rows(-page), + KeyCode::Char('f') if ctrl => ListJump::Rows(page), + KeyCode::Char('b') if ctrl => ListJump::Rows(-page), + KeyCode::Char('d') if ctrl => ListJump::Rows(half), + KeyCode::Char('u') if ctrl => ListJump::Rows(-half), + _ => return None, + }) +} + +/// Apply the jump keys to a log, moving by the screenful it last showed. +/// Says whether `key` was one of them. +pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool { + match list_jump(key, view.page()) { + Some(ListJump::First) => view.to_oldest(), + Some(ListJump::Last) => view.to_newest(), + Some(ListJump::Rows(rows)) => view.scroll(rows), + None => return false, + } + true +} + +/// `selected` moved by `jump` over a list `len` long, stopping at the ends. +pub(super) fn jumped(selected: usize, len: usize, jump: ListJump) -> usize { + let last = len.saturating_sub(1); + match jump { + ListJump::First => 0, + ListJump::Last => last, + ListJump::Rows(rows) => selected.saturating_add_signed(rows).min(last), + } +} + +/// The keys every list answers alike: a row at a time wrapping at either +/// end, and the jumps of [`list_jump`]. Says whether `key` was one of them. +fn navigate_list( + key: KeyEvent, + table: &mut TableState, + len: usize, + page: usize, +) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false), + KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true), + _ => { + let Some(jump) = list_jump(key, page) else { + return false; + }; + if len > 0 { + let current = table.selected().unwrap_or(0); + table.select(Some(jumped(current, len, jump))); + } + } + } + true +} + +/// Move a table's selection one row, wrapping at either end. +fn cycle(table: &mut TableState, len: usize, forward: bool) { + if len == 0 { + return; + } + let current = table.selected().unwrap_or(0); + let next = if forward { + (current + 1) % len + } else { + (current + len - 1) % len + }; + table.select(Some(next)); +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs new file mode 100644 index 0000000..52d226b --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -0,0 +1,288 @@ +mod browse; +mod downloads; +mod input; +mod render; +mod rooms; +mod search; +mod settings; + +use crate::api::SessionApi; +use crate::models::{AppState, TuiExit}; +use crate::persist::{ + snapshot::{Snapshot, restore_messages, restore_searches}, + state::{PersistedMessage, StateStore}, +}; +use color_eyre::Result; +use ratatui::{ + DefaultTerminal, + crossterm::event::{self, Event, KeyEventKind, poll}, +}; +use std::{sync::Arc, time::Duration}; + +pub struct MainTui { + client: Arc, + state: AppState, + download_dir: String, + use_username_dirs: bool, + search_timeout: Duration, + spinner_state: usize, + store: Option, + /// Where settings changes are persisted — the config file this run + /// resolved, so `--config` is honoured. + config_path: Option, + /// Last snapshot written to disk, to skip no-op saves. + saved_snapshot: Snapshot, + /// Which divider the left mouse button is holding, if any. + resize_drag: Option, + listener_fallback: Option<(u16, u16)>, + folder_fetches: Vec, + folder_queue_results: Vec>, + folder_download_status: Option, +} + +impl MainTui { + pub fn new( + client: Arc, + download_dir: String, + use_username_dirs: bool, + search_timeout: Duration, + store: Option, + config_path: Option, + ) -> Self { + let mut tui = Self { + client, + state: AppState::new(), + download_dir, + search_timeout, + use_username_dirs, + spinner_state: 0, + store, + config_path, + saved_snapshot: Snapshot::default(), + resize_drag: None, + listener_fallback: None, + folder_fetches: Vec::new(), + folder_queue_results: Vec::new(), + folder_download_status: None, + }; + tui.restore_persisted_state(); + tui + } + + /// Bring back last session's state: search history, chat rooms + /// (rejoined on the server), and downloads — incomplete ones are + /// re-enqueued so they resume automatically. + fn restore_persisted_state(&mut self) { + // Whatever the session has been collecting comes first: attached to a + // daemon this is the conversation so far, including everything that + // arrived while nothing was showing it. Locally it is empty and the + // snapshot below is the whole record. + let history: Vec = self + .client + .message_history() + .into_iter() + .map(|message| PersistedMessage { + peer: message.peer, + outgoing: message.outgoing, + text: message.text, + at: chrono::DateTime::from_timestamp(message.at, 0) + .unwrap_or_default() + .into(), + }) + .collect(); + restore_messages(&mut self.state, &history); + + let Some(store) = &self.store else { + self.saved_snapshot = Snapshot::capture(&self.state); + return; + }; + + restore_searches(&mut self.state, &store.load_search_queries()); + restore_messages(&mut self.state, &store.load_messages()); + // Restore the unread badge so messages left unread carry over. Set + // before the baseline snapshot below so it isn't seen as a change. + self.state.unread_messages = store.load_unread(); + + for room in store.load_rooms() { + if self.state.rooms.focus_or_open(&room) + && let Err(e) = self.client.join_room(&room) + { + soulseek_rs::warn!("Could not rejoin {room}: {e}"); + } + } + + let downloads = store.load_downloads(); + self.saved_snapshot = Snapshot::capture(&self.state); + // Completed entries are shown as-is; the rest re-enqueue below and + // reappear through the normal downloads channel. + self.saved_snapshot.downloads.clone_from(&downloads); + + let sender = self.downloads_sender(); + for entry in downloads { + if entry.completed { + self.state.downloads.push(crate::models::DownloadEntry { + download: soulseek_rs::types::Download { + username: entry.username, + filename: entry.filename, + token: 0, + size: entry.size, + download_directory: entry.download_directory, + status: soulseek_rs::DownloadStatus::Completed, + sender: std::sync::mpsc::channel().0, + queue_position: None, + metadata: soulseek_rs::types::DownloadMetadata::default( + ), + }, + receiver: None, + }); + } else { + let client = self.client.clone(); + let sender = sender.clone(); + std::thread::spawn(move || { + match client.download( + entry.filename.clone(), + entry.username, + entry.size, + entry.download_directory, + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => soulseek_rs::warn!( + "Could not resume {}: {e}", + entry.filename + ), + } + }); + } + } + } + + /// Write state to disk when it differs from what was last saved. + fn save_persisted_state(&mut self) { + let Some(store) = &self.store else { return }; + let snapshot = Snapshot::capture(&self.state); + if snapshot == self.saved_snapshot { + return; + } + if snapshot.downloads != self.saved_snapshot.downloads + && let Err(e) = store.save_downloads(&snapshot.downloads) + { + soulseek_rs::warn!("Could not save downloads state: {e}"); + } + if snapshot.queries != self.saved_snapshot.queries + && let Err(e) = store.save_search_queries(&snapshot.queries) + { + soulseek_rs::warn!("Could not save search history: {e}"); + } + if snapshot.rooms != self.saved_snapshot.rooms + && let Err(e) = store.save_rooms(&snapshot.rooms) + { + soulseek_rs::warn!("Could not save room state: {e}"); + } + if snapshot.messages != self.saved_snapshot.messages + && let Err(e) = store.save_messages(&snapshot.messages) + { + soulseek_rs::warn!("Could not save message history: {e}"); + } + if snapshot.unread_messages != self.saved_snapshot.unread_messages + && let Err(e) = store.save_unread(snapshot.unread_messages) + { + soulseek_rs::warn!("Could not save unread count: {e}"); + } + self.saved_snapshot = snapshot; + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result { + use ratatui::crossterm::{event::DisableMouseCapture, execute}; + + // Run the event loop, then restore the terminal unconditionally: if the + // loop returns early with an error the terminal must still be taken out + // of raw mode / the alternate screen and mouse capture disabled, or the + // user is left with a corrupted terminal. + let result = self.run_event_loop(&mut terminal); + self.save_persisted_state(); + + let _ = execute!(std::io::stdout(), DisableMouseCapture); + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + + result.map(|()| self.state.exit.unwrap_or(TuiExit::Quit)) + } + + /// What the window holds, for a test to read back after driving it. + #[must_use] + pub const fn state(&self) -> &AppState { + &self.state + } + + /// One frame's worth of catching up with the session: everything the + /// event loop does between drawing and reading the keyboard. + pub fn poll_session(&mut self) { + self.update_search_results(); + self.update_downloads(); + self.poll_private_messages(); + self.poll_browse_result(); + self.poll_room_events(); + self.state.uploads = self.client.uploads(); + self.spinner_state = (self.spinner_state + 1) % 10; + self.save_persisted_state(); + } + + fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { + while self.state.exit.is_none() { + terminal.draw(|frame| self.render(frame))?; + + self.poll_session(); + + // Drain every queued input event before the next draw: key + // autorepeat outpaces the frame time, and handling one event per + // frame makes the backlog keep scrolling for seconds after the + // key is released. + if poll(Duration::from_millis(100))? { + loop { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key_event(key); + } + Event::Mouse(mouse) => { + self.handle_mouse_event(mouse); + } + _ => {} + } + if self.state.exit.is_some() || !poll(Duration::ZERO)? { + break; + } + } + } + } + + Ok(()) + } +} + +#[expect(clippy::too_many_arguments, reason = "one call site per mode")] +pub fn launch_main_tui( + terminal: DefaultTerminal, + client: Arc, + download_dir: String, + use_username_dirs: bool, + search_timeout: Duration, + store: Option, + config_path: Option, + listener_fallback: Option<(u16, u16)>, +) -> Result { + let mut tui = MainTui::new( + client, + download_dir, + use_username_dirs, + search_timeout, + store, + config_path, + ); + tui.listener_fallback = listener_fallback; + tui.run(terminal) +} + +#[cfg(test)] +mod tests; diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs new file mode 100644 index 0000000..66bfb2c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -0,0 +1,520 @@ +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.listener_fallback, + ); + + self.render_content(frame, main_chunks[1]); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3], shortcuts); + } else { + self.render_shortcuts(frame, main_chunks[2], shortcuts); + } + + self.render_overlays(frame); + } + + /// Results across the top, where a long file name has the whole width; + /// Searches, Downloads and Info in a row underneath. A hidden pane drops + /// out of its row and the rest widen; a zoomed one has the area alone. + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + // Only what is drawn can be clicked. + self.state.searches_pane_area = None; + self.state.results_pane_area = None; + self.state.downloads_pane_area = None; + self.state.info_pane_area = None; + self.state.hsplit_divider = None; + self.state.vsplit_dividers.clear(); + self.state.row_area = None; + + if self.state.layout.zoomed { + self.render_pane(frame, area, self.state.focused_pane); + return; + } + self.state.content_area = Some(area); + + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] + .into_iter() + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) + .collect(); + + let row_area = if self.state.layout.is_visible(FocusedPane::Results) { + // With only Info left in the row, the results deserve more of + // the height. + let row_weight = if row_panes.is_empty() { 1 } else { 2 }; + let top = match self.state.layout.top_height { + Some(rows) => Constraint::Length(shrink(rows, area.height)), + None => Constraint::Fill(3), + }; + let [top, bottom] = + Layout::vertical([top, Constraint::Fill(row_weight)]) + .areas(area); + // The two touching border rows between the halves; either can + // be dragged. + self.state.hsplit_divider = Some(Rect::new( + area.x, + top.y.saturating_add(top.height).saturating_sub(1), + area.width, + 2, + )); + self.render_pane(frame, top, FocusedPane::Results); + bottom + } else { + area + }; + self.state.row_area = Some(row_area); + + let mut constraints: Vec = row_panes + .iter() + .map(|(pane, weight)| { + let dragged = match *pane { + FocusedPane::Searches => self.state.layout.searches_width, + FocusedPane::Downloads => self.state.layout.downloads_width, + FocusedPane::Results => None, + }; + match dragged { + Some(cols) => { + Constraint::Length(shrink(cols, row_area.width)) + } + None => Constraint::Fill(*weight), + } + }) + .collect(); + constraints.push(Constraint::Fill(6)); // Info + let chunks = Layout::horizontal(constraints).split(row_area); + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + // The seam at this pane's right edge: its border column and the + // next pane's, both draggable. + self.state.vsplit_dividers.push(( + *pane, + Rect::new( + chunk.x.saturating_add(chunk.width).saturating_sub(1), + row_area.y, + 2, + row_area.height, + ), + )); + self.render_pane(frame, *chunk, *pane); + } + self.state.info_pane_area = Some(chunks[row_panes.len()]); + self.render_info_pane(frame, chunks[row_panes.len()]); + } + + fn render_pane( + &mut self, + frame: &mut Frame, + area: Rect, + pane: FocusedPane, + ) { + let focused = self.state.focused_pane == pane; + match pane { + FocusedPane::Searches => { + self.state.searches_pane_area = Some(area); + render_searches_pane( + frame, + area, + &self.state.searches, + &mut self.state.searches_table_state, + focused, + self.state.searches_query_offset, + ); + } + FocusedPane::Results => { + self.state.results_pane_area = Some(area); + self.render_results_pane(frame, area, focused); + } + FocusedPane::Downloads => { + self.state.downloads_pane_area = Some(area); + render_downloads_pane( + frame, + area, + &self.state.downloads, + &self.state.uploads, + &mut self.state.downloads_table_state, + focused, + self.state.downloads_name_offset, + ); + } + } + } + + fn render_results_pane( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + ) { + // When a filter is active the rendered rows are a subset, so the pane + // also needs the mapping back to unfiltered indices to render the + // selection checkboxes correctly. + let (results_items, results_original_indices) = + if self.state.results_filter_query.is_empty() { + (&self.state.results_items, None) + } else { + ( + &self.state.results_filtered_items, + Some(self.state.results_filtered_indices.as_slice()), + ) + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + area, + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + original_indices: results_original_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused, + active_search_query, + name_offset: self.state.results_name_offset, + }, + ); + } + + /// The Info pane follows the focus: the highlighted result, or the + /// highlighted transfer. + fn render_info_pane(&self, frame: &mut Frame, area: Rect) { + let selected = if self.state.focused_pane == FocusedPane::Results { + self.highlighted_result().map(InfoSubject::Result) + } else { + selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) + }; + render_download_info_pane( + frame, + area, + selected, + matches!( + self.state.focused_pane, + FocusedPane::Results | FocusedPane::Downloads + ), + ); + } + fn render_overlays(&mut self, frame: &mut Frame) { + // Messages inbox overlays everything when open. + if self.state.show_messages { + self.render_messages_popup(frame); + } + + // Browse tree overlays everything when open. + if self.state.show_browse && !self.state.browse.is_empty() { + let area = centered_rect(80, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + &self.state.browse, + &mut self.state.browse_table_state, + self.spinner_state, + &self.state.downloads, + ); + } + + // Settings popup overlays everything when open. + if self.state.settings.is_some() { + self.render_settings_popup(frame); + } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &mut self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } + + // The keys list only opens from the main view, so it is on top of + // nothing; drawn last so that stays true if that ever changes. + if self.state.show_help { + self.render_help_popup(frame); + } + } + + fn render_settings_popup(&self, frame: &mut Frame) { + use crate::models::{SettingsMode, SettingsRow}; + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let selected = settings.selected_row(); + let mut lines: Vec = Vec::new(); + let entry = |on: bool, label: &str, value: String| { + Line::from(vec![ + Span::styled(if on { "> " } else { " " }, accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + ]) + }; + let typing = |label: &str, value: String| { + Line::from(vec![ + Span::styled("> ", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), + ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change password…", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log out…", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(entry( + selected == SettingsRow::UseUsernameDirs, + "Use username directory ", + if settings.use_username_dirs { + "[x]" + } else { + "[ ]" + } + .to_string(), + )); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) + } else { + entry( + selected == SettingsRow::DownloadDir, + "Download folder: ", + settings.download_dir.clone(), + ) + }); + lines.push(Line::styled( + format!("Shared folders ({}):", settings.share_dirs.len()), + dimmed_style(), + )); + for (i, dir) in settings.share_dirs.iter().enumerate() { + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); + } + if settings.share_dirs.is_empty() { + lines.push(Line::styled( + " (nothing shared — press 'a' to add a folder)", + dimmed_style(), + )); + } + if settings.mode == SettingsMode::AddingShare { + lines.push(typing(" Add share: ", settings.input.clone())); + } + if let Some(status) = &settings.status { + lines.push(Line::from("")); + lines.push(Line::styled(status.clone(), warning_style())); + } + + let block = pane_block(true).title(plain_title("Settings", true)); + frame.render_widget( + ratatui::widgets::Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + } + + fn render_messages_popup(&mut self, frame: &mut Frame) { + // The per-conversation chat box supersedes the old flat message list; + // it renders the messages and the compose line itself. + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + self.state.popup_area = Some(area); + let own = self.client.username(); + render_chat_pane(frame, area, &mut self.state, &own); + } + fn render_command_bar(&self, frame: &mut Frame, area: Rect) { + let prefix = match self.state.command_bar_mode { + CommandBarMode::Search => "search: ", + CommandBarMode::Message => "message (to: recipient text): ", + CommandBarMode::Browse => "browse user: ", + }; + let block = pane_block(true); + // Derive from the block's inner rect so borders and padding stay in + // one place; the cursor follows whatever the pane reserves. + let inner = block.inner(area); + let prefix_width = prefix.chars().count() as u16; + let input_width = inner.width.saturating_sub(prefix_width); + let (visible_input, cursor_column) = visible_input_at_cursor( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + input_width, + ); + let command_line = Line::from(vec![ + Span::styled(prefix, accent_style()), + Span::styled(visible_input, primary_style()), + ]); + + frame.render_widget(Paragraph::new(command_line).block(block), area); + + if inner.width > 0 && inner.height > 0 { + let cursor_x = inner + .x + .saturating_add(prefix_width) + .saturating_add(cursor_column) + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position(Position::new(cursor_x, inner.y)); + } + } +} + +/// A dragged pane size kept inside the space the terminal now offers: never +/// below [`MIN_PANE`], and never so big that what shares the split with it +/// drops below `MIN_PANE` too. +pub(super) fn shrink(dragged: u16, total: u16) -> u16 { + let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); + dragged.max(MIN_PANE).min(max) +} + +/// The smallest pane a drag can leave anyone: a border and a row or column +/// of content each side. +pub(super) const MIN_PANE: u16 = 3; +/// A `Rect` centered within `area`, sized to the given percentages. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} +fn visible_input_at_cursor( + input: &str, + cursor_position: usize, + width: u16, +) -> (String, u16) { + if width == 0 { + return (String::new(), 0); + } + + let cursor_position = input.floor_char_boundary(cursor_position); + let cursor_character_index = input[..cursor_position].chars().count(); + let max_cursor_column = usize::from(width.saturating_sub(1)); + let start_character_index = + cursor_character_index.saturating_sub(max_cursor_column); + + let visible_input = input + .chars() + .skip(start_character_index) + .take(usize::from(width)) + .collect(); + let cursor_column = cursor_character_index + .saturating_sub(start_character_index) + .min(max_cursor_column); + + (visible_input, cursor_column as u16) +} diff --git a/soulseek-rs/src/ui/main_tui/render/shortcuts.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs new file mode 100644 index 0000000..90ff0bb --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -0,0 +1,620 @@ +//! The keys the window advertises: the shortcut bar along the foot, the `?` +//! overlay behind it, and the unread badge that rides in the bar. +//! +//! A child module of `render`, so it still reaches the window's state and +//! `render`'s own helpers directly; only what `render` calls back into is +//! widened to `pub(super)`. + +use super::{ + Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line, + MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span, + accent_style, body_style, dimmed_style, info_style, pack_shortcuts, + pane_block, plain_title, warning_style, +}; + +/// The keys overlay, grouped by where a key applies. Two columns, because a +/// single list runs longer than most terminals are tall. +const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ + ( + "Panes", + &[ + ("Tab / Shift-Tab", "next / previous pane"), + ("1 2 3", "focus a pane, hidden or not"), + ("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"), + ], + ), + ( + "Anywhere", + &[ + ("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"), + ("?", "this list"), + ("q", "quit"), + ], + ), + ( + "Any list", + &[ + ("↑ ↓ / k j", "move one row"), + ("Home End / g G", "first / last row"), + ("PgUp PgDn / ^b ^f", "one page"), + ("^u ^d", "half a page"), + ("h l / ← →", "scroll a long name sideways"), + ("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)])] = &[ + ( + "Searches", + &[ + ("Enter", "show its results"), + ("S", "run the search again"), + ("d", "remove the search"), + ("C", "clear every search"), + ("c", "chat rooms"), + ], + ), + ( + "Results", + &[ + ("Space", "select / deselect"), + ("a / A", "select all / none"), + ("Enter", "download the selection"), + ("D", "download highlighted file's containing folder"), + ("/", "filter, Enter keeps it, Esc clears"), + ("b", "browse the owner"), + ("c", "chat rooms"), + ], + ), + ( + "Downloads / Uploads", + &[ + ("p", "pause / resume"), + ("x", "cancel"), + ("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"), + ], + ), + ( + "Chat and browse popups", + &[ + ("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"), + ("^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)"), + ], + ), +]; + +/// 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")] + } + SettingsMode::NewPassword => { + vec![ + ("Type", "password"), + ("Enter", "repeat it"), + ("Esc", "cancel"), + ] + } + SettingsMode::RepeatPassword(_) => { + vec![ + ("Type", "password"), + ("Enter", "change it"), + ("Esc", "cancel"), + ] + } + SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { + vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")] + } + SettingsMode::Navigate => vec![ + ("↑↓", "move"), + match settings.selected_row() { + SettingsRow::ChangePassword => ("Enter", "change password"), + SettingsRow::Logout => ("Enter", "log out"), + SettingsRow::DownloadDir => ("Enter", "edit folder"), + SettingsRow::UseUsernameDirs => ("Enter", "toggle option"), + SettingsRow::Share(_) => ("d", "remove share"), + }, + ("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. + 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; + let columns = help_columns(screen.width.saturating_sub(frame_width)); + let gaps = HELP_GAP * (columns.len() as u16 - 1); + let text_width = columns.iter().map(|lines| widest(lines)).sum::(); + let width = screen.width.min(text_width + gaps + frame_width); + let tallest = columns.iter().map(Vec::len).max().unwrap_or(0); + let height = screen + .height + .min(u16::try_from(tallest + 2).unwrap_or(u16::MAX)); + let area = Rect::new( + screen.x + (screen.width - width) / 2, + screen.y + (screen.height - height) / 2, + width, + height, + ); + frame.render_widget(ratatui::widgets::Clear, area); + + let block = pane_block(true).title(plain_title("Keys", true)); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Both columns scroll together, and no further than the taller one + // needs. + let window = self + .state + .help_view + .window(tallest, usize::from(inner.height)); + let scroll = u16::try_from(window.start).unwrap_or(u16::MAX); + + let areas = + Layout::horizontal(vec![Constraint::Fill(1); columns.len()]) + .spacing(HELP_GAP) + .split(inner); + for (area, lines) in areas.iter().zip(columns) { + frame.render_widget( + Paragraph::new(lines).scroll((scroll, 0)), + *area, + ); + } + } + + /// Context shortcuts for the chat-rooms popup. + fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { + if self.state.rooms.composing { + return vec![ + ("Type", "message"), + ("Enter", "send"), + ("Esc", "cancel"), + ]; + } + match self.state.rooms.view { + RoomsView::List => { + if self.state.rooms.list_is_filtering { + vec![ + ("Type", "filter"), + ("Enter", "join match"), + ("Esc", "clear filter"), + ] + } else { + vec![ + ("↑↓", "move"), + ("Enter", "join"), + ("/", "filter"), + ("Tab", "open rooms"), + ("Esc", "close"), + ] + } + } + 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"), + ("l", "rooms"), + ("x", "leave"), + ], + } + } + + /// 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)> { + let mut keys = vec![ + ("Tab/1-3", "pane"), + ( + "z", + if self.state.layout.zoomed { + "unzoom" + } else { + "zoom" + }, + ), + ("w", "hide"), + ]; + // 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 + /// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`]. + fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { + if self.state.show_help { + vec![("?/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![ + ("Type", "message"), + ("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"), + ] + } + } else if self.state.show_rooms { + self.rooms_shortcuts() + } else if self.state.show_browse { + 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![ + ("Type", "search term"), + ("←→", "move cursor"), + ("Backspace/Del", "edit"), + ("Enter", "search"), + ("Esc", "cancel"), + ], + CommandBarMode::Message => vec![ + ("Type", "recipient then message"), + ("Enter", "send"), + ("Esc", "cancel"), + ], + CommandBarMode::Browse => vec![ + ("Type", "username"), + ("Enter", "browse"), + ("Esc", "cancel"), + ], + } + } else if self.state.results_is_filtering + && self.state.focused_pane == FocusedPane::Results + { + // Typing goes to the filter, so no letter keys are on offer here. + 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"), + ("b", "browse user"), + ("h/l", "scroll query"), + ], + FocusedPane::Results => vec![ + ("Space", "select"), + ("Enter", "download"), + ("D", "download containing folder"), + ("b", "browse owner"), + ("c", "chat"), + ("/", "filter"), + ("a/A", "select all/none"), + ("h/l", "scroll name"), + ("g/G", "top/end"), + ], + FocusedPane::Downloads => vec![ + ("p", "pause/resume"), + ("x", "cancel"), + ("r", "retry failed"), + ("d", "delete queued/done"), + ("c", "clear finished"), + ("C", "clear all"), + ("b", "browse user"), + ("h/l", "scroll name"), + ], + }; + keys.extend(self.pane_shortcuts()); + keys + } + } + + /// 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. + pub(super) fn shortcut_rows(&self, width: u16) -> Vec> { + // Borders and padding, and the unread badge's column when it shows. + let badge = self.unread_badge().width() as u16; + let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; + let mut rows = + pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved)); + if let Some(status) = &self.folder_download_status { + rows.push(Line::styled(status.clone(), info_style())); + } + rows + } + + /// A prominent unread badge for the shortcuts bar. It rides in the + /// always-visible bar, so unread mail/chat shows in every pane, not just + /// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright + /// for the first half of the cycle, dim for the second. + fn unread_badge(&self) -> Line<'static> { + unread_indicator( + self.state.unread_messages, + self.state.rooms.total_unread(), + self.spinner_state < 5, + ) + } + + pub(super) fn render_shortcuts( + &self, + frame: &mut Frame, + area: Rect, + rows: Vec>, + ) { + // Surface our own sharing status in the block title. + let shared = self.client.shared_directories(); + let sharing = match shared.as_slice() { + [] => "off".to_string(), + [only] => only.clone(), + more => format!("{} folders", more.len()), + }; + let title = format!("Shortcuts · Sharing: {sharing}"); + // Themed block (master's design system), but we take its inner area so + // the unread badge can be split off to the right below. + let block = pane_block(false).title(plain_title(title, false)); + let inner = block.inner(area); + frame.render_widget(block, area); + + // The unread badge is right-aligned so the context shortcuts on the + // left never crowd it out. + let unread = self.unread_badge(); + if unread.spans.is_empty() { + frame.render_widget(Paragraph::new(rows), inner); + } else { + let badge_width = unread.width() as u16; + let cols = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(badge_width), + ]) + .spacing(1) + .split(inner); + frame.render_widget(Paragraph::new(rows), cols[0]); + frame.render_widget( + Paragraph::new(unread).alignment(Alignment::Right), + cols[1], + ); + } + } +} + +/// Columns between the two halves of the keys list. +const HELP_GAP: u16 = 2; + +/// The widest key in a column, so the descriptions line up. +fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { + sections + .iter() + .flat_map(|(_, keys)| keys.iter()) + .map(|(key, _)| Span::raw(*key).width()) + .max() + .unwrap_or(0) +} + +/// Cells the widest of `lines` takes. +fn widest(lines: &[Line<'_>]) -> u16 { + let cells = lines.iter().map(Line::width).max().unwrap_or(0); + u16::try_from(cells).unwrap_or(u16::MAX) +} + +/// The keys list laid out for `width` cells of text: two columns when both +/// fit beside each other, otherwise the right half under the left. +fn help_columns(width: u16) -> Vec>> { + let left = help_lines(HELP_LEFT); + let right = help_lines(HELP_RIGHT); + if width >= widest(&left) + HELP_GAP + widest(&right) { + return vec![left, right]; + } + let mut stacked = left; + stacked.push(Line::from("")); + stacked.extend(right); + vec![stacked] +} + +fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { + let key_width = key_column_width(sections); + let mut lines = Vec::new(); + for (index, (heading, keys)) in sections.iter().enumerate() { + if index > 0 { + lines.push(Line::from("")); + } + lines.push(Line::styled( + (*heading).to_string(), + accent_style().add_modifier(Modifier::BOLD), + )); + for (key, action) in *keys { + lines.push(Line::from(vec![ + Span::styled(format!("{key: Line<'static> { + let mut counts: Vec = Vec::new(); + if messages > 0 { + counts.push(format!("✉ {messages}")); + } + if rooms > 0 { + counts.push(format!("💬 {rooms}")); + } + if counts.is_empty() { + return Line::from(Vec::new()); + } + let style = if bright { + warning_style().add_modifier(Modifier::BOLD) + } else { + dimmed_style() + }; + Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style)) +} + +#[cfg(test)] +mod tests { + use super::unread_indicator; + + fn text(messages: usize, rooms: usize) -> String { + unread_indicator(messages, rooms, true) + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect() + } + + #[test] + fn unread_indicator_is_empty_when_nothing_is_unread() { + // Empty in both blink phases — no badge means no badge. + assert!(unread_indicator(0, 0, true).spans.is_empty()); + assert!(unread_indicator(0, 0, false).spans.is_empty()); + } + + #[test] + fn unread_indicator_shows_each_source_and_both_together() { + // Bracketed so the badge reads as one distinct block. + assert_eq!(text(3, 0), "⟨ ✉ 3 ⟩"); + assert_eq!(text(0, 2), "⟨ 💬 2 ⟩"); + // Both present: DMs first, rooms second, separated so they don't run + // together. + assert_eq!(text(3, 2), "⟨ ✉ 3 💬 2 ⟩"); + } + + #[test] + fn blink_phases_share_text_but_differ_in_style() { + // The self-drawn blink must keep the same glyphs (so the bar never + // reflows) while changing style, or it would not read as blinking. + let bright = unread_indicator(1, 0, true); + let dim = unread_indicator(1, 0, false); + assert_eq!(bright.spans[0].content, dim.spans[0].content); + assert_ne!( + bright.spans[0].style, dim.spans[0].style, + "bright and dim phases must look different" + ); + } +} diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs new file mode 100644 index 0000000..a5cc811 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -0,0 +1,294 @@ +use super::MainTui; +use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log}; +use crate::models::{ChatFilter, RoomsView}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +impl MainTui { + /// Open the chat-rooms popup and refresh the room list. If rooms are + /// already open, jump straight to the chat view; otherwise show the list. + pub(super) fn start_rooms(&mut self) { + let _ = self.client.request_room_list(); + self.state.show_rooms = true; + if self.state.rooms.open.is_empty() { + self.state.rooms.view = RoomsView::List; + } else { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + } + + pub(super) fn handle_rooms_input(&mut self, key: KeyEvent) { + // Composing a message captures typing. + if self.state.rooms.composing { + self.handle_room_compose_input(key); + return; + } + // Filtering the room list captures typing. + if self.state.rooms.view == RoomsView::List + && self.state.rooms.list_is_filtering + { + self.handle_room_filter_input(key); + return; + } + // So does typing a filter over the log or the member list; the + // other keys still move around. + if self.state.rooms.view == RoomsView::Chat + && self.state.rooms.filtering.is_some() + && self.handle_chat_filter_input(key) + { + return; + } + + match self.state.rooms.view { + RoomsView::List => self.handle_rooms_list_input(key), + RoomsView::Chat => self.handle_rooms_chat_input(key), + } + } + + fn handle_rooms_list_input(&mut self, key: KeyEvent) { + let len = self.state.rooms.filtered_rooms().len(); + if let Some(jump) = list_jump(key, self.popup_page(1)) { + self.state.rooms.list_selected = + jumped(self.state.rooms.list_selected, len, jump); + return; + } + // Control combinations are only ever jumps; the letters below are + // not theirs to trigger. + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } + match key.code { + // Esc peels back one level: clear a lingering filter first (as the + // title's "Esc: clear" promises), otherwise close the popup. + KeyCode::Esc if !self.state.rooms.list_filter.is_empty() => { + self.state.rooms.list_filter.clear(); + self.state.rooms.list_selected = 0; + } + KeyCode::Esc | KeyCode::Char('q') => { + self.state.show_rooms = false; + } + KeyCode::Char('/') => { + self.state.rooms.list_is_filtering = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.list_selected = + self.state.rooms.list_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if len > 0 => { + self.state.rooms.list_selected = + (self.state.rooms.list_selected + 1).min(len - 1); + } + KeyCode::Enter => self.join_selected_room(), + KeyCode::Tab if !self.state.rooms.open.is_empty() => { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + _ => {} + } + } + + fn handle_room_filter_input(&mut self, key: KeyEvent) { + let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key) + else { + return self.handle_rooms_list_input(key); + }; + match edit { + FilterEdit::Changed | FilterEdit::Cleared => { + self.state.rooms.list_selected = 0; + } + FilterEdit::Kept => self.join_selected_room(), + } + if edit != FilterEdit::Changed { + self.state.rooms.list_is_filtering = false; + } + } + + fn handle_rooms_chat_input(&mut self, key: KeyEvent) { + // Paging keys move through the room's history; the member list is a + // row at a time. + if let Some(view) = self.state.rooms.active_view_mut() + && scroll_log(view, key) + { + return; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } + let filtered = !self.state.rooms.log_filter.is_empty() + || !self.state.rooms.user_filter.is_empty(); + match key.code { + KeyCode::Char('q') => self.state.show_rooms = false, + // Esc peels back one level: the filters first, then the list. + KeyCode::Esc if filtered => { + self.state.rooms.set_log_filter(String::new()); + self.state.rooms.set_user_filter(String::new()); + } + KeyCode::Esc | KeyCode::Char('l') => { + self.state.rooms.view = RoomsView::List; + } + KeyCode::Char('/') => { + self.state.rooms.filtering = Some(ChatFilter::Log); + } + KeyCode::Char('u') => { + self.state.rooms.filtering = Some(ChatFilter::Users); + } + KeyCode::Tab => self.state.rooms.next_tab(), + KeyCode::BackTab => self.state.rooms.prev_tab(), + KeyCode::Char('x') => self.leave_active_room(), + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.select_user_up(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.rooms.select_user_down(); + } + // Act on the highlighted member of the room. + KeyCode::Char('b') => self.browse_selected_room_user(), + KeyCode::Char('m') => self.message_selected_room_user(), + KeyCode::Enter if self.state.rooms.active_room().is_some() => { + self.state.rooms.composing = true; + } + _ => {} + } + } + + /// The keys that edit whichever filter is being typed, the log's or the + /// member list's. Says whether `key` was one of them. + fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool { + let rooms = &mut self.state.rooms; + let over_log = rooms.filtering == Some(ChatFilter::Log); + let mut filter = if over_log { + rooms.log_filter.clone() + } else { + rooms.user_filter.clone() + }; + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + if edit != FilterEdit::Changed { + rooms.filtering = None; + } + // Keeping a filter leaves the view where it is. + if edit != FilterEdit::Kept { + if over_log { + rooms.set_log_filter(filter); + } else { + rooms.set_user_filter(filter); + } + } + true + } + + /// Browse the shares of the member highlighted in the active room. Closes + /// the rooms popup so the browse tree is the sole overlay. + fn browse_selected_room_user(&mut self) { + if let Some(user) = self.state.rooms.selected_user() { + self.state.show_rooms = false; + self.start_browse(user); + } + } + + /// Compose a private message to the member highlighted in the active room. + /// Closes the rooms popup and opens the chat box on that conversation. + fn message_selected_room_user(&mut self) { + if let Some(user) = self.state.rooms.selected_user() { + self.state.show_rooms = false; + self.state.open_chat(user); + } + } + + fn handle_room_compose_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => self.send_room_message(), + KeyCode::Esc => { + self.state.rooms.composing = false; + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.clear(); + } + } + KeyCode::Backspace => { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.pop(); + } + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.push(c); + } + } + _ => {} + } + } + + /// Join (or focus) the room highlighted in the list view. + fn join_selected_room(&mut self) { + if let Some(name) = self.state.rooms.selected_room_name() { + let newly_opened = self.state.rooms.focus_or_open(&name); + if newly_opened && let Err(e) = self.client.join_room(&name) { + soulseek_rs::warn!("Failed to join {name}: {e}"); + } + } + } + + /// Leave the active room and close its tab. + fn leave_active_room(&mut self) { + if let Some(name) = self.state.rooms.close_active() + && let Err(e) = self.client.leave_room(&name) + { + soulseek_rs::warn!("Failed to leave {name}: {e}"); + } + } + + /// Send the active room's compose buffer. The server echoes the message + /// back as a RoomEvent, which is what actually renders it in the log. + fn send_room_message(&mut self) { + let active = self.state.rooms.active; + let (room, text) = match self.state.rooms.open.get(active) { + Some(room) if !room.input.trim().is_empty() => { + (room.name.clone(), room.input.trim().to_string()) + } + _ => { + self.state.rooms.composing = false; + return; + } + }; + if let Err(e) = self.client.say_in_room(&room, &text) { + soulseek_rs::warn!("Failed to say in {room}: {e}"); + } + if let Some(room) = self.state.rooms.open.get_mut(active) { + room.input.clear(); + } + self.state.rooms.composing = false; + } + + /// The room whose newest messages are on screen, if any: the active one + /// in the chat view, unless the reader is held back in its history, when + /// what arrives still counts as unread. + pub(super) fn viewed_room(&self) -> Option { + if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat { + return None; + } + self.state + .rooms + .active_room() + .filter(|room| room.view.following()) + .map(|room| room.name.clone()) + } + + /// Drain chat-room events into the rooms state, tracking unread badges. + pub(super) fn poll_room_events(&mut self) { + let viewing = self.viewed_room(); + for event in self.client.take_room_events() { + self.state.rooms.apply_event(event, viewing.as_deref()); + } + } +} diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs new file mode 100644 index 0000000..e2892cf --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -0,0 +1,506 @@ +use super::MainTui; +use crate::models::{ + ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, + SearchStatus, +}; +use chrono::Local; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Instant, +}; + +/// How long after a search went out a repeat of `S` counts as the same +/// press: key autorepeat is faster than anyone means to search. +const RERUN_HOLD: std::time::Duration = std::time::Duration::from_secs(1); + +impl MainTui { + pub(super) fn remove_search_at_index(&mut self, index: usize) { + if index >= self.state.searches.len() { + return; + } + + // Cancel the search if it's active + if let Some(search) = self.state.searches.get(index) { + search.cancel_flag.store(true, Ordering::Relaxed); + } + + // Check if we're removing the currently active search + let was_active_search = self.state.selected_search_index == Some(index); + + // Drop it from the session too. The list is a view of what the session + // holds, so removing it only here would last until the next sync put + // it straight back — and against a daemon it would come back for every + // other window as well. + if let Some(search) = self.state.searches.get(index) { + self.client.forget_search(&search.query); + } + self.state.searches.remove(index); + + if let Some(current_idx) = self.state.selected_search_index { + if current_idx == index { + // Removed the active search - clear it + self.state.selected_search_index = None; + } else if current_idx > index { + // Active search was after removed one - decrement index + self.state.selected_search_index = Some(current_idx - 1); + } + // If current_idx < index, no change needed + } + + // If we removed the active search, clear results pane + if was_active_search { + self.clear_results_pane(); + } + + if self.state.searches.is_empty() { + self.state.searches_table_state.select(None); + } else { + let new_selection = if index >= self.state.searches.len() { + self.state.searches.len() - 1 + } else { + index + }; + self.state.searches_table_state.select(Some(new_selection)); + } + } + + pub(super) fn clear_all_searches(&mut self) { + // Cancel all active searches + for search in &self.state.searches { + search.cancel_flag.store(true, Ordering::Relaxed); + } + + // And drop them from the session, or the next sync brings every one + // of them straight back — the same reason removing a single search + // has to say so. + let queries: Vec = self + .state + .searches + .iter() + .map(|search| search.query.clone()) + .collect(); + for query in queries { + self.client.forget_search(&query); + } + + self.state.searches.clear(); + self.state.searches_table_state.select(None); + self.state.selected_search_index = None; + + // Clear results pane + self.clear_results_pane(); + } + + fn clear_results_pane(&mut self) { + self.state.results_items.clear(); + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(None); + self.state.results_filter_query.clear(); + self.state.results_is_filtering = false; + self.state.results_name_offset = 0; + } + + pub(super) fn visible_results(&self) -> &[FileDisplayData] { + if self.state.results_filter_query.is_empty() { + &self.state.results_items + } else { + &self.state.results_filtered_items + } + } + + pub(super) fn highlighted_result(&self) -> Option<&FileDisplayData> { + self.visible_results() + .get(self.state.results_table_state.selected()?) + } + + pub(super) fn original_index(&self, row: usize) -> Option { + if self.state.results_filter_query.is_empty() { + Some(row) + } else { + self.state.results_filtered_indices.get(row).copied() + } + } + + pub(super) fn apply_filter(&mut self) { + let (items, indices) = filter_results( + &self.state.results_items, + &self.state.results_filter_query, + ); + self.state.results_filtered_items = items; + self.state.results_filtered_indices = indices; + if !self.state.results_filtered_items.is_empty() { + self.state.results_table_state.select(Some(0)); + } + } + + /// Drain any private messages received since the last tick into the inbox. + pub(super) fn poll_private_messages(&mut self) { + for msg in self.client.take_private_messages() { + self.state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: msg.username().to_string(), + text: msg.message().to_string(), + at: Local::now(), + }); + // Badge the inbox when it isn't currently open. + if !self.state.show_messages { + self.state.unread_messages += 1; + } + } + } + + /// Parse a ` ` compose line and send it. + pub(super) fn send_message_from_input(&mut self, input: &str) { + let Some((recipient, text)) = input.split_once(char::is_whitespace) + else { + return; + }; + let recipient = recipient.trim(); + let text = text.trim(); + if recipient.is_empty() || text.is_empty() { + return; + } + + match self.client.send_private_message(recipient, text) { + Ok(()) => { + self.state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: recipient.to_string(), + text: text.to_string(), + at: Local::now(), + }); + // Land in the chat box on the conversation just started. + self.state.open_chat(recipient.to_string()); + self.state.chat_composing = false; + } + Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), + } + } + + /// Send the chat popup's compose buffer to the active conversation, + /// staying in compose mode so a reply can follow immediately. + pub(super) fn send_chat_message(&mut self) { + let Some(peer) = self.state.active_chat_peer().map(ToString::to_string) + else { + return; + }; + let text = self.state.chat_input.trim().to_string(); + if text.is_empty() { + return; + } + + match self.client.send_private_message(&peer, &text) { + Ok(()) => self.state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer, + text, + at: Local::now(), + }), + Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), + } + self.state.chat_input.clear(); + } + + pub(super) fn start_search(&mut self, query: String) { + let entry = self.launch_search(query); + self.state.searches.push(entry); + self.show_search(self.state.searches.len() - 1); + } + + /// Run the search at `index` again, in its row: a restored query comes + /// back with nothing under it, and this saves typing it out. A run still + /// collecting is stopped first, or two threads would fill one set. + pub(super) fn rerun_search_at_index(&mut self, index: usize) { + let Some(previous) = self.state.searches.get(index) else { + return; + }; + // A held key repeats, and every repeat would put another search on + // the wire: a run that only just went out is left to run. + if previous.status == SearchStatus::Active + && previous.start_time.elapsed() < RERUN_HOLD + { + return; + } + previous.cancel_flag.store(true, Ordering::Relaxed); + self.state.searches[index] = self.launch_search(previous.query.clone()); + self.show_search(index); + } + + /// Put `query` on the wire and hand back the entry that tracks it. The + /// session keys results by query, so a repeat starts its set afresh, and + /// the entry's empty results match that. + fn launch_search(&self, query: String) -> SearchEntry { + let cancel_flag = Arc::new(AtomicBool::new(false)); + let search_entry = SearchEntry { + query: query.clone(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: Instant::now(), + cancel_flag: cancel_flag.clone(), + }; + + let client = self.client.clone(); + let timeout = self.search_timeout; + + // Results are polled in update_search_results, not returned here. + thread::spawn(move || { + if let Err(e) = + client.search_with_cancel(&query, timeout, Some(cancel_flag)) + { + soulseek_rs::warn!("Search failed: {e}"); + } + }); + + search_entry + } + + /// Make the search at `index` the one the Results pane shows, starting + /// from an empty display, filter and all, for what is about to arrive. + fn show_search(&mut self, index: usize) { + self.clear_results_pane(); + self.state.searches_table_state.select(Some(index)); + self.state.selected_search_index = Some(index); + self.state.results_table_state.select(Some(0)); + self.state.focus_pane(FocusedPane::Results); + } + + /// Show the searches the session knows about, not just this window's. + /// Returns whether it synced, which is only the case against a daemon. + /// + /// Against a daemon the search cache is shared, so a window that opens + /// later sees what the others have looked for, and a query run in one + /// appears in the other. A search is still collecting if it went out less + /// than one search window ago — the window belongs to whichever client + /// started it, so the age is the only thing another window can judge by. + fn sync_searches_with_session(&mut self) -> bool { + // Only worth doing when the session is shared: a window with its own + // session ran every search in the list and already knows their state. + if self.client.daemon_endpoint().is_none() { + return false; + } + + let timeout = self.search_timeout.as_secs(); + let known_searches = self.client.all_searches(); + for known in &known_searches { + let running = known + .started_secs_ago + .is_some_and(|elapsed| elapsed < timeout); + + if let Some(existing) = self + .state + .searches + .iter_mut() + .find(|search| search.query == known.query) + { + // Ours keeps the status its own worker thread sets; the rest + // follow the session, so a search finishing in one window + // stops saying "Active" in the others. + if known.started_secs_ago.is_some() && !existing.owned { + existing.status = if running { + SearchStatus::Active + } else { + SearchStatus::Completed + }; + } + // The count is every entry's change detector: as long as it + // matches what this window holds, there is nothing to fetch. + existing.known_files = known.files; + continue; + } + + self.state.searches.push(SearchEntry { + query: known.query.clone(), + status: if running { + SearchStatus::Active + } else { + SearchStatus::Completed + }, + results: Vec::new(), + known_files: known.files, + owned: false, + start_time: std::time::Instant::now(), + cancel_flag: std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ), + }); + } + + // Dismissed elsewhere means dismissed here. Ours stay: this window is + // the authority on a search it started. + self.state.searches.retain(|search| { + search.owned + || known_searches + .iter() + .any(|known| known.query == search.query) + }); + true + } + + pub(super) fn update_search_results(&mut self) { + // Only a synced session reports counts, so only there can an + // unchanged count spare the fetch. Locally `known_files` is never + // maintained and skipping on it would starve every search. + let counted = self.sync_searches_with_session(); + let timeout = self.search_timeout; + let selected_search_index = self.state.selected_search_index; + + // Fetch all results in one go (single lock acquisition per query) + // Use try_get_search_results to avoid blocking the UI thread + // Only what the user can see or what is still arriving, and against + // a daemon only when the session's count says something actually + // changed: results accumulate, so an unchanged count means a fetch + // would carry the entire set across the socket to learn nothing. + // A re-searched query resets the session's set; only a refill that + // lands back on exactly the held total within one tick could leave + // stale rows, and that window was already showing them. + let all_results: Vec<(usize, Vec<_>)> = self + .state + .searches + .iter() + .enumerate() + .filter(|(idx, search)| { + (search.status == SearchStatus::Active + || selected_search_index == Some(*idx)) + && (!counted || search.known_files != search.results.len()) + }) + .map(|(idx, s)| (idx, s.query.clone())) + .filter_map(|(idx, query)| { + self.client + .try_get_search_results(&query) + .map(|results| (idx, results)) + }) + .collect(); + + // Now update state without holding any client locks + for (idx, search_results) in all_results { + if let Some(search) = self.state.searches.get_mut(idx) { + // Results only accumulate, so an unchanged file count means + // nothing new arrived: skip the rebuild, which clones the + // full result list several times and dominates frame time. + let total_files: usize = + search_results.iter().map(|r| r.files.len()).sum(); + if total_files != search.results.len() { + search.results.clear(); + for result in search_results { + for file in result.files { + search.results.push(FileDisplayData { + filename: file.name.clone(), + size: file.size, + username: result.username.clone(), + speed: result.speed, + slots: result.slots, + bitrate: file.attribs.get(&0).copied(), + length_seconds: file.attribs.get(&1).copied(), + }); + } + } + + // Update selected search if this is the active one. Re-derive + // the filtered view from the current query so an active + // filter is preserved as new results stream in, rather than + // being clobbered by the full unfiltered list. + if let Some(selected_idx) = selected_search_index + && selected_idx == idx + { + self.state.results_items = search.results.clone(); + let (items, indices) = filter_results( + &self.state.results_items, + &self.state.results_filter_query, + ); + self.state.results_filtered_items = items; + self.state.results_filtered_indices = indices; + } + } + } + } + + // Outside the fetch loop: a tick that had nothing to fetch must + // still finish a run-out search. + for search in &mut self.state.searches { + if search.status == SearchStatus::Active + && search.start_time.elapsed() > timeout + { + search.status = SearchStatus::Completed; + } + } + } +} + +/// Filter `items` by a case-insensitive substring match on filename or +/// username. Returns the matching items alongside their indices in the original +/// list, so callers can translate a filtered display index back to the +/// unfiltered results. An empty query returns everything (identity mapping). +fn filter_results( + items: &[FileDisplayData], + query: &str, +) -> (Vec, Vec) { + let query = query.to_lowercase(); + if query.is_empty() { + return (items.to_vec(), (0..items.len()).collect()); + } + + let mut filtered_items = Vec::new(); + let mut filtered_indices = Vec::new(); + for (idx, item) in items.iter().enumerate() { + if item.filename.to_lowercase().contains(&query) + || item.username.to_lowercase().contains(&query) + { + filtered_items.push(item.clone()); + filtered_indices.push(idx); + } + } + (filtered_items, filtered_indices) +} + +#[cfg(test)] +mod tests { + use super::filter_results; + use crate::models::FileDisplayData; + + fn file(filename: &str, username: &str) -> FileDisplayData { + FileDisplayData { + filename: filename.to_string(), + username: username.to_string(), + ..Default::default() + } + } + + #[test] + fn empty_query_returns_identity_mapping() { + let items = vec![file("a.mp3", "bob"), file("b.flac", "amy")]; + let (filtered, indices) = filter_results(&items, ""); + assert_eq!(filtered.len(), 2); + assert_eq!(indices, vec![0, 1]); + } + + #[test] + fn query_matches_filename_and_username_and_maps_indices() { + let items = vec![ + file("track.mp3", "bob"), + file("song.flac", "alice"), + file("alice_demo.mp3", "carol"), + ]; + // "alice" matches item 1 (username) and item 2 (filename). + let (filtered, indices) = filter_results(&items, "alice"); + assert_eq!(filtered.len(), 2); + assert_eq!(indices, vec![1, 2]); + assert_eq!(filtered[0].filename, "song.flac"); + assert_eq!(filtered[1].filename, "alice_demo.mp3"); + } + + #[test] + fn query_is_case_insensitive() { + let items = vec![file("The Weeknd.mp3", "dj")]; + let (filtered, indices) = filter_results(&items, "WEEKND"); + assert_eq!(filtered.len(), 1); + assert_eq!(indices, vec![0]); + } +} diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs new file mode 100644 index 0000000..7668fda --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -0,0 +1,177 @@ +//! Settings popup behavior: open, key handling, and applying changes — +//! persisted to config.toml, applied live to the session (pushed over the +//! socket when the session is a daemon's). + +use super::MainTui; +use crate::models::{AccountInfo, SettingsAction, SettingsState}; +use crate::persist::secret::KeyringStore; +use ratatui::crossterm::event::KeyEvent; + +impl MainTui { + pub(super) fn open_settings(&mut self) { + // Read once here, not while drawing: see `AccountInfo`. + let account = AccountInfo { + username: self.client.username(), + shares: self.share_counts(), + listen_port: self.client.listen_port(), + daemon: self.client.daemon_endpoint(), + }; + self.state.settings = Some(SettingsState::new( + account, + self.download_dir.clone(), + self.client.shared_directories(), + self.use_username_dirs, + )); + } + + pub(super) fn handle_settings_input(&mut self, key: KeyEvent) { + let Some(settings) = self.state.settings.as_mut() else { + return; + }; + match settings.handle_key(key) { + SettingsAction::None => {} + SettingsAction::Close => self.state.settings = None, + SettingsAction::Apply => self.apply_settings(), + SettingsAction::Reindex => self.reindex_shares(), + SettingsAction::ChangePassword(password) => { + self.change_password(&password); + } + SettingsAction::Logout => { + self.state.exit = Some(crate::models::TuiExit::Logout); + } + } + } + + fn change_password(&mut self, password: &str) { + let status = match crate::persist::secret::change_password( + self.client.as_ref(), + &KeyringStore, + password, + ) { + Ok(None) => "Password changed".into(), + Ok(Some(e)) => format!("Password changed, but not stored: {e}"), + Err(e) => format!("Could not change it: {e}"), + }; + self.set_settings_status(status); + } + + /// Persist the edited settings and apply them live. + /// + /// Attached to a daemon, both folders name paths on the *daemon's* + /// machine: they are pushed over the socket and validated there, and no + /// directory is created on this one. Locally they are this machine's and + /// are validated here. + pub(super) fn apply_settings(&mut self) { + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let download_dir = settings.download_dir.clone(); + let share_dirs = settings.share_dirs.clone(); + let use_username_dirs = settings.use_username_dirs; + + let shares = if self.client.daemon_endpoint().is_some() { + share_dirs.clone() + } else { + if let Err(e) = std::fs::create_dir_all(&download_dir) { + self.set_settings_status(format!( + "Cannot create {download_dir}: {e}" + )); + return; + } + // Validate the share paths (tilde-expand, must exist). + crate::directories::resolve_shared_directories(&share_dirs) + }; + let dropped = share_dirs.len() - shares.len(); + + // Two independent applies: a failure of one must not misreport or + // silently discard the other, so both run and both are told. + let mut refused = Vec::new(); + let dir_applied = + match self.client.set_download_directory(download_dir.clone()) { + Ok(()) => true, + Err(e) => { + refused.push(format!("download folder: {e}")); + false + } + }; + if let Err(e) = self.client.set_shared_directories(shares) { + refused.push(format!("shares: {e}")); + } + + let mut status = if !refused.is_empty() { + format!("Could not apply {}", refused.join("; ")) + } else if dropped > 0 { + self.refresh_share_counts(); + format!("Applied ({dropped} invalid path(s) ignored)") + } else { + format!("Applied · sharing {}", self.refresh_share_counts()) + }; + if dir_applied { + self.download_dir.clone_from(&download_dir); + } + self.use_username_dirs = use_username_dirs; + + // Persist to config.toml so the change survives a restart. What was + // refused live is not written either: the file must not promise a + // folder the session never accepted. + if let Some(path) = self.config_path.clone() { + let result = crate::persist::config::FileConfig::load(&path) + .and_then(|mut config| { + if dir_applied { + config.download_dir = Some(download_dir); + } + // The singular `shared_dir` is merged with `shared_dirs` on + // load, so leaving it would resurrect a removed folder. + config.shared_dir = None; + config.shared_dirs = Some(share_dirs); + config.use_username_dirs = Some(use_username_dirs); + config.save(&path) + }); + if let Err(e) = result { + status = format!("Could not save config: {e}"); + } + } + self.set_settings_status(status); + self.load_own_shares(); + } + + /// Bring the account block's share count back in step, and hand the same + /// count to the caller: counting is a round trip worth making once. + fn refresh_share_counts(&mut self) -> String { + let counts = self.share_counts(); + if let Some(settings) = self.state.settings.as_mut() { + settings.account.shares.clone_from(&counts); + } + counts + } + + fn set_settings_status(&mut self, status: String) { + if let Some(settings) = self.state.settings.as_mut() { + settings.status = Some(status); + } + } + + /// Re-scan the current share paths (picks up files changed on disk). + // ponytail: the scan runs on the UI thread, so a very large share set + // freezes the window for its duration. Move it to a worker if that bites. + pub(super) fn reindex_shares(&mut self) { + let dirs = self.client.shared_directories(); + let result = self.client.set_shared_directories(dirs); + // Only the settings pane shows these, and `r` in the share view + // calls us with the pane closed — where the counts would be a + // round-trip against the daemon for a string nobody reads. + if self.state.settings.is_some() { + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); + } + self.load_own_shares(); + } + + fn share_counts(&self) -> String { + let (folders, files) = self.client.shared_counts(); + format!("{files} files in {folders} folders") + } +} diff --git a/soulseek-rs/src/ui/main_tui/tests/browse.rs b/soulseek-rs/src/ui/main_tui/tests/browse.rs new file mode 100644 index 0000000..eb8727d --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/browse.rs @@ -0,0 +1,532 @@ +//! The browse popup, over a peer's shares and over our own. + +use super::*; + +fn directory( + name: &str, + files: &[(&str, u64)], +) -> soulseek_rs::SharedDirectory { + soulseek_rs::SharedDirectory { + name: name.to_string(), + files: files + .iter() + .map(|(name, size)| soulseek_rs::SharedFileEntry { + name: (*name).to_string(), + size: *size, + attributes: Vec::new(), + }) + .collect(), + } +} + +fn folder_window(session: Arc) -> MainTui { + let mut tui = attach(session); + tui.state.focused_pane = FocusedPane::Results; + tui.state.results_items = results(2); + tui.state.results_items[0].filename = + "Music\\Artist\\Album\\03 - Track.flac".into(); + tui.state.results_items[1].filename = "Other\\track.mp3".into(); + tui.state.results_items[1].username = "alice".into(); + tui.state.results_table_state.select(Some(0)); + tui +} + +fn wait_for(mut ready: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !ready() { + assert!( + std::time::Instant::now() < deadline, + "worker did not finish" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn finish_folder(tui: &mut MainTui) { + wait_for(|| { + tui.poll_session(); + tui.folder_fetches.is_empty() && tui.folder_queue_results.is_empty() + }); + tui.update_downloads(); +} + +fn answer( + session: &TalkativeSession, + directories: Vec, +) { + wait_for(|| !session.browse_requests.lock().unwrap().is_empty()); + session + .browse_results + .lock() + .unwrap() + .insert("bob".into(), directories); +} + +#[test] +fn containing_folder_uses_soulseek_paths_and_rejects_malformed_paths() { + use super::super::browse::containing_folder; + for (path, parent) in [ + ( + "Music\\Artist\\Album\\03 - Track.flac", + "Music\\Artist\\Album", + ), + ("@@share\\song.mp3", "@@share"), + ("\\Music\\Björk\\曲.flac", "\\Music\\Björk"), + ("C:\\Music\\song.mp3", "C:\\Music"), + ] { + assert_eq!(containing_folder(path), Some(parent), "{path}"); + } + for path in [ + "", + "track.mp3", + "\\track.mp3", + "Music\\", + "Music\\..\\song.mp3", + "Music\\.\\song.mp3", + "Music\\\\song.mp3", + "Music\\bad\0.mp3", + "Music/track.mp3", + ] { + assert_eq!(containing_folder(path), None, "{path:?}"); + } +} + +#[test] +fn shift_d_downloads_only_highlighted_folder_from_complete_peer_listing() { + for per_user in [false, true] { + let (release, gate) = std::sync::mpsc::channel(); + let session = Arc::new(TalkativeSession { + accept_downloads: true, + browse_gate: Some(std::sync::Mutex::new(gate)), + ..Default::default() + }); + let mut tui = folder_window(session.clone()); + tui.use_username_dirs = per_user; + tui.state.results_selected_indices.insert(1); + // The kept filter maps the highlighted row to the original result. + tui.state.results_items.swap(0, 1); + tui.state.results_filter_query = "Track.flac".into(); + tui.apply_filter(); + tui.state.results_selected_indices.clear(); + tui.state.results_selected_indices.insert(0); + tui.handle_key_event(KeyEvent::new( + KeyCode::Char('D'), + KeyModifiers::SHIFT, + )); + // The worker is held at the request: navigation and rendering still run. + press(&mut tui, KeyCode::Tab); + assert_ne!(tui.state.focused_pane, FocusedPane::Results); + assert!(screen_of(&mut tui).contains("Fetching folder")); + assert!(!tui.state.show_browse); + assert!(tui.state.browse.tabs.is_empty()); + assert!(session.download_requests.lock().unwrap().is_empty()); + release.send(()).unwrap(); + answer( + &session, + vec![ + directory("Music\\Artist", &[("parent.mp3", 1)]), + directory( + "Music\\Artist\\Album\\", + &[ + ("03 - Track.flac", 30), + ("01 - Missing.flac", 10), + ("cover.jpg", 20), + ], + ), + directory("Music\\Artist\\Album\\Disc 2", &[("child.flac", 2)]), + directory( + "Music\\Artist\\Album extras", + &[("sibling.flac", 3)], + ), + directory("Other", &[("track.mp3", 4)]), + ], + ); + finish_folder(&mut tui); + let downloads = session.download_requests.lock().unwrap(); + assert_eq!(downloads.len(), 3); + for (download, name, size) in [ + (&downloads[0], "03 - Track.flac", 30), + (&downloads[1], "01 - Missing.flac", 10), + (&downloads[2], "cover.jpg", 20), + ] { + assert_eq!(download.username, "bob"); + assert_eq!( + download.filename, + format!("Music\\Artist\\Album\\{name}") + ); + assert_eq!(download.size, size); + assert_eq!( + download.download_directory, + if per_user { "/tmp/bob" } else { "/tmp" } + ); + } + assert_eq!(tui.state.downloads.len(), 3); + assert_eq!(tui.state.results_selected_indices, [0].into()); + assert!(screen_of(&mut tui).contains("Queued 3 files from bob")); + } +} + +#[test] +fn enter_still_downloads_selected_files_and_b_still_opens_the_owner() { + let session = Arc::new(TalkativeSession { + accept_downloads: true, + ..Default::default() + }); + let mut tui = folder_window(session.clone()); + tui.state.results_selected_indices.insert(1); + press(&mut tui, KeyCode::Enter); + wait_for(|| !session.download_requests.lock().unwrap().is_empty()); + let requests = session.download_requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].username, "alice"); + assert_eq!(requests[0].filename, "Other\\track.mp3"); + assert!(tui.state.results_selected_indices.is_empty()); + assert!(session.browse_requests.lock().unwrap().is_empty()); + drop(requests); + + press(&mut tui, KeyCode::Char('b')); + assert!(tui.state.show_browse); + assert_eq!(tui.state.browse.active_tab().unwrap().username, "bob"); + assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]); + assert!(tui.folder_fetches.is_empty()); +} + +#[test] +fn d_without_a_highlight_or_outside_results_does_nothing() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = folder_window(session.clone()); + for selection in [None, Some(99)] { + tui.state.results_table_state.select(selection); + press(&mut tui, KeyCode::Char('D')); + assert!(tui.folder_fetches.is_empty()); + assert!(tui.folder_download_status.is_none()); + } + tui.state.results_items.clear(); + tui.state.results_table_state.select(Some(0)); + press(&mut tui, KeyCode::Char('D')); + assert!(tui.folder_fetches.is_empty()); + tui.state.results_items = results(1); + for pane in [FocusedPane::Searches, FocusedPane::Downloads] { + tui.state.focused_pane = pane; + press(&mut tui, KeyCode::Char('D')); + assert!(tui.folder_fetches.is_empty()); + } + assert!(session.browse_requests.lock().unwrap().is_empty()); +} + +#[test] +fn d_is_text_while_editing_a_filter_and_ctrl_d_still_pages() { + let mut tui = results_tui(40); + tui.state.results_table_state.select(Some(0)); + ctrl(&mut tui, 'd'); + assert!(selected(&tui).unwrap() > 0); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('D')); + assert_eq!(tui.state.results_filter_query, "D"); + assert!(tui.folder_fetches.is_empty()); +} + +#[test] +fn failed_requests_and_timeouts_report_errors_and_allow_retry() { + let session = Arc::new(TalkativeSession { + browse_fails: true, + ..Default::default() + }); + let mut tui = folder_window(session.clone()); + press(&mut tui, KeyCode::Char('D')); + finish_folder(&mut tui); + assert!(screen_of(&mut tui).contains("Could not fetch folder from bob")); + assert!(session.download_requests.lock().unwrap().is_empty()); + + let session = Arc::new(TalkativeSession::default()); + let mut tui = folder_window(session.clone()); + press(&mut tui, KeyCode::Char('D')); + wait_for(|| !session.browse_requests.lock().unwrap().is_empty()); + tui.folder_fetches[0].requested_at = std::time::Instant::now() + .checked_sub(Duration::from_secs(21)) + .expect("a time before the browse timeout"); + finish_folder(&mut tui); + assert!(screen_of(&mut tui).contains("timed out")); + // A late response must not queue anything after the timeout. + answer( + &session, + vec![directory("Music\\Artist\\Album", &[("late.flac", 1)])], + ); + tui.poll_session(); + assert!(session.download_requests.lock().unwrap().is_empty()); + press(&mut tui, KeyCode::Char('D')); + wait_for(|| session.browse_requests.lock().unwrap().len() == 2); + assert_eq!(tui.folder_fetches.len(), 1); +} + +#[test] +fn empty_missing_and_malformed_folders_do_not_queue_files() { + for (listing, message) in [ + (vec![directory("Music\\Artist\\Album", &[])], "is empty"), + (vec![], "folder not found"), + ( + vec![directory( + "Music\\Artist\\Album\\Child", + &[("child.flac", 1)], + )], + "folder not found", + ), + ( + vec![directory( + "Music\\Artist\\Album", + &[("valid.flac", 1), ("..\\escape.flac", 2)], + )], + "invalid file name", + ), + ] { + let session = Arc::new(TalkativeSession { + accept_downloads: true, + ..Default::default() + }); + let mut tui = folder_window(session.clone()); + press(&mut tui, KeyCode::Char('D')); + answer(&session, listing); + finish_folder(&mut tui); + assert!( + tui.folder_download_status + .as_ref() + .unwrap() + .contains(message) + ); + assert!(session.download_requests.lock().unwrap().is_empty()); + } + let session = Arc::new(TalkativeSession::default()); + let mut tui = folder_window(session.clone()); + tui.state.results_items[0].filename = "track.flac".into(); + press(&mut tui, KeyCode::Char('D')); + assert!(screen_of(&mut tui).contains("invalid remote file path")); + assert!(tui.folder_fetches.is_empty()); + assert!(session.browse_requests.lock().unwrap().is_empty()); +} + +#[test] +fn download_queue_failures_are_reported() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = folder_window(session.clone()); + press(&mut tui, KeyCode::Char('D')); + answer( + &session, + vec![directory( + "Music\\Artist\\Album", + &[("a.flac", 1), ("b.flac", 2)], + )], + ); + finish_folder(&mut tui); + assert!(screen_of(&mut tui).contains("Queued 0 files from bob; 2 failed")); + assert!(tui.state.downloads.is_empty()); +} + +#[test] +fn a_listing_serves_both_browse_and_folder_download_without_duplicate_requests() +{ + for browse_first in [false, true] { + let session = Arc::new(TalkativeSession { + accept_downloads: true, + ..Default::default() + }); + let mut tui = folder_window(session.clone()); + if browse_first { + press(&mut tui, KeyCode::Char('b')); + press(&mut tui, KeyCode::Esc); + } + press(&mut tui, KeyCode::Char('D')); + press(&mut tui, KeyCode::Char('D')); + assert_eq!(tui.folder_fetches.len(), 1); + press(&mut tui, KeyCode::Char('b')); + answer( + &session, + vec![directory("Music\\Artist\\Album", &[("a.flac", 1)])], + ); + finish_folder(&mut tui); + assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]); + assert_eq!( + tui.state.browse.active_tab().unwrap().status, + crate::models::BrowseStatus::Loaded + ); + assert_eq!(session.download_requests.lock().unwrap().len(), 1); + } +} + +#[test] +fn help_documents_the_containing_folder_key() { + let mut tui = results_tui(1); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_sized(&mut tui, 180, 60); + let row = + row_with(&screen, "download highlighted file's containing folder"); + assert!(row.contains('D')); +} + +#[test] +fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { + let session = Arc::new(TalkativeSession::default()); + *session.listing.lock().expect("not poisoned") = + vec![soulseek_rs::SharedDirectory { + name: "Music\\Album".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "track.mp3".to_string(), + size: 4096, + attributes: Vec::new(), + }], + }]; + let mut tui = attach(session.clone()); + + press(&mut tui, KeyCode::Char('B')); + assert!(tui.state.show_browse, "the popup opens"); + let tab = tui.state.browse.active_tab().expect("tab"); + assert!(tab.own, "on this session's own index"); + assert_eq!(tab.username, "tester"); + assert_eq!(tab.file_count, 1); + let screen = screen_of(&mut tui); + assert!(screen.contains("My shares"), "{screen}"); + press(&mut tui, KeyCode::Char('L')); + let screen = screen_of(&mut tui); + assert!( + screen.contains("Album") && screen.contains("track.mp3"), + "the virtual path a peer would see, folder by folder: {screen}" + ); + + // Nothing here is downloadable from ourselves. + press(&mut tui, KeyCode::Char('d')); + assert!(tui.state.downloads.is_empty()); + + // `r` re-scans the disk rather than re-asking a peer, and the tab + // picks up what the fresh scan found. + session.listing.lock().expect("not poisoned").clear(); + press(&mut tui, KeyCode::Char('r')); + assert!( + session.shares_set.lock().expect("not poisoned").is_some(), + "the session was asked to re-index" + ); + assert_eq!( + tui.state.browse.active_tab().expect("tab").file_count, + 0, + "the view follows the new index" + ); + + // A second B focuses the tab it already opened. + press(&mut tui, KeyCode::Esc); + press(&mut tui, KeyCode::Char('B')); + assert_eq!(tui.state.browse.tabs.len(), 1); +} + +#[test] +fn o_leaves_the_share_view_for_the_folders_that_feed_it() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + press(&mut tui, KeyCode::Char('B')); + press(&mut tui, KeyCode::Char('o')); + assert!(!tui.state.show_browse, "the popup steps aside"); + assert!(tui.state.settings.is_some(), "for the settings pane"); +} + +#[test] +fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session); + browsed(&mut tui, 40); + let rows = tui.state.browse.active_tab().expect("tab").rows().len(); + assert_eq!(rows, 41, "the folder and its files"); + + press(&mut tui, KeyCode::End); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + assert_eq!(selected(&tui), 40); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), 0); + press(&mut tui, KeyCode::PageDown); + let page = selected(&tui); + assert!(page > 1 && page < 40, "a page of rows: {page}"); + ctrl(&mut tui, 'd'); + assert!(selected(&tui) > page, "ctrl-d pages"); + assert!( + tui.state.downloads.is_empty() + && tui.state.downloads_receiver_channel.is_none(), + "and does not download the folder" + ); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), 40); + assert!(tui.state.show_browse, "still open"); +} + +#[test] +fn folder_keys_and_the_filter_move_through_a_browsed_tree() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + tui.state.browse.open("bob"); + let listing = vec![ + soulseek_rs::SharedDirectory { + name: "music\\alpha".to_string(), + files: vec![ + soulseek_rs::SharedFileEntry { + name: "a1.mp3".to_string(), + size: 1, + attributes: Vec::new(), + }, + soulseek_rs::SharedFileEntry { + name: "a2.flac".to_string(), + size: 2, + attributes: Vec::new(), + }, + ], + }, + soulseek_rs::SharedDirectory { + name: "music\\beta".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "b1.mp3".to_string(), + size: 3, + attributes: Vec::new(), + }], + }, + ]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(&mut tui); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + let rows = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").rows().len() + }; + + press(&mut tui, KeyCode::Char('L')); + assert_eq!(rows(&tui), 6, "every folder open"); + press(&mut tui, KeyCode::Char('J')); + press(&mut tui, KeyCode::Char('J')); + assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); + press(&mut tui, KeyCode::Char('K')); + assert_eq!(selected(&tui), 1, "back to alpha"); + press(&mut tui, KeyCode::Char('H')); + assert_eq!(rows(&tui), 1, "just music"); + + press(&mut tui, KeyCode::Char('/')); + for c in "flac".chars() { + press(&mut tui, KeyCode::Char(c)); + } + assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); + let screen = screen_of(&mut tui); + assert!(screen.contains("filter: flac_"), "{screen}"); + ctrl(&mut tui, 'd'); + assert_eq!( + tui.state.browse.active_tab().expect("tab").filter(), + "flac", + "a control chord pages, it does not type" + ); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + press(&mut tui, KeyCode::Char('j')); + assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); + press(&mut tui, KeyCode::Esc); + assert_eq!(rows(&tui), 1, "Esc clears the filter first"); + assert!(tui.state.show_browse); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_browse, "then hides the popup"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs new file mode 100644 index 0000000..1ea058f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -0,0 +1,399 @@ +//! The window itself: which panes are up, how big, and what the chrome +//! says while they move. + +use super::*; + +#[test] +fn the_results_pane_has_the_whole_width_to_itself() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + + let results = row_with(&screen, "[2] Results"); + assert!(results.starts_with('╭'), "{results}"); + assert!(results.trim_end().ends_with('╮'), "{results}"); + assert!(!results.contains("[1]"), "nothing beside it: {results}"); + + // The other three share the row underneath, in this order. + let row = row_with(&screen, "[1] Searches"); + let searches = row.find("[1]").expect("searches"); + let downloads = row.find("[3]").expect("downloads"); + let info = row.find("[Info]").expect("info"); + assert!(searches < downloads && downloads < info, "{row}"); +} + +#[test] +fn a_listener_that_fell_back_says_so_in_the_status_bar() { + let mut tui = furnished_tui(); + tui.listener_fallback = Some((2234, 50123)); + + let screen = screen_of(&mut tui); + + assert!( + screen.contains("port 2234 in use, listening on 50123"), + "{screen}" + ); +} + +#[test] +fn tab_and_shift_tab_walk_the_panes_in_legend_order() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + press(&mut tui, KeyCode::BackTab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn w_hides_the_focused_pane_and_its_number_brings_it_back() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + + press(&mut tui, KeyCode::Char('w')); + + let screen = screen_of(&mut tui); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); + // The transfers widen into the space. + let row = row_with(&screen, "[3] Downloads"); + assert!(row.starts_with('╭'), "{row}"); + + press(&mut tui, KeyCode::Char('1')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn hiding_the_results_gives_the_row_the_whole_height() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(!screen.contains("[2] Results"), "{screen}"); + // The row now starts right under the status bar. + let status = screen + .lines() + .position(|line| line.contains("Status")) + .expect("status bar"); + let row = screen + .lines() + .position(|line| line.contains("[1] Searches")) + .expect("searches"); + assert_eq!(row, status + 3, "{screen}"); +} + +#[test] +fn the_last_pane_standing_cannot_be_hidden() { + // Results goes, then Downloads; Searches is what is left, and a + // third press leaves it there. + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('z')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[Info]"), "{screen}"); + assert!(screen.contains("[z → unzoom]"), "{screen}"); + + // Tab while zoomed switches which pane fills the window. + press(&mut tui, KeyCode::Tab); + let screen = screen_of(&mut tui); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[2] Results"), "{screen}"); + + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.layout.zoomed); + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[Info]"), "{screen}"); + + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Char('z')); + assert!(!tui.state.layout.zoomed, "z toggles"); +} + +#[test] +fn a_search_brings_a_hidden_results_pane_back() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + assert!(!tui.state.layout.is_visible(FocusedPane::Results)); + + press(&mut tui, KeyCode::Char('s')); + press(&mut tui, KeyCode::Char('x')); + press(&mut tui, KeyCode::Enter); + + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn question_mark_opens_the_keys_list_and_closes_it_again() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("Keys"), "{screen}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + assert!(screen.contains("clear every finished one"), "{screen}"); + + // Other keys are swallowed while it is open. + press(&mut tui, KeyCode::Char('q')); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); + assert!(!tui.state.show_help); + + press(&mut tui, KeyCode::Char('?')); + press(&mut tui, KeyCode::Char('w')); + assert!(tui.state.show_help, "w did not reach the panes"); + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_help); +} + +#[test] +fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_sized(&mut tui, 80, 24); + let panes = row_with(&screen, "Panes"); + assert!(!panes.contains("Searches"), "one column: {panes}"); + assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); + assert!(!screen.contains("next room, chat or user"), "{screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("next room, chat or user"), "{screen}"); + assert!(!screen.contains("next / previous pane"), "{screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + + // A wide window shows both columns at once, nothing to scroll. It + // has to be tall enough to hold the taller column outright; in a + // shorter one End follows the tail and the headings scroll off. + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 160, 44); + let panes = row_with(&screen, "Panes"); + assert!(panes.contains("Searches"), "two columns: {panes}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); +} + +#[test] +fn a_click_where_a_pane_just_hid_does_not_focus_it() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + // Hidden, but not yet redrawn: the old area is still on record. + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('w')); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + assert!(screen.contains("[q → quit]"), "{screen}"); + let bar_rows = screen.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 2, "{screen}"); + + let wide = screen_sized(&mut tui, 300, 40); + let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 1, "{wide}"); +} + +#[test] +fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[C → clear all]"), "{screen}"); + assert!(screen.contains("[S → search again]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("run the search again"), "{screen}"); +} + +#[test] +fn the_downloads_pane_lists_its_clear_everything_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[c → clear finished]"), "{screen}"); + assert!(screen.contains("[C → clear all]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("cancelling the live ones"), "{screen}"); +} + +#[test] +fn shift_w_brings_every_pane_back_and_leaves_zoom() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('z')); + assert!(tui.state.layout.zoomed); + assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[W → reset layout]"), "{screen}"); + press(&mut tui, KeyCode::Char('W')); + assert!(!tui.state.layout.zoomed); + assert!( + FocusedPane::ALL + .iter() + .all(|p| tui.state.layout.is_visible(*p)) + ); +} + +#[test] +fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let inside = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }; + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + + press(&mut tui, KeyCode::Char('w')); + let _ = screen_of(&mut tui); + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn dragging_the_border_under_results_resizes_its_row() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let results = tui.state.results_pane_area.expect("laid out"); + let divider = tui.state.hsplit_divider.expect("a divider"); + let before = results.height; + let focused = tui.state.focused_pane; + + // Two drags: the row shrinks by the rows the pointer moved up. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y), + (results.x + 1, divider.y - 2), + ); + assert_eq!(tui.state.layout.top_height, Some(before - 2)); + assert_eq!( + tui.state.focused_pane, focused, + "a grab is not a focus click" + ); + + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.results_pane_area.expect("laid out").height, + before - 2 + ); + + // A drag far past the row stops at the smallest pane, not zero. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y - 2), + (results.x + 1, 0), + ); + assert!(tui.state.layout.top_height.expect("resized") >= 3); + + press(&mut tui, KeyCode::Char('W')); + assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); +} + +#[test] +fn dragging_a_row_panes_right_edge_gives_it_width() { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let seam = tui + .state + .vsplit_dividers + .iter() + .find(|(pane, _)| *pane == FocusedPane::Searches) + .map(|(_, rect)| *rect) + .expect("a seam beside Searches"); + tui.state.focused_pane = FocusedPane::Downloads; + + mouse_drag_row( + &mut tui, + (seam.x, searches.y + 1), + (seam.x + 4, searches.y + 1), + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + + // The widths are applied, and Info narrows to whatever is left. + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.searches_pane_area.expect("laid out").width, + searches.width + 4 + ); + assert!(tui.state.info_pane_area.expect("laid out").width >= 3); + + // Releasing the button ends the drag: later motions just move the + // pointer. + mouse( + &mut tui, + MouseEventKind::Drag(MouseButton::Left), + seam.x + 40, + searches.y + 1, + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); +} + +#[test] +fn the_info_pane_describes_the_highlighted_result() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + "@@x\\Music\\Album\\Second Track.mp3".to_string(); + tui.state.results_items[1].bitrate = Some(320); + tui.state.results_table_state.select(Some(1)); + + let screen = screen_of(&mut tui); + assert!(screen.contains("@@x/Music/Album"), "{screen}"); + assert!(screen.contains("Bitrate"), "{screen}"); + + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_of(&mut tui); + assert!(!screen.contains("@@x/Music/Album"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs new file mode 100644 index 0000000..272abc9 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -0,0 +1,602 @@ +//! The window driven the way a terminal drives it: keys and mouse events in, +//! a rendered screen out, over a stand-in session. +//! +//! This file is the harness the topic modules below share — the session +//! double, the window constructors, and the press/screen helpers. + +mod browse; +mod layout; +mod navigation; +mod rooms; +mod searches; +mod session; +mod settings; +mod transfers; + +use super::*; + +use crate::daemon::proto::ChatMessageDto; + +use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus}; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// A stand-in for a daemon: it already holds a conversation, a transfer +/// queue, and a search cache, none of which this window created. +#[derive(Default)] +struct TalkativeSession { + history: Vec, + downloads: Vec, + searches: std::sync::Mutex>, + /// What `try_get_search_results` hands back to the window. + results: std::sync::Mutex>, + /// Every query a window put on the wire, in order. + searched: std::sync::Mutex>, + /// How many times a window asked for the search list. + all_searches_calls: std::sync::atomic::AtomicUsize, + /// How many times a window pulled a full result set across. Against a + /// daemon each pull is the whole set over the socket, so the tests + /// count them. + result_fetches: std::sync::atomic::AtomicUsize, + /// Whether this session is a daemon's. Local sessions do not sync. + shared: bool, + /// What `set_download_directory` was last asked for, if anything. + download_dir_set: std::sync::Mutex>, + /// What `set_shared_directories` was last asked for, if anything. + shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, + download_cancelled: std::sync::Mutex>, + upload_cancelled: std::sync::Mutex>, + download_removed: std::sync::Mutex>, + browse_requests: std::sync::Mutex>, + browse_results: std::sync::Mutex< + std::collections::HashMap>, + >, + browse_fails: bool, + browse_gate: Option>>, + accept_downloads: bool, + download_requests: std::sync::Mutex>, +} + +fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Queued) +} + +fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Completed) +} + +fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo { + soulseek_rs::types::UploadInfo { + username: username.to_string(), + filename: filename.to_string(), + size: 10, + bytes_sent: 1, + speed_bytes_per_sec: 1.0, + status: soulseek_rs::types::UploadStatus::InProgress, + } +} + +fn at_status( + username: &str, + filename: &str, + status: soulseek_rs::DownloadStatus, +) -> soulseek_rs::types::Download { + let (sender, _) = std::sync::mpsc::channel(); + soulseek_rs::types::Download { + username: username.to_string(), + filename: filename.to_string(), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: soulseek_rs::types::DownloadMetadata::default(), + } +} + +fn key(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) +} + +impl SessionApi for TalkativeSession { + fn message_history(&self) -> Vec { + self.history.clone() + } + fn daemon_endpoint(&self) -> Option { + self.shared.then(|| "test-daemon".to_string()) + } + fn username(&self) -> String { + "tester".to_string() + } + fn listen_port(&self) -> Option { + None + } + fn session_loss(&self) -> Option { + None + } + fn search_with_cancel( + &self, + query: &str, + _timeout: Duration, + _cancel: Option>, + ) -> soulseek_rs::Result> { + self.searched + .lock() + .expect("not poisoned") + .push(query.to_string()); + Ok(Vec::new()) + } + fn get_search_results(&self, _key: &str) -> Vec { + Vec::new() + } + fn all_searches(&self) -> Vec { + self.all_searches_calls + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.searches.lock().expect("not poisoned").clone() + } + fn forget_search(&self, query: &str) -> bool { + let mut searches = self.searches.lock().expect("not poisoned"); + let before = searches.len(); + searches.retain(|search| search.query != query); + searches.len() != before + } + fn get_search_results_count(&self, _key: &str) -> usize { + 0 + } + fn try_get_search_results( + &self, + _key: &str, + ) -> Option> { + self.result_fetches + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Some(self.results.lock().expect("not poisoned").clone()) + } + fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn wishlist_interval(&self) -> Duration { + Duration::from_mins(12) + } + fn download( + &self, + filename: String, + username: String, + size: u64, + directory: String, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + let mut download = queued(&username, &filename); + download.size = size; + download.download_directory = directory; + self.download_requests + .lock() + .expect("not poisoned") + .push(download.clone()); + if self.accept_downloads { + Ok((download, std::sync::mpsc::channel().1)) + } else { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + } + fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + directory: String, + metadata: soulseek_rs::types::DownloadMetadata, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + self.download(filename, username, size, directory).map( + |(mut download, receiver)| { + download.metadata = metadata; + (download, receiver) + }, + ) + } + fn get_all_downloads(&self) -> Vec { + self.downloads.clone() + } + fn pause_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn resume_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_download(&self, u: &str, f: &str) -> bool { + self.download_removed + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn uploads(&self) -> Vec { + Vec::new() + } + fn take_upload_events(&self) -> Vec { + Vec::new() + } + fn cancel_upload(&self, u: &str, f: &str) -> bool { + self.upload_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn cancel_download(&self, u: &str, f: &str) -> bool { + self.download_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn set_upload_slots(&self, _slots: usize) {} + fn check_privileges(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn own_privilege_seconds(&self) -> Option { + None + } + fn request_room_list(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn say_in_room( + &self, + _room: &str, + _message: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn room_members(&self, _room: &str) -> Vec { + Vec::new() + } + fn room_member_stats( + &self, + _room: &str, + ) -> Vec { + Vec::new() + } + fn take_room_events(&self) -> Vec { + Vec::new() + } + fn send_private_message( + &self, + _u: &str, + _m: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_private_messages(&self) -> Vec { + Vec::new() + } + fn browse_user(&self, username: &str) -> soulseek_rs::Result<()> { + self.browse_requests + .lock() + .expect("not poisoned") + .push(username.to_string()); + if let Some(gate) = &self.browse_gate { + gate.lock() + .expect("not poisoned") + .recv_timeout(Duration::from_secs(5)) + .expect("release browse request"); + } + if self.browse_fails { + Err(soulseek_rs::SoulseekRs::NotConnected) + } else { + Ok(()) + } + } + fn take_browse_result( + &self, + username: &str, + ) -> Option> { + self.browse_results + .lock() + .expect("not poisoned") + .remove(username) + } + fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watched_users(&self) -> Vec { + Vec::new() + } + fn user_info(&self, _username: &str) -> Option { + None + } + fn shared_counts(&self) -> (u32, u32) { + (3, 12) + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password_set.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } + fn shared_listing(&self) -> Vec { + self.listing.lock().expect("not poisoned").clone() + } + fn shared_directories(&self) -> Vec { + Vec::new() + } + fn set_shared_directories( + &self, + directories: Vec, + ) -> soulseek_rs::Result<()> { + *self.shares_set.lock().expect("not poisoned") = Some(directories); + Ok(()) + } + fn set_download_directory( + &self, + directory: String, + ) -> soulseek_rs::Result<()> { + *self.download_dir_set.lock().expect("not poisoned") = Some(directory); + Ok(()) + } +} + +fn tui(history: Vec) -> MainTui { + with_session(TalkativeSession { + history, + shared: true, + ..TalkativeSession::default() + }) +} + +fn shared(searches: Vec) -> TalkativeSession { + TalkativeSession { + searches: std::sync::Mutex::new(searches), + shared: true, + ..TalkativeSession::default() + } +} + +fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch { + crate::api::SessionSearch { + query: query.to_string(), + files, + started_secs_ago: Some(age), + } +} + +fn with_session(session: TalkativeSession) -> MainTui { + attach(Arc::new(session)) +} + +fn attach(session: Arc) -> MainTui { + MainTui::new( + session, + "/tmp".to_string(), + false, + Duration::from_secs(1), + None, + None, + ) +} + +fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { + soulseek_rs::SearchResult { + token: 0, + files: names + .iter() + .map(|name| soulseek_rs::types::File { + username: "bob".to_string(), + name: (*name).to_string(), + size: 42, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +/// An attached window that selected a two-file search and has already +/// ticked past its one necessary fetch. +fn window_that_pulled_two_files() -> (Arc, MainTui) { + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + ..shared(vec![search_of("aphex twin", 2, 9_999)]) + }); + let mut tui = attach(session.clone()); + tui.state.selected_search_index = Some(0); + tui.update_search_results(); + tui.update_search_results(); + (session, tui) +} + +fn results(n: usize) -> Vec { + (0..n) + .map(|i| crate::models::FileDisplayData { + filename: format!("@@x\\Music\\{i:02}.mp3"), + username: "bob".to_string(), + ..Default::default() + }) + .collect() +} + +fn results_tui(n: usize) -> MainTui { + let mut tui = with_session(TalkativeSession::default()); + tui.state.results_items = results(n); + tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. + tui.state.results_pane_area = + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); + tui +} + +fn press(tui: &mut MainTui, code: KeyCode) { + tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn ctrl(tui: &mut MainTui, ch: char) { + tui.handle_key_event(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::CONTROL, + )); +} + +fn selected(tui: &MainTui) -> Option { + tui.state.results_table_state.selected() +} + +fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("backend"); + terminal.draw(|frame| tui.render(frame)).expect("draw"); + terminal.backend().to_string() +} + +fn screen_of(tui: &mut MainTui) -> String { + screen_sized(tui, 160, 40) +} + +fn mouse( + tui: &mut MainTui, + kind: ratatui::crossterm::event::MouseEventKind, + column: u16, + row: u16, +) { + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }); +} + +fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); + mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); + mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); +} + +/// The screen row carrying a pane's legend, `[2] Results` say, without +/// the quotes the test backend wraps each row in. +fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { + let line = screen + .lines() + .find(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); + let start = line.find('"').map_or(0, |i| i + 1); + let end = line.rfind('"').unwrap_or(line.len()); + &line[start..end] +} + +/// A window with a search, its results, and a transfer, so every pane +/// has something to show. +fn furnished_tui() -> MainTui { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "aphex twin".to_string(), + status: SearchStatus::Completed, + results: results(3), + known_files: 3, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.selected_search_index = Some(0); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "@@x\\Music\\00.mp3"), + receiver: None, + }); + tui +} + +fn browsed(tui: &mut MainTui, files: usize) { + tui.state.browse.open("bob"); + let listing = vec![soulseek_rs::SharedDirectory { + name: "Music".to_string(), + files: (0..files) + .map(|i| soulseek_rs::SharedFileEntry { + name: format!("{i:02}.mp3"), + size: 1, + attributes: Vec::new(), + }) + .collect(), + }]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(tui); +} + +fn in_a_room(tui: &mut MainTui, messages: usize) { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Joined { + room: "jazz".to_string(), + users: (0..30).map(|i| format!("user{i:02}")).collect(), + }, + None, + ); + tui.state.rooms.focus_or_open("jazz"); + for i in 0..messages { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Message { + room: "jazz".to_string(), + username: "alice".to_string(), + message: format!("line {i:03}"), + }, + Some("jazz"), + ); + } + tui.state.show_rooms = true; + let _ = screen_of(tui); +} + +/// The queries the window has sent so far, once at least `count` of them +/// are out: a search goes to the session from a worker thread, so a test +/// has to give it a moment. +fn searched(session: &TalkativeSession, count: usize) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let seen = session.searched.lock().expect("not poisoned").clone(); + if seen.len() >= count || std::time::Instant::now() > deadline { + return seen; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// A window over last session's search list: the queries came back from +/// disk, with nothing under them. +fn restored(session: Arc, queries: &[&str]) -> MainTui { + let mut tui = attach(session); + let queries: Vec = + queries.iter().map(ToString::to_string).collect(); + restore_searches(&mut tui.state, &queries); + tui.state.focused_pane = FocusedPane::Searches; + tui +} diff --git a/soulseek-rs/src/ui/main_tui/tests/navigation.rs b/soulseek-rs/src/ui/main_tui/tests/navigation.rs new file mode 100644 index 0000000..fca9606 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/navigation.rs @@ -0,0 +1,236 @@ +//! Moving through a list: rows, pages, and the sideways scroll that +//! walks a name too long for its column. + +use super::*; + +#[test] +fn end_and_home_jump_to_the_last_and_first_result() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), Some(0)); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(10)); + ctrl(&mut tui, 'f'); + assert_eq!(selected(&tui), Some(20)); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(19)); + ctrl(&mut tui, 'b'); + assert_eq!(selected(&tui), Some(9)); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(0)); + assert!( + !tui.state.show_browse && !tui.state.command_bar_active, + "ctrl-b pages up instead of opening browse" + ); +} + +#[test] +fn ctrl_d_and_ctrl_u_move_half_a_pane() { + let mut tui = results_tui(30); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + ctrl(&mut tui, 'u'); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn navigation_keys_do_nothing_without_results() { + let mut tui = results_tui(0); + tui.state.results_table_state.select(None); + press(&mut tui, KeyCode::End); + press(&mut tui, KeyCode::PageDown); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), None); +} + +#[test] +fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 8); + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.results_name_offset, 16); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 22); + press(&mut tui, KeyCode::Left); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.results_name_offset, 0); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn scrolling_follows_the_highlighted_row_not_the_longest_one() { + let mut tui = results_tui(3); + tui.state.results_items[0].filename = "short.mp3".to_string(); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_items[2].filename = "short.mp3".to_string(); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + tui.state.results_table_state.select(Some(2)); + press(&mut tui, KeyCode::Char('h')); + assert_eq!( + tui.state.results_name_offset, 0, + "a step left lands within the row now highlighted" + ); +} + +#[test] +fn picking_another_search_starts_its_results_unscrolled() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + + tui.state.searches.push(crate::models::SearchEntry { + query: "other".to_string(), + status: SearchStatus::Active, + results: results(2), + known_files: 2, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Enter); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn ctrl_keys_are_ignored_outside_the_results_pane() { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "keep me".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); + ctrl(&mut tui, 'b'); + assert!(!tui.state.command_bar_active, "ctrl-b is not b"); + ctrl(&mut tui, 'q'); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); +} + +#[test] +fn ctrl_keys_page_while_a_filter_is_being_typed() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('m')); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + assert_eq!(tui.state.results_filter_query, "m", "no d appended"); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); +} + +#[test] +fn space_after_a_filter_that_matches_nothing_does_not_panic() { + let mut tui = results_tui(3); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Enter); + assert!(tui.state.results_filtered_items.is_empty()); + press(&mut tui, KeyCode::Char(' ')); + assert!(tui.state.results_selected_indices.is_empty()); +} + +#[test] +fn l_and_h_scroll_a_transfer_name_and_a_query_too() { + let mut tui = furnished_tui(); + tui.state.downloads[0].download.filename = "y".repeat(120); + tui.state.searches[0].query = "z".repeat(60); + let _ = screen_of(&mut tui); + + tui.state.focused_pane = FocusedPane::Downloads; + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.downloads_name_offset, 8); + press(&mut tui, KeyCode::Char('$')); + let end = tui.state.downloads_name_offset; + assert!(end > 8, "the end is past a step: {end}"); + let screen = screen_of(&mut tui); + assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.downloads_name_offset, 0); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.searches_query_offset, 8); + press(&mut tui, KeyCode::End); + assert_eq!( + tui.state.searches_table_state.selected(), + Some(0), + "End is still a row key, $ is the name key" + ); + press(&mut tui, KeyCode::Char('$')); + assert!(tui.state.searches_query_offset > 8); + let screen = screen_of(&mut tui); + assert!(screen.contains("…zzzz"), "{screen}"); + assert!( + tui.state.results_name_offset == 0, + "each list scrolls on its own" + ); +} + +#[test] +fn page_keys_work_in_the_transfers_and_searches_lists_too() { + let mut tui = furnished_tui(); + for i in 0..30 { + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", &format!("{i}.mp3")), + receiver: None, + }); + } + tui.state.focused_pane = FocusedPane::Downloads; + tui.state.downloads_table_state.select(Some(0)); + let _ = screen_of(&mut tui); // lays the panes out, which sets the page size + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); + press(&mut tui, KeyCode::Home); + assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::PageDown); + let page = tui.state.downloads_table_state.selected().expect("row"); + assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); + assert!(tui.state.downloads_table_state.selected() > Some(page)); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('G')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + assert_eq!(tui.state.searches.len(), 1, "g and G only move"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/rooms.rs b/soulseek-rs/src/ui/main_tui/tests/rooms.rs new file mode 100644 index 0000000..4cd7af0 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/rooms.rs @@ -0,0 +1,164 @@ +//! Rooms and conversations: their logs, their lists, and their filters. + +use super::*; + +#[test] +fn slash_and_u_filter_a_rooms_log_and_its_members() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + in_a_room(&mut tui, 30); + press(&mut tui, KeyCode::Char('u')); + for c in "user2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + assert_eq!( + tui.state.rooms.selected_user().as_deref(), + Some("user21"), + "j moves within the matching members" + ); + let screen = screen_of(&mut tui); + assert!(screen.contains("Users 10/30"), "{screen}"); + + press(&mut tui, KeyCode::Char('/')); + for c in "line 01".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("line 010"), "{screen}"); + assert!(!screen.contains("line 029"), "{screen}"); + assert!(screen.contains("filter: line 01_"), "{screen}"); + ctrl(&mut tui, 'u'); + assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.rooms.log_filter.is_empty()); + assert!(tui.state.rooms.user_filter.is_empty()); + assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); + press(&mut tui, KeyCode::Esc); + assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); +} + +#[test] +fn slash_filters_the_open_conversation() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + for text in ["hello there", "see you", "hello again"] { + tui.state.messages.push(crate::models::ChatMessage { + direction: MessageDirection::Incoming, + peer: "bob".to_string(), + text: text.to_string(), + at: chrono::Local::now(), + }); + } + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + press(&mut tui, KeyCode::Char('/')); + for c in "hello".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("hello again"), "{screen}"); + assert!(!screen.contains("see you"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_messages); +} + +#[test] +fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { + let mut tui = with_session(TalkativeSession::default()); + in_a_room(&mut tui, 120); + + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "tails by default: {screen}"); + assert!(!screen.contains("line 000"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("line 119"), "scrolled back: {screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "the oldest: {screen}"); + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "clamped at the top: {screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "{screen}"); + + // The member list keeps its own keys, and ctrl-x is not x. + press(&mut tui, KeyCode::Down); + assert_eq!(tui.state.rooms.user_selected, 1); + ctrl(&mut tui, 'x'); + assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); +} + +#[test] +fn page_keys_move_through_the_room_list() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::List( + (0..60) + .map(|i| soulseek_rs::types::RoomInfo { + name: format!("room{i:02}"), + user_count: 60 - i, + }) + .collect(), + ), + None, + ); + tui.state.show_rooms = true; + let _ = screen_of(&mut tui); + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.rooms.list_selected, 59); + press(&mut tui, KeyCode::PageUp); + assert!(tui.state.rooms.list_selected < 59); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.rooms.list_selected, 0); + ctrl(&mut tui, 'f'); + assert!(tui.state.rooms.list_selected > 1); +} + +#[test] +fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { + let mut tui = tui((0..80) + .map(|i| ChatMessageDto { + peer: "bob".into(), + outgoing: i % 2 == 0, + text: format!("msg {i:03}"), + at: i, + }) + .chain(std::iter::once(ChatMessageDto { + peer: "carol".into(), + outgoing: false, + text: "hello".into(), + at: 100, + })) + .collect()); + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); + press(&mut tui, KeyCode::Char('g')); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 000"), "{screen}"); + + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.active_chat_peer(), Some("carol")); + assert!( + tui.state.chat_view.following(), + "a fresh chat starts at its end" + ); + press(&mut tui, KeyCode::BackTab); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/searches.rs b/soulseek-rs/src/ui/main_tui/tests/searches.rs new file mode 100644 index 0000000..0d40e7f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/searches.rs @@ -0,0 +1,89 @@ +//! Running a search the list already holds. + +use super::*; + +#[test] +fn shift_s_runs_the_highlighted_search_again_in_its_place() { + // The list survives a restart, but a query on it was only ever a + // reminder: seeing what the network has now meant typing it again. + let session = Arc::new(TalkativeSession::default()); + let mut tui = + restored(session.clone(), &["boards of canada", "aphex twin"]); + tui.state.searches_table_state.select(Some(1)); + + press(&mut tui, KeyCode::Char('S')); + + assert_eq!( + searched(&session, 1), + vec!["aphex twin".to_string()], + "the highlighted query went out again, untyped" + ); + let queries: Vec<&str> = tui + .state + .searches + .iter() + .map(|s| s.query.as_str()) + .collect(); + assert_eq!( + queries, + ["boards of canada", "aphex twin"], + "it keeps its row: no duplicate, nothing moved" + ); + assert_eq!(tui.state.searches[1].status, SearchStatus::Active); + assert_eq!(tui.state.selected_search_index, Some(1)); + assert_eq!( + tui.state.focused_pane, + FocusedPane::Results, + "and the window turns to where the results will land" + ); + assert!(tui.state.results_items.is_empty()); +} + +#[test] +fn shift_s_stops_a_run_still_collecting_before_starting_over() { + let mut tui = furnished_tui(); + tui.state.searches[0].status = SearchStatus::Active; + tui.state.searches[0].start_time = std::time::Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("two seconds ago"); + let previous = tui.state.searches[0].cancel_flag.clone(); + tui.state.focused_pane = FocusedPane::Searches; + tui.state.searches_table_state.select(Some(0)); + + press(&mut tui, KeyCode::Char('S')); + + assert!( + previous.load(std::sync::atomic::Ordering::Relaxed), + "the worker that was collecting is told to stop" + ); + let fresh = &tui.state.searches[0]; + assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); + assert!(fresh.results.is_empty(), "stale rows do not linger"); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn shift_s_is_only_a_key_over_the_searches_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + + // Over Results the same key means nothing, so a stray shift does not + // throw a finished search away. + tui.state.focused_pane = FocusedPane::Results; + press(&mut tui, KeyCode::Char('S')); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); + std::thread::sleep(Duration::from_millis(50)); + assert!(searched(&session, 0).is_empty(), "nothing went out"); +} + +#[test] +fn a_held_shift_s_is_one_search_not_one_per_repeat() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + press(&mut tui, KeyCode::Char('S')); + press(&mut tui, KeyCode::Char('1')); + press(&mut tui, KeyCode::Char('S')); + assert_eq!(searched(&session, 1).len(), 1); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/session.rs b/soulseek-rs/src/ui/main_tui/tests/session.rs new file mode 100644 index 0000000..a409630 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/session.rs @@ -0,0 +1,289 @@ +//! A window over a session it did not start: what a daemon's state +//! looks like arriving in a window, and what the window pushes back. + +use super::*; + +#[test] +fn an_attached_session_opens_with_the_conversation_already_in_it() { + // A TUI attached to a daemon has no state file of its own; the + // conversation has to come from the session, or it starts blank while + // the daemon has been collecting messages for hours. + let tui = tui(vec![ + ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: "hi".into(), + at: 1, + }, + ChatMessageDto { + peer: "bob".into(), + outgoing: true, + text: "hello".into(), + at: 2, + }, + ]); + + assert_eq!(tui.state.messages.len(), 2); + assert_eq!(tui.state.messages[0].peer, "bob"); + assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); + assert_eq!( + tui.state.messages[1].direction, + MessageDirection::Outgoing, + "both halves of the conversation, not just what arrived" + ); + assert_eq!(tui.state.chat_peers(), ["bob"]); +} + +#[test] +fn a_window_shows_transfers_it_did_not_start() { + // Two windows on one daemon are two views of one queue. A transfer + // queued in the other one has to appear here, or closing a window + // would look like losing its downloads. + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "@@x\\theirs.mp3")], + shared: true, + ..TalkativeSession::default() + }); + assert!( + tui.state.downloads.is_empty(), + "nothing until the first poll" + ); + + tui.update_downloads(); + + assert_eq!(tui.state.downloads.len(), 1); + assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); + assert!( + tui.state.downloads[0].receiver.is_none(), + "someone else's transfer has no channel of ours" + ); + + // Polling again must not double it up. + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); +} + +#[test] +fn a_transfer_the_session_forgets_leaves_every_window() { + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "gone.mp3")], + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); + + // The session no longer has it — cancelled from the other window. + tui.client = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert!( + tui.state.downloads.is_empty(), + "a queue is shared in both directions" + ); +} + +#[test] +fn a_window_shows_searches_run_in_another() { + let mut tui = + with_session(shared(vec![search_of("their query", 7, 9_999)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches.len(), 1); + assert_eq!(tui.state.searches[0].query, "their query"); + + // And it is not added a second time on the next frame. + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn a_search_another_window_is_still_running_shows_as_active() { + // It had said "Done" the moment it appeared, because an imported + // search carried no notion of still collecting — so the other window + // looked finished while results were still arriving. + let mut tui = with_session(shared(vec![search_of("still going", 3, 0)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches[0].status, SearchStatus::Active); + assert_eq!( + tui.state.searches[0].known_files, 3, + "and its count comes across before the results do" + ); +} + +#[test] +fn a_search_that_has_run_its_window_shows_as_done() { + let mut tui = with_session(shared(vec![search_of("finished", 12, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); +} + +#[test] +fn removing_a_search_removes_it_from_the_session() { + // Removing it only from this window would last until the next sync, + // which would put it straight back — and in a shared session it would + // come back for every other window too. + let mut tui = with_session(shared(vec![search_of("unwanted", 1, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + tui.remove_search_at_index(0); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty(), "and it stays gone"); +} + +#[test] +fn clearing_every_search_clears_them_in_the_session_too() { + let mut tui = with_session(shared(vec![ + search_of("one", 1, 9_999), + search_of("two", 2, 9_999), + ])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 2); + + tui.clear_all_searches(); + tui.update_search_results(); + + assert!( + tui.state.searches.is_empty(), + "clearing has to reach the session, or every row comes back" + ); +} + +#[test] +fn a_search_dismissed_in_another_window_leaves_this_one() { + let session = shared(vec![search_of("theirs", 4, 9_999)]); + let mut tui = with_session(session); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + // The other window dismissed it. + tui.client.forget_search("theirs"); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty()); +} + +#[test] +fn a_tick_asks_the_session_for_its_searches_once() { + // Against a daemon every call is a round trip, and the daemon answers + // it by walking its whole search cache — twice per frame was half the + // cost of attaching at all. + let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); + let mut tui = attach(session.clone()); + + tui.update_search_results(); + + assert_eq!( + session + .all_searches_calls + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "one sync means one listing, not one per use of it" + ); +} + +#[test] +fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { + // The result set only accumulates, so an unchanged count means a + // fetch would hand back what this window already holds — against a + // daemon that is the entire result set over the socket, every frame, + // forever. + let (session, tui) = window_that_pulled_two_files(); + + assert_eq!(tui.state.searches[0].results.len(), 2); + assert_eq!( + session + .result_fetches + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "the set crosses once; after that the unchanged count spares it" + ); +} + +#[test] +fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { + let (session, mut tui) = window_that_pulled_two_files(); + + // A late responder lands in the session. + *session.searches.lock().expect("not poisoned") = + vec![search_of("aphex twin", 3, 9_999)]; + *session.results.lock().expect("not poisoned") = + vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 3, + "a changed count must bring the new results across" + ); +} + +#[test] +fn a_local_window_fetches_results_without_counting_on_the_session() { + // Locally nothing maintains known_files, so the count gate must stay + // out of the way: a search whose count still reads zero has to get + // its results anyway. + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + shared: false, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + tui.state.searches.push(crate::models::SearchEntry { + query: "mine".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 2, + "a zero count matching zero held rows must not read as \ + nothing-to-fetch on a local session" + ); +} + +#[test] +fn a_window_with_its_own_session_is_left_alone() { + // Nothing to share, so nothing is synced — and in particular the + // completed transfers restored from disk, which the session has never + // heard of, are not mistaken for cancelled and deleted. + let mut tui = with_session(TalkativeSession { + downloads: Vec::new(), + shared: false, + ..TalkativeSession::default() + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "from-disk.mp3"), + receiver: None, + }); + + tui.update_downloads(); + + assert_eq!( + tui.state.downloads.len(), + 1, + "a local window's history must survive the tick" + ); +} + +#[test] +fn a_local_session_starts_from_its_own_snapshot_instead() { + // Locally the session knows no history and the state file is the whole + // record, so nothing is invented. + assert!(tui(Vec::new()).state.messages.is_empty()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs new file mode 100644 index 0000000..8f72269 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -0,0 +1,228 @@ +//! The settings popup: the account it names, the folders it applies, +//! and the password and logout it can reach. + +use super::*; + +/// Attached, both folders in the settings form name paths on the +/// *daemon's* machine: they go over the socket exactly as typed, and +/// nothing is created or validated against this machine's filesystem. +#[test] +fn applying_settings_attached_pushes_both_folders_to_the_daemon() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session.clone(), + "/daemon/old".to_string(), + false, + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + let daemon_only = scratch.path().join("only-on-the-daemon"); + let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&daemon_dir); + settings.share_dirs = vec!["/not-here-either".to_string()]; + } + tui.apply_settings(); + + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(daemon_dir.clone()), + "the folder is pushed to the daemon" + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec!["/not-here-either".to_string()]), + "shares go over as typed — the daemon's filesystem judges them" + ); + assert!( + !daemon_only.exists(), + "no directory is created on this machine for a daemon's path" + ); + assert_eq!(tui.download_dir, daemon_dir); + + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config readable"); + assert_eq!( + saved.download_dir.as_deref(), + Some(daemon_dir.as_str()), + "the change still lands in config.toml for the next restart" + ); +} + +/// Locally the same form works on this machine: the folder is created +/// here and share paths that do not exist here are dropped before the +/// session hears about them. +#[test] +fn applying_settings_locally_stays_on_this_machine() { + let session = Arc::new(TalkativeSession { + shared: false, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let share = scratch.path().join("music"); + std::fs::create_dir_all(&share).expect("share dir"); + let mut tui = MainTui::new( + session.clone(), + "/tmp".to_string(), + false, + Duration::from_secs(1), + None, + Some(config_path), + ); + + let downloads = scratch.path().join("downloads"); + let downloads_dir = downloads.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&downloads_dir); + settings.share_dirs = vec![ + share.to_str().expect("utf-8").to_string(), + "/definitely-not-a-real-share".to_string(), + ]; + } + tui.apply_settings(); + + // The seam is told in both modes; a local session accepts and takes + // the directory per download instead of storing it. + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(downloads_dir.clone()) + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec![share.to_str().expect("utf-8").to_string()]), + "paths missing on this machine are dropped before applying" + ); + assert!(downloads.is_dir(), "the folder is created here"); + assert_eq!(tui.download_dir, downloads_dir); +} + +#[test] +fn toggling_username_folders_applies_live_and_persists() { + let session = Arc::new(TalkativeSession::default()); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session, + scratch.path().to_str().expect("utf-8").to_string(), + false, + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + tui.open_settings(); + tui.state.settings.as_mut().expect("open").use_username_dirs = true; + tui.apply_settings(); + + assert!(tui.use_username_dirs, "the next download uses it"); + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config saved"); + assert_eq!(saved.use_username_dirs, Some(true)); +} + +/// The account block answers "who is this session, and what is it +/// offering" without a trip to another screen. +#[test] +fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); +} + +/// Typed twice the same way, the new password reaches the session — and +/// attached to a daemon it is the daemon that stores it. +#[test] +fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); +} + +/// A mistyped repeat is caught here rather than on the server, where the +/// account would be left with a password nobody knows. +#[test] +fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); +} + +/// Logging out ends the window, but as a log-out: the caller brings the +/// login screen back rather than returning to the shell. +#[test] +fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); +} + +/// Attached, the login is the daemon's: there is no logout row to land +/// on, so the row below the password is the download folder. +#[test] +fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/transfers.rs b/soulseek-rs/src/ui/main_tui/tests/transfers.rs new file mode 100644 index 0000000..6957d3c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/transfers.rs @@ -0,0 +1,239 @@ +//! What the transfer lists do when a key lands on a row. + +use super::*; + +#[test] +fn x_cancels_the_highlighted_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "song.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![("bob".to_string(), "song.mp3".to_string())] + ); + assert!( + session + .upload_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the download row cancels no upload" + ); +} + +#[test] +fn shift_c_clears_the_whole_transfer_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "waiting.mp3"), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "carol", + "live.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("dave", "done.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('C')); + + assert!(tui.state.downloads.is_empty(), "every row goes"); + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ], + "live downloads are cancelled; the finished one is not" + ); + assert_eq!( + *session.download_removed.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ("dave".to_string(), "done.mp3".to_string()), + ], + "the session forgets every download, not just this window's rows" + ); + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())], + "a streaming upload is cancelled too" + ); +} + +#[test] +fn d_clears_a_cancelled_row() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "gone.mp3", + soulseek_rs::DownloadStatus::Cancelled, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes"); +} + +#[test] +fn x_on_a_finished_download_asks_nothing() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "a finished download is not cancelled" + ); +} + +#[test] +fn x_on_an_upload_row_still_cancels_the_upload() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(1)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())] + ); + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the upload row cancels no download" + ); +} + +#[test] +fn b_browses_the_user_of_the_highlighted_download() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert!(tui.state.show_browse, "the browse popup opens"); + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("bob"), + "the download's user is the one browsed" + ); + assert!( + !tui.state.command_bar_active, + "b on a transfer goes straight to the user, not the prompt" + ); +} + +#[test] +fn b_on_an_upload_row_browses_that_user() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("alice") + ); +} + +#[test] +fn d_clears_a_finished_row_but_not_a_running_one() { + // Finished transfers are what a list fills up with after a restart, so + // `d` has to be able to dismiss one — restored or not. A running + // transfer is not ours to discard, and stays put. + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes too"); + assert_eq!(tui.state.downloads_table_state.selected(), None); + + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "still.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 2, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + + tui.handle_key_event(key('d')); + + assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); +} diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs new file mode 100644 index 0000000..fd9542f --- /dev/null +++ b/soulseek-rs/src/ui/mod.rs @@ -0,0 +1,13 @@ +mod downloads; +pub mod login; +mod main_tui; +mod paging; +mod panes; +mod styles; +mod utils; + +pub use downloads::render_download_stats; +pub use main_tui::{MainTui, launch_main_tui}; +pub use paging::*; +pub use styles::*; +pub use utils::*; diff --git a/soulseek-rs/src/ui/paging.rs b/soulseek-rs/src/ui/paging.rs new file mode 100644 index 0000000..d91cb4a --- /dev/null +++ b/soulseek-rs/src/ui/paging.rs @@ -0,0 +1,104 @@ +//! How a pane's table shows one page of a list that may run to tens of +//! thousands of rows: which rows those are, and how ratatui is handed just +//! those. Paging and drawing both count rows here, so they agree on what a +//! page is. + +use crate::ui::pane_block; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Table, TableState}, +}; +use std::ops::Range; + +/// Rows a list in `area` shows at once, less the `chrome` rows above it: a +/// table header, a popup's tab bar. At least one, so a page key always +/// moves, even before the first draw. +#[must_use] +pub fn page_of(area: Option, chrome: u16) -> usize { + area.map_or(0, |area| { + usize::from(pane_block(false).inner(area).height.saturating_sub(chrome)) + }) + .max(1) +} + +/// The page a table over `area` shows of `len` rows, `chrome` rows of the +/// area being above them: where the last frame left off, moved only as far +/// as keeps the selection in view. A selection past the end lands on the +/// last row, as the table would have put it. +pub fn page_window( + state: &mut TableState, + len: usize, + area: Rect, + chrome: u16, +) -> Range { + if let Some(selected) = state.selected_mut() { + *selected = (*selected).min(len.saturating_sub(1)); + } + visible_range( + state.offset(), + state.selected(), + len, + page_of(Some(area), chrome), + ) +} + +/// Draw `table`, holding the rows of the page that starts at `start`, with +/// `state` shifted onto that page. The page start is kept for the next +/// frame. +pub fn render_page( + frame: &mut Frame, + table: Table, + area: Rect, + state: &mut TableState, + start: usize, +) { + let mut page_state = TableState::default() + .with_selected(state.selected().map(|selected| selected - start)); + frame.render_stateful_widget(table, area, &mut page_state); + *state.offset_mut() = start; +} + +/// The page a `height`-tall table shows, by ratatui's rule (its +/// `visible_rows` is private): the offset, moved only as far as keeps the +/// selection in view. A selection past the end counts as the last row. +fn visible_range( + offset: usize, + selected: Option, + len: usize, + height: usize, +) -> Range { + let height = height.max(1); + let last = len.saturating_sub(1); + let start = match selected.map(|selected| selected.min(last)) { + Some(selected) => { + offset.clamp((selected + 1).saturating_sub(height), selected) + } + None => offset.min(last), + }; + start..(start + height).min(len) +} + +#[cfg(test)] +mod tests { + use super::{page_window, visible_range}; + use ratatui::{layout::Rect, widgets::TableState}; + + #[test] + fn the_page_follows_the_selection_and_is_never_longer_than_the_pane() { + assert_eq!(visible_range(500, Some(505), 10_000, 10), 500..510); + assert_eq!(visible_range(0, Some(9_999), 10_000, 10), 9_990..10_000); + assert_eq!(visible_range(500, Some(499), 10_000, 10), 499..509); + assert_eq!(visible_range(9_995, None, 10_000, 10), 9_995..10_000); + assert_eq!(visible_range(0, Some(9), 3, 2), 1..3); + } + + #[test] + fn a_window_pulls_a_selection_past_the_end_onto_the_last_row() { + // 6 rows tall: 2 border, 1 header, 3 of the list. + let area = Rect::new(0, 0, 40, 6); + let mut state = TableState::default().with_selected(Some(9)); + assert_eq!(page_window(&mut state, 5, area, 1), 2..5); + assert_eq!(state.selected(), Some(4)); + } +} diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs new file mode 100644 index 0000000..cc95259 --- /dev/null +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -0,0 +1,487 @@ +use crate::models::{BrowseState, BrowseStatus, BrowseTabs, DownloadEntry}; +use crate::ui::{ + HIGHLIGHT_SYMBOL, body_style, dimmed_style, download_status_glyph, + error_style, filter_title, format_bytes, get_spinner_char, highlight_style, + page_window, pane_block, primary_style, render_page, row_highlight_style, + warning_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, +}; +use soulseek_rs::DownloadStatus; +use soulseek_rs::types::Download; +use std::collections::HashMap; + +/// Render the browse popup: a tab bar of browsed users (when more than one is +/// open) above the active user's collapsible shared-file tree. +/// +/// `downloads` is the window's transfer list; a file row whose download is +/// there shows the same status glyph the Downloads pane gives it, and a +/// folder row summarises the downloads of the files queued below it. +pub fn render_browse_pane( + frame: &mut Frame, + area: Rect, + tabs: &BrowseTabs, + table_state: &mut TableState, + spinner_state: usize, + downloads: &[DownloadEntry], +) { + let Some(active) = tabs.active_tab() else { + return; + }; + + // With multiple users open, reserve the top row for a tab bar. + let tree_area = if tabs.tabs.len() > 1 { + let chunks = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]) + .split(area); + render_browse_tabs(frame, chunks[0], tabs); + chunks[1] + } else { + area + }; + + render_browse_one( + frame, + tree_area, + active, + table_state, + spinner_state, + downloads, + ); +} + +fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { + let mut spans: Vec = Vec::new(); + for (i, tab) in tabs.tabs.iter().enumerate() { + let style = if i == tabs.active { + highlight_style().add_modifier(Modifier::BOLD) + } else { + dimmed_style() + }; + let you = if tab.own { " (you)" } else { "" }; + spans.push(Span::styled(format!(" {}{you} ", tab.username), style)); + spans.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Render one browsed user's shared-file tree. +fn render_browse_one( + frame: &mut Frame, + area: Rect, + browse: &BrowseState, + table_state: &mut TableState, + spinner_state: usize, + downloads: &[DownloadEntry], +) { + // Our own index is not downloadable and re-scans instead of re-asking, + // so it names itself and offers its own keys. + let heading = if browse.own { + "My shares".to_string() + } else { + format!("Browse {}", browse.username) + }; + let title = match browse.status { + BrowseStatus::Loaded => { + let keys = if browse.own { + "/: filter, r: re-index, o: folders, w: close, Esc: hide" + } else { + "Enter/d: download, /: filter, Tab: user, w: close, Esc: hide" + }; + let shown = + browse.rows().iter().filter(|row| !row.is_folder).count(); + filter_title( + &format!("{heading} — {shown}/{} files", browse.file_count), + browse.filter(), + browse.filtering, + &format!( + " {heading} — {} files, {} folders ({keys}) ", + browse.file_count, browse.folder_count + ), + ) + } + _ => format!(" {heading} "), + }; + let block = pane_block(true).title(title); + + match browse.status { + BrowseStatus::Loading => { + let text = format!( + "{} Requesting shared files from {}…", + get_spinner_char(spinner_state), + browse.username + ); + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } + BrowseStatus::Empty => { + let text = if browse.own { + "You are not sharing any files. Press o to add a folder." + .to_string() + } else { + format!("{} is not sharing any files.", browse.username) + }; + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } + BrowseStatus::TimedOut => { + let text = vec![ + Line::styled( + format!("Couldn't reach {}.", browse.username), + error_style(), + ), + Line::raw(""), + Line::styled( + "They may be offline, or their connection can't be \ + reached (both of you may be behind a router/firewall).", + body_style(), + ), + Line::styled("Press r to try again.", dimmed_style()), + ]; + frame.render_widget(Paragraph::new(text).block(block), area); + } + BrowseStatus::Loaded + if browse.rows().is_empty() && !browse.filter().is_empty() => + { + let text = format!("Nothing here matches '{}'.", browse.filter()); + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } + BrowseStatus::Loaded => { + let all = browse.rows(); + // Queued files carry the download's full share path, so a row + // matches a transfer of this user by path alone. + let transfers: Vec<&Download> = downloads + .iter() + .filter(|entry| entry.download.username == browse.username) + .map(|entry| &entry.download) + .collect(); + let status_of: HashMap<&str, &DownloadStatus> = transfers + .iter() + .map(|download| (download.filename.as_str(), &download.status)) + .collect(); + table_state.select(Some(browse.selected_row)); + let window = page_window(table_state, all.len(), area, 0); + let start = window.start; + let rows: Vec = all[window] + .iter() + .map(|row| { + let indent = " ".repeat(row.depth); + let (label, size) = if row.is_folder { + // A folder's download is its files' downloads, so + // the folder reports the one status that describes + // the files queued below it. + let below: Vec<&Download> = transfers + .iter() + .copied() + .filter(|d| under_folder(&d.filename, &row.path)) + .collect(); + let transfer = folder_status(&below); + let marker = transfer.map_or_else( + || Span::raw(""), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let open = if row.expanded { "▾" } else { "▸" }; + let size = if matches!( + transfer, + Some(DownloadStatus::InProgress { .. }) + ) { + format!("{}%", folder_percent(&below)) + } else { + String::new() + }; + ( + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled( + format!("{open} {}", row.name), + primary_style() + .add_modifier(Modifier::BOLD), + ), + ])), + Cell::from(size).style(warning_style()), + ) + } else { + let status = status_of.get(row.path.as_str()).copied(); + // The two spaces before a file's name become the + // transfer's glyph while its download lives in the + // list: ⋯ queued, ↯ active, ⏸ paused, ✓ done. + let marker = status.map_or_else( + || Span::raw(" "), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let plain_size = + row.size.map(format_bytes).unwrap_or_default(); + let size_text = match status { + Some(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + }) if *total_bytes > 0 => { + let percent = + bytes_downloaded * 100 / total_bytes; + format!("{plain_size} {percent}%") + } + _ => plain_size, + }; + ( + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled(row.name.clone(), body_style()), + ])), + Cell::from(size_text).style(warning_style()), + ) + }; + Row::new(vec![label, size]) + }) + .collect(); + + let table = + Table::new(rows, [Constraint::Fill(1), Constraint::Length(12)]) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(block); + + render_page(frame, table, area, table_state, start); + } + } +} + +/// Whether `file` is one of the folder's own files in a share path. +fn under_folder(file: &str, folder: &str) -> bool { + file.strip_prefix(folder) + .is_some_and(|rest| rest.starts_with('\\')) +} + +/// How loudly a status speaks for a whole folder: moving work first, +/// then paused, then queued, then a failure worth surfacing; done only +/// says so once every file is. +const fn status_rank(status: &DownloadStatus) -> u8 { + match status { + DownloadStatus::InProgress { .. } => 5, + DownloadStatus::Paused { .. } => 4, + DownloadStatus::Queued => 3, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut => 2, + DownloadStatus::Cancelled => 1, + DownloadStatus::Completed => 0, + } +} + +/// The status that describes a folder's download: the loudest of its +/// files', or nothing while none of them is in the transfer list. +fn folder_status<'a>(files: &[&'a Download]) -> Option<&'a DownloadStatus> { + files + .iter() + .map(|download| &download.status) + .max_by_key(|status| status_rank(status)) +} + +/// How far a folder's queued bytes have come, whole percents. +fn folder_percent(files: &[&Download]) -> u8 { + let bytes: u64 = files.iter().map(|d| d.bytes_downloaded()).sum(); + let total: u64 = files.iter().map(|d| d.size).sum(); + bytes.saturating_mul(100).checked_div(total).unwrap_or(0) as u8 +} + +#[cfg(test)] +mod tests { + use super::render_browse_pane; + use crate::models::{BrowseState, BrowseTabs, DownloadEntry}; + use crate::ui::{GLYPH_ACTIVE, GLYPH_DONE}; + use ratatui::{Terminal, backend::TestBackend, widgets::TableState}; + use soulseek_rs::types::{Download, DownloadMetadata, DownloadStatus}; + use soulseek_rs::{SharedDirectory, SharedFileEntry}; + use std::sync::mpsc; + + fn share() -> SharedDirectory { + SharedDirectory { + name: "share".to_string(), + files: ["active.mp3", "done.mp3", "plain.mp3"] + .iter() + .map(|name| SharedFileEntry { + name: (*name).to_string(), + size: 4096, + attributes: Vec::new(), + }) + .collect(), + } + } + + fn download(filename: &str, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "bob".to_string(), + // Queued files carry the full share path, as in the tree. + filename: format!("share\\{filename}"), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + /// One tab drawn onto a `width`-wide buffer. + fn screen( + browse: BrowseState, + downloads: &[DownloadEntry], + width: u16, + ) -> String { + let mut tabs = BrowseTabs::default(); + tabs.tabs.push(browse); + let mut state = TableState::default(); + let mut terminal = + Terminal::new(TestBackend::new(width, 8)).expect("backend"); + terminal + .draw(|frame| { + render_browse_pane( + frame, + frame.area(), + &tabs, + &mut state, + 0, + downloads, + ); + }) + .expect("draw"); + terminal.backend().to_string() + } + + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + screen(browse, downloads, 60) + } + + #[test] + fn a_downloading_file_carries_its_transfer_glyph() { + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 2048, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} active.mp3")), + "{screen}" + ); + assert!( + screen.contains(&format!("{GLYPH_DONE} done.mp3")), + "{screen}" + ); + assert!( + screen.contains("50%"), + "the size cell tells the progress: {screen}" + ); + assert!( + screen.contains(" plain.mp3"), + "an unqueued row keeps the two plain spaces: {screen}" + ); + } + + #[test] + fn a_folder_marks_with_its_files_download() { + // One file moving, one done: the folder row reports the job with + // the loudest status below it, and the bytes' percent beside it. + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 1024, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} ▾ share")), + "{screen}" + ); + // 1024 of the 8192 bytes the two files hold has moved — the done + // one brings its own 4096 — so the folder sits at 62%. + assert!(screen.contains("62%"), "{screen}"); + } + + #[test] + fn a_folder_is_done_when_every_file_is() { + let screen = screen_with(&[ + download("active.mp3", DownloadStatus::Completed), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_DONE} ▾ share")), + "{screen}" + ); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + } + + /// The own tab, rendered with `directories` as its index. Wider than the + /// peer screens because the title carries the keys it offers. + fn own_screen(directories: &[SharedDirectory]) -> String { + let mut browse = BrowseState::loading_own("tester".to_string()); + browse.load(directories); + screen(browse, &[], 90) + } + + #[test] + fn the_own_tab_names_itself_and_offers_no_download() { + let screen = own_screen(&[share()]); + assert!(screen.contains("My shares"), "{screen}"); + assert!(!screen.contains("Browse tester"), "{screen}"); + assert!(screen.contains("re-index"), "r re-scans instead: {screen}"); + assert!( + !screen.contains("download"), + "nothing here is downloadable from ourselves: {screen}" + ); + assert!(screen.contains("plain.mp3"), "the tree still shows"); + } + + #[test] + fn sharing_nothing_says_how_to_start() { + let screen = own_screen(&[]); + assert!(screen.contains("not sharing any files"), "{screen}"); + assert!(screen.contains("Press o to add a folder"), "{screen}"); + } + + #[test] + fn another_user_s_transfers_mark_nothing() { + let mut theirs = download("active.mp3", DownloadStatus::Completed); + theirs.download.username = "carol".to_string(); + let screen = screen_with(&[theirs]); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + assert!(!screen.contains(GLYPH_DONE), "{screen}"); + } +} diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs new file mode 100644 index 0000000..92b716b --- /dev/null +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -0,0 +1,155 @@ +use crate::models::{AppState, MessageDirection, contains_filter}; +use crate::ui::{ + accent_style, border_style, dimmed_style, filter_title, highlight_style, + info_style, primary_style, wrap_chat_line, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Paragraph}, +}; + +/// Render the private-message chat box: title is the conversation partner, +/// messages left, the list of people you have chatted with on the right, and +/// the compose line pinned to the bottom. `own_username` labels our own +/// messages. +pub fn render_chat_pane( + frame: &mut Frame, + area: Rect, + state: &mut AppState, + own_username: &str, +) { + let peer = state.active_chat_peer().map(str::to_string); + let peer = peer.as_deref(); + let title = peer.map_or_else( + || " Messages (m: compose, i/Esc: close) ".to_string(), + |peer| { + filter_title( + peer, + &state.chat_filter, + state.chat_filtering, + &format!( + " {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) " + ), + ) + }, + ); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(BorderType::Rounded) + .title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]) + .split(inner); + + // Body: messages (left) + conversation list (right). + let body = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) + .split(chunks[0]); + render_peers(frame, body[1], state, peer); + + let Some(peer) = peer else { + frame.render_widget( + Paragraph::new("No messages yet. Press 'm' to send one.") + .style(dimmed_style()), + body[0], + ); + return; + }; + + render_messages(frame, body[0], state, peer, own_username); + render_compose(frame, chunks[1], state); +} + +fn render_peers( + frame: &mut Frame, + area: Rect, + state: &AppState, + active: Option<&str>, +) { + let peers = state.chat_peers(); + let block = Block::default() + .borders(Borders::LEFT) + .border_style(dimmed_style()) + .title(format!(" Chats ({}) ", peers.len())); + let inner = block.inner(area); + frame.render_widget(block, area); + + let height = inner.height as usize; + let selected = active + .and_then(|active| peers.iter().position(|p| *p == active)) + .unwrap_or(0); + // Keep the active conversation in view by scrolling the window to it. + let start = selected.saturating_sub(height.saturating_sub(1)); + let shown: Vec = peers + .iter() + .enumerate() + .skip(start) + .take(height) + .map(|(i, peer)| { + if i == selected { + Line::from(Span::styled(format!("▸ {peer}"), highlight_style())) + } else { + Line::from(Span::styled(format!(" {peer}"), primary_style())) + } + }) + .collect(); + frame.render_widget(Paragraph::new(shown), inner); +} + +fn render_messages( + frame: &mut Frame, + area: Rect, + state: &mut AppState, + peer: &str, + own_username: &str, +) { + let AppState { + messages, + chat_view: view, + chat_wrapped: wrapped, + chat_filter: filter, + .. + } = state; + let width = area.width as usize; + let key = format!("{peer}\n{filter}"); + let lines = wrapped.rows(width, &key, messages, |m| { + if m.peer != peer || !contains_filter(&m.text, filter) { + return Vec::new(); + } + let (sender, sender_style) = match m.direction { + MessageDirection::Incoming => (peer, info_style()), + MessageDirection::Outgoing => (own_username, accent_style()), + }; + wrap_chat_line( + vec![ + Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), + Span::styled(format!("<{sender}> "), sender_style), + ], + &m.text, + primary_style(), + width, + ) + }); + + // The newest rows, unless the reader is holding a place in the history. + let window = view.window(lines.len(), usize::from(area.height)); + frame.render_widget(Paragraph::new(lines[window].to_vec()), area); +} + +fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) { + let line = if state.chat_composing { + Line::from(vec![ + Span::styled("› ", accent_style()), + Span::styled(state.chat_input.clone(), primary_style()), + Span::styled("▏", accent_style()), + ]) + } else { + Line::from(Span::styled("Enter: type a message", dimmed_style())) + }; + frame.render_widget(Paragraph::new(line), area); +} diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs new file mode 100644 index 0000000..d90c480 --- /dev/null +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -0,0 +1,494 @@ +use crate::models::{DownloadEntry, FileDisplayData}; +use crate::ui::{ + accent_style, dimmed_style, error_style, format_bytes, format_progress_bar, + format_speed, inactive_style, info_style, pane_block, pane_title, + primary_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Paragraph, Wrap}, +}; +use soulseek_rs::DownloadStatus; +use soulseek_rs::types::{UploadInfo, UploadStatus}; +use soulseek_rs::utils::path::expand_tilde; + +const LABEL_WIDTH: usize = 20; + +pub enum InfoSubject<'a> { + Download(&'a DownloadEntry), + Upload(&'a UploadInfo), + Result(&'a FileDisplayData), +} + +/// Resolve the transfers table's selected row index into the download or +/// upload it points at, matching the order [`render_downloads_pane`] renders. +#[must_use] +pub fn selected_transfer<'a>( + index: Option, + downloads: &'a [DownloadEntry], + uploads: &'a [UploadInfo], +) -> Option> { + let index = index?; + if let Some(download) = downloads.get(index) { + return Some(InfoSubject::Download(download)); + } + // Past the downloads, so this row is an upload. + uploads + .get(index - downloads.len()) + .map(InfoSubject::Upload) +} + +pub fn render_download_info_pane( + frame: &mut Frame, + area: Rect, + selected: Option, + focused: bool, +) { + let block = pane_block(focused).title(pane_title("Info", "", focused)); + + let Some(selected) = selected else { + let paragraph = Paragraph::new(Line::from(Span::styled( + "Select a result or transfer for details.", + dimmed_style(), + ))) + .block(block); + frame.render_widget(paragraph, area); + return; + }; + + let lines = match selected { + InfoSubject::Download(entry) => build_info_lines(&entry.download), + InfoSubject::Upload(upload) => build_upload_info_lines(upload), + InfoSubject::Result(file) => build_result_info_lines(file), + }; + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +/// Details for a file we are serving to a peer, laid out like the download +/// view so the pane reads the same whichever direction is selected. +fn build_upload_info_lines(upload: &UploadInfo) -> Vec> { + let mut lines: Vec> = Vec::new(); + push_file_header(&mut lines, &upload.filename, primary_style()); + + // The pane is shared with downloads, so name the direction explicitly. + lines.push(label_value("Direction", "↑ Upload")); + lines.push(label_value("To user", &upload.username)); + lines.push(label_value("Size", &format_bytes(upload.size))); + + let (status_text, status_style) = match &upload.status { + UploadStatus::Queued(place) => { + (format!("Queued (#{place})"), inactive_style()) + } + UploadStatus::InProgress => { + ("In progress".to_string(), warning_style()) + } + UploadStatus::Completed => ("Completed".to_string(), success_style()), + UploadStatus::Cancelled => ("Cancelled".to_string(), inactive_style()), + UploadStatus::Failed(_) => ("Failed".to_string(), error_style()), + }; + lines.push(label_value_styled("Status", status_text, status_style)); + + match &upload.status { + UploadStatus::InProgress => { + lines.push(Line::from("")); + push_transfer_progress( + &mut lines, + "Sent", + upload.bytes_sent, + upload.size, + ); + lines.push(label_value( + "Speed", + &format_speed(upload.speed_bytes_per_sec), + )); + + if upload.speed_bytes_per_sec > 0.0 + && upload.size > upload.bytes_sent + { + let remaining = upload.size - upload.bytes_sent; + let eta_secs = + (remaining as f64 / upload.speed_bytes_per_sec) as u32; + lines.push(label_value("ETA", &format_duration(eta_secs))); + } + } + UploadStatus::Failed(reason) => { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{: {} + } + + lines +} + +fn build_result_info_lines(file: &FileDisplayData) -> Vec> { + let mut lines: Vec> = Vec::new(); + push_file_header(&mut lines, &file.filename, accent_style()); + + lines.push(label_value_styled( + "User", + file.username.clone(), + info_style(), + )); + lines.push(label_value_styled( + "Size", + format_bytes(file.size), + warning_style(), + )); + push_media_attributes(&mut lines, file.bitrate, file.length_seconds); + lines.push(Line::from("")); + + lines.push(free_slots_line(file.slots)); + lines.push(label_value( + "Upload speed", + &format_speed(f64::from(file.speed)), + )); + + lines +} + +fn push_file_header( + lines: &mut Vec>, + filename: &str, + style: Style, +) { + let (basename, parent_dir) = split_filename(filename); + lines.push(Line::from(Span::styled( + basename, + style.add_modifier(ratatui::style::Modifier::BOLD), + ))); + if !parent_dir.is_empty() { + lines.push(Line::from(Span::styled(parent_dir, dimmed_style()))); + } + lines.push(Line::from("")); +} + +fn push_media_attributes( + lines: &mut Vec>, + bitrate: Option, + length_seconds: Option, +) { + if let Some(bitrate) = bitrate { + lines.push(label_value("Bitrate", &format!("{bitrate} kbps"))); + } + if let Some(length) = length_seconds { + lines.push(label_value("Length", &format_duration(length))); + } +} + +fn free_slots_line(slots: u8) -> Line<'static> { + let (slot_text, slot_style) = if slots > 0 { + (format!("{slots} available"), success_style()) + } else { + ("all busy".to_string(), inactive_style()) + }; + label_value_styled("Free slots", slot_text, slot_style) +} + +fn build_info_lines( + download: &soulseek_rs::types::Download, +) -> Vec> { + let mut lines: Vec> = Vec::new(); + push_file_header(&mut lines, &download.filename, accent_style()); + + lines.push(label_value_styled( + "User", + download.username.clone(), + info_style(), + )); + lines.push(label_value_styled( + "Size", + format_bytes(download.size), + warning_style(), + )); + + let (status_text, status_style) = match &download.status { + DownloadStatus::Queued => ("Queued".to_string(), inactive_style()), + DownloadStatus::InProgress { .. } => { + ("In progress".to_string(), warning_style()) + } + DownloadStatus::Paused { .. } => ("Paused".to_string(), info_style()), + DownloadStatus::Completed => ("Completed".to_string(), success_style()), + DownloadStatus::Cancelled => { + ("Cancelled".to_string(), inactive_style()) + } + DownloadStatus::Failed(_) => ("Failed".to_string(), error_style()), + DownloadStatus::TimedOut => ("Timed out".to_string(), error_style()), + }; + lines.push(label_value_styled("Status", status_text, status_style)); + + let save_path = expand_tilde(&download.download_directory) + .to_string_lossy() + .to_string(); + lines.push(label_value_styled("Save to", save_path, info_style())); + push_media_attributes( + &mut lines, + download.metadata.bitrate, + download.metadata.length_seconds, + ); + + match &download.status { + DownloadStatus::Queued => { + lines.push(Line::from("")); + let position_text = match download.queue_position { + Some(p) => format!("#{p}"), + None => "unknown".to_string(), + }; + lines.push(label_value("Queue position", &position_text)); + + if let Some(slots) = download.metadata.peer_free_slots { + lines.push(free_slots_line(slots)); + } + + if let Some(speed) = download.metadata.peer_upload_speed { + lines.push(label_value( + "Upload speed", + &format_speed(f64::from(speed)), + )); + } + } + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => { + lines.push(Line::from("")); + push_transfer_progress( + &mut lines, + "Downloaded", + *bytes_downloaded, + *total_bytes, + ); + lines.push(label_value( + "Speed", + &format_speed(*speed_bytes_per_sec), + )); + + if *speed_bytes_per_sec > 0.0 && *total_bytes > *bytes_downloaded { + let remaining = *total_bytes - *bytes_downloaded; + let eta_secs = (remaining as f64 / speed_bytes_per_sec) as u32; + lines.push(label_value("ETA", &format_duration(eta_secs))); + } + } + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => { + lines.push(Line::from("")); + push_transfer_progress( + &mut lines, + "Downloaded", + *bytes_downloaded, + *total_bytes, + ); + } + DownloadStatus::Failed(reason) => { + if let Some(reason) = reason { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{: {} + } + + lines +} + +fn label_value(label: &str, value: &str) -> Line<'static> { + label_value_styled(label, value.to_string(), primary_style()) +} + +fn label_value_styled( + label: &str, + value: String, + value_style: Style, +) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label: / ` line, shared by the +/// download ("Downloaded") and upload ("Sent") views. +fn push_transfer_progress( + lines: &mut Vec>, + label: &str, + transferred: u64, + total_bytes: u64, +) { + let (progress, percent) = if total_bytes > 0 { + let ratio = transferred as f64 / total_bytes as f64; + (ratio, (ratio * 100.0) as u8) + } else { + (0.0, 0) + }; + lines.push(format_progress_bar(progress, 20, percent)); + lines.push(label_value( + label, + &format!( + "{} / {}", + format_bytes(transferred), + format_bytes(total_bytes) + ), + )); +} + +fn split_filename(path: &str) -> (String, String) { + let normalized = path.replace('\\', "/"); + if let Some(idx) = normalized.rfind('/') { + let (parent, basename) = normalized.split_at(idx); + ( + basename.trim_start_matches('/').to_string(), + parent.to_string(), + ) + } else { + (normalized, String::new()) + } +} + +fn format_duration(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + if hours > 0 { + format!("{hours}h {minutes:02}m {secs:02}s") + } else if minutes > 0 { + format!("{minutes}m {secs:02}s") + } else { + format!("{secs}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_seconds_only() { + assert_eq!(format_duration(0), "0s"); + assert_eq!(format_duration(45), "45s"); + } + + #[test] + fn format_duration_minutes_and_seconds() { + assert_eq!(format_duration(60), "1m 00s"); + assert_eq!(format_duration(125), "2m 05s"); + } + + #[test] + fn format_duration_hours_minutes_seconds() { + assert_eq!(format_duration(3600), "1h 00m 00s"); + assert_eq!(format_duration(3725), "1h 02m 05s"); + } + + #[test] + fn split_filename_with_backslash_path() { + let (basename, parent) = + split_filename("@@drive\\Music\\Album\\track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, "@@drive/Music/Album"); + } + + #[test] + fn split_filename_with_forward_slash_path() { + let (basename, parent) = split_filename("/home/user/track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, "/home/user"); + } + + #[test] + fn split_filename_with_no_separator() { + let (basename, parent) = split_filename("track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, ""); + } + + fn download_with_status( + status: DownloadStatus, + ) -> soulseek_rs::types::Download { + let (sender, _rx) = std::sync::mpsc::channel(); + soulseek_rs::types::Download { + username: "peer".to_string(), + filename: "song.mp3".to_string(), + token: 1, + size: 100, + download_directory: "~/dl".to_string(), + status, + sender, + queue_position: None, + metadata: soulseek_rs::types::DownloadMetadata::default(), + } + } + + fn lines_to_text(lines: &[Line<'static>]) -> String { + lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn a_search_result_lists_its_path_owner_and_attributes() { + let file = crate::models::FileDisplayData { + filename: "@@x\\Music\\Album\\track.mp3".to_string(), + size: 2048, + username: "bob".to_string(), + speed: 1_000_000, + slots: 2, + bitrate: Some(320), + length_seconds: Some(125), + }; + let text = lines_to_text(&build_result_info_lines(&file)); + assert!(text.starts_with("track.mp3\n@@x/Music/Album\n"), "{text}"); + for needle in ["bob", "320 kbps", "2m 05s", "2 available"] { + assert!(text.contains(needle), "missing {needle}: {text}"); + } + } + + #[test] + fn failed_download_renders_its_error_reason() { + let download = download_with_status(DownloadStatus::Failed(Some( + "Peer disconnected".to_string(), + ))); + let text = lines_to_text(&build_info_lines(&download)); + assert!(text.contains("Error"), "missing Error label: {text}"); + assert!(text.contains("Peer disconnected"), "missing reason: {text}"); + } + + #[test] + fn failed_download_without_reason_shows_no_error_line() { + let download = download_with_status(DownloadStatus::Failed(None)); + let text = lines_to_text(&build_info_lines(&download)); + assert!(text.contains("Failed"), "status should still show Failed"); + assert!(!text.contains("Error"), "unexpected Error line: {text}"); + } +} diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs new file mode 100644 index 0000000..98a7878 --- /dev/null +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -0,0 +1,225 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::models::DownloadEntry; +use crate::ui::{ + GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, + accent_style, body_style, dimmed_style, download_status_glyph, error_style, + format_bytes, format_speed, header_style, inactive_style, info_style, + page_window, pane_block, pane_title, primary_style, render_page, + row_highlight_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, +}; +use soulseek_rs::DownloadStatus; +use soulseek_rs::types::{UploadInfo, UploadStatus}; + +// Sized to the widest value each column shows in ordinary use — a +// three-digit megabyte count on both sides of the progress — so the file +// name keeps whatever width the pane has beyond that. +const WIDTHS: [Constraint; 5] = [ + Constraint::Length(6), + Constraint::Fill(1), + Constraint::Length(12), + Constraint::Length(25), + Constraint::Length(10), +]; + +/// Filename is the second column. +const NAME_COLUMN: usize = 1; + +/// The offset that brings the end of a transfer's name into view. +#[must_use] +pub fn name_end_offset(name: &str, area: Rect) -> usize { + end_offset(name, area, &WIDTHS, NAME_COLUMN) +} + +/// What the list shows for an upload: the peer-facing path is ours, so only +/// the file's own name is worth a column. +#[must_use] +pub fn upload_display_name(upload: &UploadInfo) -> &str { + upload + .filename + .rsplit(['\\', '/']) + .next() + .unwrap_or(&upload.filename) +} + +pub fn render_downloads_pane( + frame: &mut Frame, + area: Rect, + downloads: &[DownloadEntry], + uploads: &[UploadInfo], + table_state: &mut TableState, + focused: bool, + name_offset: usize, +) { + if downloads.is_empty() && uploads.is_empty() { + let empty_block = pane_block(focused).title(pane_title( + "3", + "Downloads/Uploads", + focused, + )); + + let paragraph = Paragraph::new( + "No transfers. Select files in [2] Results and press Enter.", + ) + .style(dimmed_style()) + .block(empty_block); + frame.render_widget(paragraph, area); + return; + } + + let header = Row::new(vec![ + Cell::from("Status").style(header_style()), + Cell::from("Filename").style(header_style()), + Cell::from("User").style(header_style()), + Cell::from("Progress").style(header_style()), + Cell::from("Speed").style(header_style()), + ]) + .height(1); + + let name_width = column_width(area, &WIDTHS, NAME_COLUMN); + // Downloads come first, uploads after; one page of the two together. + let total = downloads.len() + uploads.len(); + let window = page_window(table_state, total, area, 1); + let start = window.start; + let split = downloads.len(); + let shown_downloads = &downloads[start.min(split)..window.end.min(split)]; + let shown_uploads = + &uploads[start.saturating_sub(split)..window.end.saturating_sub(split)]; + let mut rows: Vec = shown_downloads + .iter() + .map(|download_entry| { + let download = &download_entry.download; + let (status_icon, status_style) = + download_status_glyph(&download.status); + + let progress_text = match &download.status { + DownloadStatus::Queued => "Queued".to_string(), + DownloadStatus::InProgress { .. } + | DownloadStatus::Paused { .. } => { + let prefix = if matches!( + download.status, + DownloadStatus::Paused { .. } + ) { + "Paused " + } else { + "" + }; + let percent = if download.size > 0 { + (download.bytes_downloaded() as f64 + / download.size as f64 + * 100.0) as u8 + } else { + 0 + }; + format!( + "{prefix}{}/{} ({}%)", + format_bytes(download.bytes_downloaded()), + format_bytes(download.size), + percent + ) + } + DownloadStatus::Completed => "Completed".to_string(), + DownloadStatus::Cancelled => "Cancelled".to_string(), + DownloadStatus::Failed(_) => "Failed".to_string(), + DownloadStatus::TimedOut => "Timed out".to_string(), + }; + + let speed_text = match &download.status { + DownloadStatus::InProgress { .. } => { + format_speed(download.speed_bytes_per_sec()) + } + _ => "-".to_string(), + }; + + let progress_style = match &download.status { + DownloadStatus::Completed => success_style(), + DownloadStatus::Failed(_) | DownloadStatus::TimedOut => { + error_style() + } + DownloadStatus::Queued | DownloadStatus::Cancelled => { + inactive_style() + } + _ => primary_style(), + }; + + Row::new(vec![ + Cell::from(status_icon).style(status_style), + Cell::from(scroll_text( + &download.filename, + name_offset, + name_width, + )) + .style(body_style()), + Cell::from(download.username.clone()).style(info_style()), + Cell::from(progress_text).style(progress_style), + Cell::from(speed_text).style(warning_style()), + ]) + }) + .collect(); + + rows.extend(shown_uploads.iter().map(|upload| { + let (status_icon, status_style) = match &upload.status { + UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()), + UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), + UploadStatus::Completed => (GLYPH_DONE, success_style()), + UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()), + UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()), + }; + let progress_text = match &upload.status { + UploadStatus::Queued(place) => format!("Queued #{place}"), + UploadStatus::InProgress => { + let percent = if upload.size > 0 { + (upload.bytes_sent as f64 / upload.size as f64 * 100.0) + as u8 + } else { + 0 + }; + format!( + "{}/{} ({}%)", + format_bytes(upload.bytes_sent), + format_bytes(upload.size), + percent + ) + } + UploadStatus::Completed => "Completed".to_string(), + UploadStatus::Cancelled => "Cancelled".to_string(), + UploadStatus::Failed(_) => "Failed".to_string(), + }; + // Same rule as a download: a rate only while the transfer is running. + let speed_text = match &upload.status { + UploadStatus::InProgress => { + format_speed(upload.speed_bytes_per_sec) + } + _ => "-".to_string(), + }; + Row::new(vec![ + Cell::from(format!("↑ {status_icon}")).style(status_style), + Cell::from(scroll_text( + upload_display_name(upload), + name_offset, + name_width, + )) + .style(body_style()), + Cell::from(upload.username.clone()).style(info_style()), + Cell::from(progress_text).style(primary_style()), + Cell::from(speed_text).style(warning_style()), + ]) + })); + + let table = Table::new(rows, WIDTHS) + .header(header) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(pane_block(focused).title(pane_title( + "3", + "Downloads/Uploads", + focused, + ))); + + render_page(frame, table, area, table_state, start); +} diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs new file mode 100644 index 0000000..357df0a --- /dev/null +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -0,0 +1,23 @@ +mod browse_pane; +mod chat_pane; +mod download_info_pane; +mod downloads_pane; +mod name_scroll; +mod results_pane; +mod rooms_pane; +mod searches_pane; + +pub use browse_pane::render_browse_pane; +pub use chat_pane::render_chat_pane; +pub use download_info_pane::{ + InfoSubject, render_download_info_pane, selected_transfer, +}; +pub use downloads_pane::{ + name_end_offset as transfer_name_end_offset, render_downloads_pane, + upload_display_name, +}; +pub use results_pane::{ + ResultsPaneParams, name_end_offset, render_results_pane, +}; +pub use rooms_pane::render_rooms_pane; +pub use searches_pane::{query_end_offset, render_searches_pane}; diff --git a/soulseek-rs/src/ui/panes/name_scroll.rs b/soulseek-rs/src/ui/panes/name_scroll.rs new file mode 100644 index 0000000..1cd2b4f --- /dev/null +++ b/soulseek-rs/src/ui/panes/name_scroll.rs @@ -0,0 +1,126 @@ +//! Sideways scrolling for a table column that holds text wider than it is: +//! a file name, a query. Every list scrolls the same way, so the arithmetic +//! lives here and each pane only says which of its columns it applies to. + +use crate::ui::{HIGHLIGHT_SYMBOL, pane_block}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Cells column `column` of a table laid out with `widths` gets inside a pane +/// drawn over `area`: what the pane keeps for its border, padding and the +/// highlight symbol comes off first. +#[must_use] +pub fn column_width(area: Rect, widths: &[Constraint], column: usize) -> usize { + let inner = pane_block(false).inner(area); + let symbol = HIGHLIGHT_SYMBOL.width() as u16; + let [_, columns] = + Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)]) + .areas(inner); + usize::from( + Layout::horizontal(widths).spacing(1).split(columns)[column].width, + ) +} + +/// How far a text of `cells` can scroll before its tail is in view, keeping +/// one cell for the ellipsis that marks the cut. +const fn overflow(cells: usize, width: usize) -> usize { + if cells <= width { 0 } else { cells + 1 - width } +} + +/// The offset that brings the end of `text` into view in the column. +#[must_use] +pub fn end_offset( + text: &str, + area: Rect, + widths: &[Constraint], + column: usize, +) -> usize { + overflow(text.width(), column_width(area, widths, column)) +} + +/// `text` scrolled `offset` cells to the left, with an ellipsis where it was +/// cut. Scrolls by cells, so a wide glyph counts double, and never starts on +/// a combining mark. Beyond the end it shows the tail. +#[must_use] +pub fn scroll_text(text: &str, offset: usize, width: usize) -> Line<'_> { + if offset == 0 { + return Line::from(text); + } + let mut tail_cells = 0; + let mut furthest = text.len(); + for (index, ch) in text.char_indices().rev() { + let cells = ch.width().unwrap_or(0); + if tail_cells + cells >= width { + if index == 0 && tail_cells + cells == width { + return Line::from(text); + } + break; + } + tail_cells += cells; + furthest = index; + } + if furthest == 0 { + return Line::from(text); + } + let mut skipped = 0; + let mut start = furthest; + for (index, ch) in text.char_indices() { + if index >= furthest || skipped >= offset { + start = index; + break; + } + skipped += ch.width().unwrap_or(0); + } + while let Some(ch) = text[start..].chars().next() + && ch.width() == Some(0) + { + start += ch.len_utf8(); + } + Line::from(vec![Span::raw("…"), Span::raw(&text[start..])]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_of(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn a_text_that_fits_never_scrolls() { + assert_eq!(text_of(&scroll_text("short", 8, 10)), "short"); + assert_eq!( + end_offset( + "short", + Rect::new(0, 0, 40, 5), + &[Constraint::Fill(1)], + 0 + ), + 0 + ); + } + + #[test] + fn the_end_offset_leaves_room_for_the_ellipsis() { + // Column of 40 - 2 border - 2 padding - 1 symbol = 35 cells. + let area = Rect::new(0, 0, 40, 5); + let widths = [Constraint::Fill(1)]; + let fits = "x".repeat(35); + let over = "x".repeat(36); + assert_eq!(end_offset(&fits, area, &widths, 0), 0); + assert_eq!(end_offset(&over, area, &widths, 0), 2); + let line = scroll_text(&over, 2, 35); + assert_eq!(text_of(&line).chars().count(), 35); + assert!(text_of(&line).starts_with('…')); + } + + #[test] + fn scrolling_past_the_end_shows_the_tail() { + let line = scroll_text("abcdefghij", usize::MAX, 5); + assert_eq!(text_of(&line), "…ghij"); + } +} diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs new file mode 100644 index 0000000..e111655 --- /dev/null +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -0,0 +1,375 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::cli::VERSION; +use crate::models::FileDisplayData; +use crate::ui::{ + BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, + header_style, info_style, page_window, pane_block, pane_title, render_page, + row_highlight_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, +}; +use std::collections::HashSet; + +// The file name and its folder share whatever width the fixed columns leave, +// the name getting the larger part: it is what a search is after, and a +// folder's tail is a scroll away. +const WIDTHS: [Constraint; 8] = [ + Constraint::Length(3), + Constraint::Fill(3), + Constraint::Fill(2), + Constraint::Length(12), + Constraint::Length(15), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(6), +]; + +pub struct ResultsPaneParams<'a> { + pub items: &'a [FileDisplayData], + pub table_state: &'a mut TableState, + pub selected_indices: &'a HashSet, + /// Maps a rendered row index to its index in the unfiltered results list. + /// `None` means the rendered rows are the unfiltered list (identity map). + pub original_indices: Option<&'a [usize]>, + pub filter_query: &'a str, + pub is_filtering: bool, + pub focused: bool, + pub active_search_query: Option<&'a str>, + pub name_offset: usize, +} + +/// The file name is the second column, the folder it sits in the third. +const NAME_COLUMN: usize = 1; +const FOLDER_COLUMN: usize = 2; + +/// A shared path as `(folder, name)`. Peers send backslashes, but a slash +/// counts too. +fn split_path(path: &str) -> (&str, &str) { + path.rsplit_once(['\\', '/']).unwrap_or(("", path)) +} + +/// The offset that brings the end of a result's longer column into view. +/// One offset scrolls the name and the folder together, so it runs until +/// both tails are showing. +#[must_use] +pub fn name_end_offset(path: &str, area: Rect) -> usize { + let (folder, name) = split_path(path); + end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset( + folder, + area, + &WIDTHS, + FOLDER_COLUMN, + )) +} + +/// Whether the rendered row `display_idx` is selected. `selected_indices` holds +/// indices into the *unfiltered* results, so under an active filter the display +/// index must be translated through `original_indices` first. +fn row_is_selected( + display_idx: usize, + original_indices: Option<&[usize]>, + selected_indices: &HashSet, +) -> bool { + let original = match original_indices { + Some(map) => match map.get(display_idx) { + Some(&o) => o, + None => return false, + }, + None => display_idx, + }; + selected_indices.contains(&original) +} + +pub fn render_results_pane( + frame: &mut Frame, + area: Rect, + params: ResultsPaneParams, +) { + let ResultsPaneParams { + items, + table_state, + selected_indices, + original_indices, + filter_query, + is_filtering, + focused, + active_search_query, + name_offset, + } = params; + if items.is_empty() { + let title = match active_search_query { + Some(query) => format!("Results: {query}"), + None => "Results".to_string(), + }; + + let empty_block = + pane_block(focused).title(pane_title("2", &title, focused)); + + let message = if is_filtering { + vec![Line::from(Span::styled( + format!("No results match filter: '{filter_query}'"), + dimmed_style(), + ))] + } else { + vec![ + Line::from(vec![ + Span::styled("soulseek-rs", body_style()), + Span::styled(format!(" v{VERSION}"), dimmed_style()), + ]), + Line::from(""), + Line::from(Span::styled("No results yet.", body_style())), + Line::from(Span::styled( + "Pick a search in [1] Searches, or start a new one.", + dimmed_style(), + )), + ] + }; + + let paragraph = Paragraph::new(message).block(empty_block); + frame.render_widget(paragraph, area); + return; + } + + let header = Row::new(vec![ + Cell::from("✓").style(header_style()), + Cell::from("Filename").style(header_style()), + Cell::from("Folder").style(header_style()), + Cell::from("Size").style(header_style()), + Cell::from("User").style(header_style()), + Cell::from("Bitrate").style(header_style()), + Cell::from("Speed").style(header_style()), + Cell::from("Slots").style(header_style()), + ]) + .height(1); + + let name_width = column_width(area, &WIDTHS, NAME_COLUMN); + let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN); + let window = page_window(table_state, items.len(), area, 1); + let start = window.start; + let rows: Vec = (start..) + .zip(&items[window]) + .map(|(idx, file)| { + let (folder, name) = split_path(&file.filename); + let checkbox = + if row_is_selected(idx, original_indices, selected_indices) { + "[✓]" + } else { + "[ ]" + }; + + let bitrate_str = file + .bitrate + .map_or_else(|| "-".to_string(), |br| format!("{br} kbps")); + + let speed_str = if file.speed > 0 { + let speed_mb = (f64::from(file.speed) / BYTES_PER_MB * 100.0) + .round() + / 100.0; + format!("{speed_mb} MB/s") + } else { + "-".to_string() + }; + + let checkbox_style = if checkbox == "[✓]" { + success_style() + } else { + dimmed_style() + }; + + Row::new(vec![ + Cell::from(checkbox).style(checkbox_style), + Cell::from(scroll_text(name, name_offset, name_width)) + .style(body_style()), + Cell::from(scroll_text(folder, name_offset, folder_width)) + .style(dimmed_style()), + Cell::from(format_bytes(file.size)).style(warning_style()), + Cell::from(file.username.clone()).style(info_style()), + Cell::from(bitrate_str).style(dimmed_style()), + Cell::from(speed_str).style(dimmed_style()), + Cell::from(file.slots.to_string()).style(dimmed_style()), + ]) + }) + .collect(); + + let title = if is_filtering { + format!("Results · filter: '{filter_query}'") + } else if let Some(query) = active_search_query { + format!("Results: {query}") + } else { + "Results".to_string() + }; + + let table = Table::new(rows, WIDTHS) + .header(header) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(pane_block(focused).title(pane_title("2", &title, focused))); + + render_page(frame, table, area, table_state, start); +} + +#[cfg(test)] +mod tests { + use super::{ResultsPaneParams, render_results_pane, row_is_selected}; + use crate::models::FileDisplayData; + use ratatui::{Terminal, backend::TestBackend, widgets::TableState}; + use std::collections::HashSet; + + fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { + let mut state = TableState::default(); + state.select(Some(0)); + render_with(items, &mut state, name_offset) + } + + fn render_with( + items: &[FileDisplayData], + state: &mut TableState, + name_offset: usize, + ) -> String { + // Wide enough for the name column to hold 11 cells, with 7 for the + // folder beside it, and tall enough for three rows. + let mut terminal = + Terminal::new(TestBackend::new(88, 6)).expect("backend"); + terminal + .draw(|frame| { + render_results_pane( + frame, + frame.area(), + ResultsPaneParams { + items, + table_state: state, + selected_indices: &HashSet::new(), + original_indices: None, + filter_query: "", + is_filtering: false, + focused: true, + active_search_query: None, + name_offset, + }, + ); + }) + .expect("draw"); + terminal.backend().to_string() + } + + fn file(filename: &str) -> FileDisplayData { + FileDisplayData { + filename: filename.to_string(), + username: "bob".to_string(), + ..Default::default() + } + } + + #[test] + fn a_scrolled_name_shows_its_tail_up_to_the_column_edge() { + let items = [ + file("abcdefghijklmnopqrstuvwxyz0123456789ABCD"), + file("short.mp3"), + ]; + let screen = render_rows(&items, usize::MAX); + assert!(screen.contains("…456789ABCD"), "{screen}"); + assert!(!screen.contains("3456789ABCD"), "{screen}"); + assert!(screen.contains(" short.mp3 "), "{screen}"); + + let screen = render_rows(&items, 8); + assert!(screen.contains("…ijklmnopqr"), "{screen}"); + assert!(!screen.contains("hijklmnopqr"), "{screen}"); + } + + #[test] + fn a_wide_glyph_name_scrolls_by_cells_not_chars() { + let items = [file("零一二三四五六七八九十百千万亿東西南北中")]; + let screen = render_rows(&items, usize::MAX); + assert!(screen.contains("…東西南北中"), "{screen}"); + assert!(!screen.contains("亿東"), "{screen}"); + } + + #[test] + fn a_scrolled_name_never_starts_with_a_combining_mark() { + let name = format!("{}e\u{301}{}", "x".repeat(5), "y".repeat(20)); + let items = [file(&name)]; + let screen = render_rows(&items, 6); + assert!(screen.contains("…yyyyyyyyyy"), "{screen}"); + assert!(!screen.contains("…\u{301}"), "{screen}"); + } + + #[test] + fn a_path_shows_its_name_before_its_folder() { + let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")]; + let screen = render_rows(&items, 0); + assert!(screen.contains("01.flac @@abc\\M"), "{screen}"); + assert!(screen.contains("cover.jpg "), "{screen}"); + } + + #[test] + fn one_offset_scrolls_the_name_and_the_folder_together() { + let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")]; + let screen = render_rows(&items, 4); + assert!(screen.contains("…efghijklmn …c\\Musi"), "{screen}"); + } + + #[test] + fn the_end_offset_is_the_longer_columns() { + // 88 cells wide: 11 for the name, 7 for the folder. + let area = ratatui::layout::Rect::new(0, 0, 88, 6); + let folder = "f".repeat(20); + assert_eq!(super::name_end_offset(&format!("{folder}\\a"), area), 14); + let name = "x".repeat(40); + assert_eq!( + super::name_end_offset(&format!("{folder}\\{name}"), area), + 30 + ); + } + + #[test] + fn an_unscrolled_name_starts_at_its_beginning() { + let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; + let screen = render_rows(&items, 0); + assert!(screen.contains("abcdefghijk"), "{screen}"); + assert!(!screen.contains("…"), "{screen}"); + } + + #[test] + fn a_far_selection_is_drawn_from_its_own_page() { + let items: Vec = + (0..10_000).map(|i| file(&format!("{i}.mp3"))).collect(); + let mut state = TableState::default(); + state.select(Some(9_999)); + let screen = render_with(&items, &mut state, 0); + assert!(screen.contains("›[ ] 9999.mp3"), "{screen}"); + assert!(screen.contains(" [ ] 9997.mp3"), "{screen}"); + assert!(!screen.contains(" [ ] 0.mp3"), "{screen}"); + assert_eq!(state.offset(), 9_997, "the next frame starts here"); + assert_eq!(state.selected(), Some(9_999)); + } + + #[test] + fn identity_mapping_used_when_no_filter() { + let selected: HashSet = std::iter::once(1).collect(); + assert!(row_is_selected(1, None, &selected)); + assert!(!row_is_selected(0, None, &selected)); + } + + #[test] + fn filtered_rows_resolve_through_original_indices() { + // Filter shows original rows 2, 5, 7 as display rows 0, 1, 2. + // The user selected original row 5. + let original_indices = [2usize, 5, 7]; + let selected: HashSet = std::iter::once(5).collect(); + assert!(!row_is_selected(0, Some(&original_indices), &selected)); + assert!(row_is_selected(1, Some(&original_indices), &selected)); + assert!(!row_is_selected(2, Some(&original_indices), &selected)); + } + + #[test] + fn out_of_range_display_index_is_not_selected() { + let original_indices = [2usize, 5]; + let selected: HashSet = [2, 5].into_iter().collect(); + assert!(!row_is_selected(9, Some(&original_indices), &selected)); + } +} diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs new file mode 100644 index 0000000..dd95ed2 --- /dev/null +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -0,0 +1,277 @@ +use crate::models::{ + ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, + contains_filter, matching_users, +}; +use crate::ui::{ + HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title, + highlight_style, info_style, page_window, pane_block, plain_title, + primary_style, render_page, row_highlight_style, wrap_chat_line, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{ + Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, + TableState, + }, +}; + +/// Render the chat-rooms popup: either the browsable room list or the open +/// rooms' tabbed chat view. +pub fn render_rooms_pane( + frame: &mut Frame, + area: Rect, + rooms: &mut RoomsState, + list_table_state: &mut TableState, +) { + match rooms.view { + RoomsView::List => render_list(frame, area, rooms, list_table_state), + RoomsView::Chat => render_chat(frame, area, rooms), + } +} + +fn render_list( + frame: &mut Frame, + area: Rect, + rooms: &RoomsState, + table_state: &mut TableState, +) { + let title = if rooms.list_is_filtering || !rooms.list_filter.is_empty() { + format!( + " Rooms · filter: {}_ (Enter: join, Esc: clear) ", + rooms.list_filter + ) + } else { + " Rooms (Enter: join, /: filter, Tab: open rooms, Esc: close) " + .to_string() + }; + let block = pane_block(true).title(title); + + let filtered = rooms.filtered_rooms(); + if filtered.is_empty() { + let text = if rooms.available.is_empty() { + "Fetching room list…" + } else { + "No rooms match the filter." + }; + frame.render_widget(Paragraph::new(text).block(block), area); + return; + } + + let header = Row::new(vec![ + Cell::from("room").style(dimmed_style()), + Cell::from("users").style(dimmed_style()), + ]); + table_state.select(Some(rooms.list_selected)); + let window = page_window(table_state, filtered.len(), area, 1); + let start = window.start; + let table_rows: Vec = filtered[window] + .iter() + .map(|r| { + let open = rooms.open_index(&r.name).is_some(); + let name = if open { + format!("● {}", r.name) + } else { + format!(" {}", r.name) + }; + let name_style = if open { + accent_style() + } else { + primary_style() + }; + Row::new(vec![ + Cell::from(name).style(name_style), + Cell::from(r.user_count.to_string()), + ]) + }) + .collect(); + + let table = + Table::new(table_rows, [Constraint::Fill(1), Constraint::Length(8)]) + .header(header) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(block); + + render_page(frame, table, area, table_state, start); +} + +fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { + let typing_log = rooms.filtering == Some(ChatFilter::Log); + let title = filter_title( + "Chat rooms", + &rooms.log_filter, + typing_log, + " Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ", + ); + let block = pane_block(true).title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::vertical([ + Constraint::Length(1), // tab bar + Constraint::Fill(1), // body + Constraint::Length(1), // compose / hint + ]) + .split(inner); + + render_tab_bar(frame, chunks[0], rooms); + + let RoomsState { + open, + active: active_index, + user_selected, + composing, + log_filter, + user_filter, + filtering, + .. + } = rooms; + let Some(active) = open.get_mut(*active_index) else { + frame.render_widget( + Paragraph::new("No open rooms. Press l for the room list.") + .style(dimmed_style()), + chunks[1], + ); + return; + }; + + // Body: messages (left) + user list (right). + let body = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) + .split(chunks[1]); + + let OpenRoom { + lines, + view, + wrapped, + users, + .. + } = active; + render_messages(frame, body[0], lines, view, wrapped, log_filter); + let shown = matching_users(users, user_filter); + let typing_users = *filtering == Some(ChatFilter::Users); + let members = if typing_users || !user_filter.is_empty() { + format!( + "Users {}/{} · {}{}", + shown.len(), + users.len(), + user_filter, + if typing_users { "_" } else { "" } + ) + } else { + format!("Users ({})", users.len()) + }; + render_users(frame, body[1], &shown, *user_selected, members); + + // Compose line or hint. + if *composing { + let line = Line::from(vec![ + Span::styled("› ", accent_style()), + Span::styled(active.input.clone(), primary_style()), + Span::styled("▏", accent_style()), + ]); + frame.render_widget(Paragraph::new(line), chunks[2]); + } else { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter: type a message", + dimmed_style(), + ))), + chunks[2], + ); + } +} + +fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) { + let mut spans: Vec = Vec::new(); + for (i, room) in rooms.open.iter().enumerate() { + let active = i == rooms.active; + let label = if room.unread > 0 { + format!(" {} ({}) ", room.name, room.unread) + } else { + format!(" {} ", room.name) + }; + let mut style = if active { + highlight_style() + } else { + primary_style() + }; + if room.unread > 0 { + style = style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(label, style)); + spans.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_messages( + frame: &mut Frame, + area: Rect, + lines: &[RoomLine], + view: &mut LogView, + wrapped: &mut WrappedLog, + filter: &str, +) { + // Each message wrapped to the pane width, then the rows the reader is + // at: the newest ones, unless they are holding a place in the history. + let width = area.width as usize; + let rendered = wrapped.rows(width, filter, lines, |l| { + let sender = l.username.as_deref().unwrap_or_default(); + if !contains_filter(&l.text, filter) && !contains_filter(sender, filter) + { + return Vec::new(); + } + let time = + Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); + match &l.username { + Some(user) => wrap_chat_line( + vec![time, Span::styled(format!("<{user}> "), info_style())], + &l.text, + primary_style(), + width, + ), + None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width), + } + }); + let window = view.window(rendered.len(), usize::from(area.height)); + frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); +} + +fn render_users( + frame: &mut Frame, + area: Rect, + users: &[&str], + selected: usize, + title: String, +) { + let block = Block::default() + .borders(Borders::LEFT) + .border_style(dimmed_style()) + .padding(PANE_PADDING) + .title(plain_title(title, false)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let height = inner.height as usize; + // Keep the highlighted user in view by scrolling the window to it. + let start = selected.saturating_sub(height.saturating_sub(1)); + let shown: Vec = users + .iter() + .enumerate() + .skip(start) + .take(height) + .map(|(i, u)| { + if i == selected { + Line::from(Span::styled(format!("▸ {u}"), highlight_style())) + } else { + Line::from(Span::styled(format!(" {u}"), primary_style())) + } + }) + .collect(); + frame.render_widget(Paragraph::new(shown), inner); +} diff --git a/soulseek-rs/src/ui/panes/searches_pane.rs b/soulseek-rs/src/ui/panes/searches_pane.rs new file mode 100644 index 0000000..03d45eb --- /dev/null +++ b/soulseek-rs/src/ui/panes/searches_pane.rs @@ -0,0 +1,97 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::models::{SearchEntry, SearchStatus}; +use crate::ui::{ + HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block, + pane_title, row_highlight_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, HighlightSpacing, Row, Table, TableState}, +}; + +// The pane shares a row with two others, so the fixed columns are as +// narrow as their widest word: the query gets the rest. +const WIDTHS: [Constraint; 3] = [ + Constraint::Length(6), + Constraint::Fill(1), + Constraint::Length(7), +]; + +/// Query is the second column. +const QUERY_COLUMN: usize = 1; + +/// The offset that brings the end of a query into view. +#[must_use] +pub fn query_end_offset(query: &str, area: Rect) -> usize { + end_offset(query, area, &WIDTHS, QUERY_COLUMN) +} + +pub fn render_searches_pane( + frame: &mut Frame, + area: Rect, + searches: &[SearchEntry], + table_state: &mut TableState, + focused: bool, + query_offset: usize, +) { + let header = Row::new(vec![ + Cell::from("Status").style(header_style()), + Cell::from("Query").style(header_style()), + Cell::from("Results").style(header_style()), + ]) + .height(1); + + let query_width = column_width(area, &WIDTHS, QUERY_COLUMN); + let rows: Vec = searches + .iter() + .map(|search| { + let status_cell = match &search.status { + SearchStatus::Active => { + Cell::from("Active").style(warning_style()) + } + SearchStatus::Completed => { + Cell::from("Done").style(success_style()) + } + }; + + // What this window has fetched, or what the session says it will + // find when it does. A search another window ran has a count long + // before this one has pulled the results across. + let results_count = if search.results.is_empty() { + search.known_files + } else { + search.results.len() + }; + // Nothing yet from a search still out: the status column already + // says it is looking. + let results_text = if results_count == 0 + && search.status == SearchStatus::Active + { + "…".to_string() + } else { + format!("{results_count}") + }; + + Row::new(vec![ + status_cell, + Cell::from(scroll_text( + &search.query, + query_offset, + query_width, + )) + .style(body_style()), + Cell::from(results_text).style(dimmed_style()), + ]) + }) + .collect(); + + let table = Table::new(rows, WIDTHS) + .header(header) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(pane_block(focused).title(pane_title("1", "Searches", focused))); + + frame.render_stateful_widget(table, area, table_state); +} diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs new file mode 100644 index 0000000..3e953ee --- /dev/null +++ b/soulseek-rs/src/ui/styles.rs @@ -0,0 +1,362 @@ +// Reusable styles and colors for consistent UI appearance + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Padding}; +use soulseek_rs::DownloadStatus; + +/// One column of breathing room on each side, so content never touches the +/// border. Vertical rows stay unpadded — they are too scarce in a terminal. +pub const PANE_PADDING: Padding = Padding::horizontal(1); + +pub const VINYL_2: Color = Color::Rgb(0x26, 0x21, 0x1D); +/// A warm oxide wash for the cursor row — dark enough that every column keeps +/// its own colour on top of it. +pub const SELECTION: Color = Color::Rgb(0x30, 0x1B, 0x14); +pub const DUST: Color = Color::Rgb(0x8A, 0x81, 0x78); +pub const PAPER: Color = Color::Rgb(0xE8, 0xE1, 0xD6); +pub const PAPER_DIM: Color = Color::Rgb(0xC9, 0xC1, 0xB4); +pub const OXIDE: Color = Color::Rgb(0xC1, 0x50, 0x2E); +pub const TAPE: Color = Color::Rgb(0xD8, 0xA6, 0x57); +pub const PHOSPHOR: Color = Color::Rgb(0x7F, 0xB6, 0x85); +pub const SIGNAL: Color = Color::Rgb(0x6E, 0x9D, 0xC9); +pub const ALARM: Color = Color::Rgb(0xB3, 0x3A, 0x3A); + +pub const COLOR_PRIMARY: Color = PAPER; +pub const COLOR_ACCENT: Color = OXIDE; +pub const COLOR_SUCCESS: Color = PHOSPHOR; +pub const COLOR_WARNING: Color = TAPE; +pub const COLOR_ERROR: Color = ALARM; +pub const COLOR_INFO: Color = SIGNAL; +pub const COLOR_INACTIVE: Color = DUST; +pub const COLOR_FOCUSED_BORDER: Color = PHOSPHOR; + +pub const GLYPH_QUEUED: &str = "⋯"; +pub const GLYPH_ACTIVE: &str = "↯"; +pub const GLYPH_PAUSED: &str = "⏸"; +pub const GLYPH_DONE: &str = "✓"; +pub const GLYPH_FAILED: &str = "✗"; +pub const GLYPH_TIMED_OUT: &str = "⧗"; +pub const GLYPH_CURSOR: &str = "▮"; + +/// What a password looks like on screen: one bullet per character. +#[must_use] +pub fn mask(secret: &str) -> String { + "•".repeat(secret.chars().count()) +} +pub const HIGHLIGHT_SYMBOL: &str = "›"; +/// Between a key and its action in the legend, spaced. +pub const SHORTCUT_ARROW: &str = " → "; + +pub fn header_style() -> Style { + Style::default().fg(DUST).add_modifier(Modifier::BOLD) +} + +pub fn highlight_style() -> Style { + Style::default() + .bg(SELECTION) + .fg(PAPER) + .add_modifier(Modifier::BOLD) +} + +/// The cursor row in a table. No foreground: a table row already colours its +/// own cells, and setting one here would flatten them all to a single tone. +pub fn row_highlight_style() -> Style { + Style::default().bg(SELECTION).add_modifier(Modifier::BOLD) +} + +pub fn success_style() -> Style { + Style::default().fg(COLOR_SUCCESS) +} + +pub fn warning_style() -> Style { + Style::default().fg(COLOR_WARNING) +} + +pub fn error_style() -> Style { + Style::default().fg(COLOR_ERROR) +} + +pub fn inactive_style() -> Style { + Style::default().fg(COLOR_INACTIVE) +} + +pub fn primary_style() -> Style { + Style::default().fg(COLOR_PRIMARY) +} + +pub fn info_style() -> Style { + Style::default().fg(COLOR_INFO) +} + +pub fn accent_style() -> Style { + Style::default().fg(COLOR_ACCENT) +} + +pub fn dimmed_style() -> Style { + Style::default().fg(DUST) +} + +pub fn body_style() -> Style { + Style::default().fg(PAPER_DIM) +} + +pub fn focused_border_style() -> Style { + Style::default().fg(COLOR_FOCUSED_BORDER) +} + +pub fn unfocused_border_style() -> Style { + Style::default().fg(DUST) +} + +pub fn border_style(focused: bool) -> Style { + if focused { + focused_border_style() + } else { + unfocused_border_style() + } +} + +pub fn title_style(focused: bool) -> Style { + if focused { + Style::default().fg(PHOSPHOR).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(DUST) + } +} + +pub fn pane_title(tag: &str, title: &str, focused: bool) -> Line<'static> { + let label = if title.is_empty() { + " ".to_string() + } else { + format!(" {title} ") + }; + Line::from(vec![ + Span::styled(format!(" [{tag}]"), Style::default().fg(SIGNAL)), + Span::styled(label, title_style(focused)), + ]) +} + +pub fn plain_title(title: impl Into, focused: bool) -> Line<'static> { + Line::from(Span::styled( + format!(" {} ", title.into()), + title_style(focused), + )) +} + +/// A popup's title: `idle` until its list is filtered, then what it is +/// filtered by, a caret after it while that is still being typed, and how +/// the filter ends. +#[must_use] +pub fn filter_title( + head: &str, + filter: &str, + typing: bool, + idle: &str, +) -> String { + if !typing && filter.is_empty() { + return idle.to_string(); + } + let caret = if typing { "_" } else { "" }; + format!(" {head} · filter: {filter}{caret} (Enter: keep, Esc: clear) ") +} + +/// The standard bordered pane. Chain `.title(..)` for a legend. +pub fn pane_block(focused: bool) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(border_style(focused)) + .border_type(BorderType::Rounded) + .padding(PANE_PADDING) +} + +pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) { + match status { + DownloadStatus::Queued => (GLYPH_QUEUED, warning_style()), + DownloadStatus::InProgress { .. } => (GLYPH_ACTIVE, accent_style()), + DownloadStatus::Paused { .. } => (GLYPH_PAUSED, info_style()), + DownloadStatus::Completed => (GLYPH_DONE, success_style()), + DownloadStatus::Cancelled => (GLYPH_FAILED, inactive_style()), + DownloadStatus::Failed(_) => (GLYPH_FAILED, error_style()), + DownloadStatus::TimedOut => (GLYPH_TIMED_OUT, error_style()), + } +} + +/// One `[key → action]` legend entry. +fn shortcut_spans( + key: &'static str, + action: &'static str, +) -> [Span<'static>; 5] { + [ + Span::styled("[", dimmed_style()), + Span::styled(key, info_style()), + Span::styled(SHORTCUT_ARROW, dimmed_style()), + Span::styled(action, body_style()), + Span::styled("]", dimmed_style()), + ] +} + +/// The most rows the shortcuts bar grows to. Past that a terminal is too +/// narrow for the legend to be worth the content rows it would take. +pub const SHORTCUT_ROWS_MAX: usize = 3; + +/// Lay the legend out in rows no wider than `width`, never breaking inside an +/// entry: a `[key → action]` split over two rows reads as two keys. Entries +/// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always +/// has at least one row and never more than that. +pub fn pack_shortcuts( + shortcuts: &[(&'static str, &'static str)], + width: u16, +) -> Vec> { + let width = usize::from(width); + let mut rows: Vec> = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut row_width = 0; + + for (key, action) in shortcuts { + let entry = shortcut_spans(key, action); + let entry_width: usize = entry.iter().map(Span::width).sum(); + if !row.is_empty() && row_width + 1 + entry_width > width { + rows.push(Line::from(std::mem::take(&mut row))); + if rows.len() == SHORTCUT_ROWS_MAX { + return rows; + } + row_width = 0; + } + if !row.is_empty() { + row.push(Span::raw(" ")); + row_width += 1; + } + row.extend(entry); + row_width += entry_width; + } + + rows.push(Line::from(row)); + rows +} + +pub fn format_progress_bar( + progress: f64, + width: usize, + percentage: u8, +) -> Line<'static> { + let filled = (progress * width as f64) as usize; + let empty = width.saturating_sub(filled); + + let spans = vec![ + Span::styled("[", dimmed_style()), + Span::styled("█".repeat(filled), Style::default().fg(SIGNAL)), + Span::styled("░".repeat(empty), Style::default().fg(VINYL_2)), + Span::styled("]", dimmed_style()), + Span::raw(" "), + Span::styled( + format!("{percentage}%"), + primary_style().add_modifier(Modifier::BOLD), + ), + ]; + + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn colors(line: &Line<'static>) -> Vec> { + line.spans.iter().map(|s| s.style.fg).collect() + } + + #[test] + fn pane_title_tags_in_signal_and_tracks_focus() { + assert_eq!( + colors(&pane_title("2", "Results", true)), + vec![Some(SIGNAL), Some(PHOSPHOR)] + ); + assert_eq!( + colors(&pane_title("2", "Results", false)), + vec![Some(SIGNAL), Some(DUST)] + ); + } + + #[test] + fn titles_are_inset_from_the_border() { + let inset = |line: &Line<'static>| { + let text: String = + line.spans.iter().map(|s| s.content.as_ref()).collect(); + text.starts_with(' ') && text.ends_with(' ') + }; + assert!(inset(&pane_title("2", "Results", true))); + assert!(inset(&pane_title("Info", "", true))); + assert!(inset(&plain_title("Status", false))); + } + + #[test] + fn download_status_glyphs_carry_their_semantic_color() { + for (status, glyph, color) in [ + (DownloadStatus::Queued, GLYPH_QUEUED, TAPE), + (DownloadStatus::Completed, GLYPH_DONE, PHOSPHOR), + (DownloadStatus::Failed(None), GLYPH_FAILED, ALARM), + (DownloadStatus::TimedOut, GLYPH_TIMED_OUT, ALARM), + (DownloadStatus::Cancelled, GLYPH_FAILED, COLOR_INACTIVE), + ] { + let (got_glyph, style) = download_status_glyph(&status); + assert_eq!(got_glyph, glyph); + assert_eq!(style.fg, Some(color)); + } + } + + #[test] + fn table_cursor_row_tints_the_background_without_recolouring_cells() { + let cursor = row_highlight_style(); + assert_eq!(cursor.fg, None); + assert_eq!(cursor.bg, Some(SELECTION)); + } + + fn texts(rows: &[Line<'static>]) -> Vec { + rows.iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect() + } + + #[test] + fn shortcuts_stay_on_one_row_when_they_fit() { + let rows = pack_shortcuts(&[("q", "quit"), ("s", "search")], 80); + assert_eq!(texts(&rows), ["[q → quit] [s → search]"]); + } + + #[test] + fn shortcuts_wrap_between_entries_never_inside_one() { + let rows = pack_shortcuts( + &[("Space", "select"), ("Enter", "download"), ("q", "quit")], + 30, + ); + // "[Space → select] [Enter → download]" is 35 cells, over the 30 the + // bar has, so the second entry starts a new row whole. + assert_eq!( + texts(&rows), + ["[Space → select]", "[Enter → download] [q → quit]"] + ); + } + + #[test] + fn an_entry_wider_than_the_bar_still_gets_a_row() { + let rows = pack_shortcuts(&[("Enter", "download"), ("q", "quit")], 5); + assert_eq!(texts(&rows), ["[Enter → download]", "[q → quit]"]); + } + + #[test] + fn the_bar_never_grows_past_its_cap() { + let keys: Vec<(&str, &str)> = (0..10).map(|_| ("k", "act")).collect(); + let rows = pack_shortcuts(&keys, 12); + assert_eq!(rows.len(), SHORTCUT_ROWS_MAX); + assert_eq!(texts(&rows)[0], "[k → act]"); + } + + #[test] + fn progress_bar_splits_fill_and_track() { + let bar = format_progress_bar(0.25, 20, 25); + assert_eq!(bar.spans[1].content.chars().count(), 5); + assert_eq!(bar.spans[1].style.fg, Some(SIGNAL)); + assert_eq!(bar.spans[2].content.chars().count(), 15); + } +} diff --git a/soulseek-rs/src/ui/utils.rs b/soulseek-rs/src/ui/utils.rs new file mode 100644 index 0000000..fd09f44 --- /dev/null +++ b/soulseek-rs/src/ui/utils.rs @@ -0,0 +1,96 @@ +use ratatui::{ + style::Style, + text::{Line, Span}, +}; + +use crate::ui::COLOR_PRIMARY; + +pub const BYTES_PER_MB: f64 = 1_048_576.0; + +pub fn format_bytes(bytes: u64) -> String { + let mb = bytes as f64 / BYTES_PER_MB; + format!("{mb:.1} MB") +} + +pub fn format_bytes_progress(downloaded: u64, total: u64) -> Line<'static> { + let downloaded_mb = downloaded as f64 / BYTES_PER_MB; + let total_mb = total as f64 / BYTES_PER_MB; + + Line::from(vec![ + Span::styled( + format!("{downloaded_mb:.1}/{total_mb:.1}"), + Style::default().fg(COLOR_PRIMARY), + ), + Span::raw(" MB"), + ]) +} + +pub fn format_speed(speed_bytes_per_sec: f64) -> String { + let mb = speed_bytes_per_sec / BYTES_PER_MB; + format!("{mb:.1} MB/s") +} + +/// Wrap a chat message into display lines: the prefix spans (time, sender) +/// start the first line, and continuation lines are indented to the prefix +/// width so the message text stays column-aligned. +// ponytail: char-count wrap; wide glyphs may spill a cell — switch to +// unicode-width if that ever matters. +pub fn wrap_chat_line( + prefix: Vec>, + text: &str, + style: Style, + width: usize, +) -> Vec> { + let prefix_width: usize = prefix.iter().map(Span::width).sum(); + let avail = width.saturating_sub(prefix_width).max(1); + let chars: Vec = text.chars().collect(); + let indent = " ".repeat(prefix_width); + + let mut lines = Vec::new(); + let mut i = 0; + loop { + let chunk: String = chars[i..].iter().take(avail).collect(); + let spans = if i == 0 { + let mut spans = prefix.clone(); + spans.push(Span::styled(chunk, style)); + spans + } else { + vec![Span::raw(indent.clone()), Span::styled(chunk, style)] + }; + lines.push(Line::from(spans)); + i += avail; + if i >= chars.len() { + return lines; + } + } +} + +const SPINNER_CHARS: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub const fn get_spinner_char(state: usize) -> &'static str { + SPINNER_CHARS[state % SPINNER_CHARS.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_chat_line_wraps_and_indents() { + let prefix = vec![Span::raw("12:00 "), Span::raw(" ")]; + let lines = wrap_chat_line(prefix, "abcdefghij", Style::default(), 16); + // prefix width 12, so 4 text chars per row. + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].to_string(), "12:00 abcd"); + assert_eq!(lines[1].to_string(), " efgh"); + assert_eq!(lines[2].to_string(), " ij"); + } + + #[test] + fn wrap_chat_line_short_message_is_one_line() { + let lines = + wrap_chat_line(vec![Span::raw("x ")], "hi", Style::default(), 80); + assert_eq!(lines.len(), 1); + } +} diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs new file mode 100644 index 0000000..6a44531 --- /dev/null +++ b/soulseek-rs/tests/cli_e2e.rs @@ -0,0 +1,3924 @@ +//! End-to-end tests of the scriptable command surface, driving the real +//! binary the way a script would: arguments in, records on stdout, progress on +//! stderr, a verdict in the exit code. +//! +//! The tests split in two. The first half needs no server and always runs. +//! The second half talks to a real Soulseek server (soulfind) and follows the +//! same server-optional rule as the library suite: with `SOULFIND_BIN` (or a +//! sibling `soulfind/bin/soulfind` checkout) it spawns one, otherwise it skips +//! with a notice. `SOULSEEK_E2E_REQUIRED=1` turns a missing server into a +//! failure, which is how CI guarantees these actually run. + +mod common; + +use common::{CLI_ENV_VARS, Soulfind, free_port, login, settle, silent_peer}; +use soulseek_rs::Client; +use std::io::Write; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_soulseek-rs"); + +/// Exit codes the command surface promises. Kept as literals so a change to +/// the enum has to be a deliberate change here too. +const EXIT_OK: i32 = 0; +const EXIT_USAGE: i32 = 2; +const EXIT_CONNECTION: i32 = 3; +const EXIT_NO_RESULTS: i32 = 4; +const EXIT_TRANSFER: i32 = 6; +const EXIT_SESSION_LOST: i32 = 7; + +/// Build a command for the binary, isolated from the developer's environment +/// and from the repository's own working directory. +/// +/// The state directory is not merely cleared but pointed somewhere empty: a +/// client with no state directory of its own would look in the real one, find +/// whatever daemon the developer happens to be running, and route the whole +/// suite through their live Soulseek session. +fn command(args: &[&str]) -> Command { + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_STATE_DIR", isolated_state_dir()); + command.current_dir(std::env::temp_dir()); + command.args(args); + command +} + +/// An empty state directory shared by every run in this test process, so +/// auto-routing finds nothing. +fn isolated_state_dir() -> PathBuf { + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| { + let path = std::env::temp_dir() + .join(format!("soulseek-cli-e2e-state-{}", std::process::id())); + std::fs::create_dir_all(&path).expect("state directory"); + path + }) + .clone() +} + +/// Which kind of session the suite drives. +/// +/// The point of the switch is that the *same* tests run both ways: identical +/// records, identical exit codes, whether the session is this process's own or +/// a daemon's. CI runs the suite twice, once per mode, and that equivalence is +/// the guarantee daemon mode rests on. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +enum Mode { + Local, + Daemon, +} + +fn mode() -> Mode { + match std::env::var("SOULSEEK_E2E_MODE").as_deref() { + Ok("daemon") => Mode::Daemon, + _ => Mode::Local, + } +} + +fn run(args: &[&str]) -> Output { + command(args).output().expect("the binary should run") +} + +fn code(output: &Output) -> i32 { + output + .status + .code() + .expect("the process should not be signalled") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +/// Records are one per line; blank trailing lines are not records. +fn records(output: &Output) -> Vec { + stdout(output) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(String::from) + .collect() +} + +/// The first record of a `--json` run, parsed. +fn json_record(output: &Output) -> serde_json::Value { + serde_json::from_str(records(output).first().expect("one record")) + .expect("valid JSON") +} + +// --------------------------------------------------------------------------- +// No server required +// --------------------------------------------------------------------------- + +#[test] +fn help_lists_every_scriptable_command() { + let output = run(&["--help"]); + assert_eq!(code(&output), EXIT_OK); + let help = stdout(&output); + for command in [ + "search", + "download", + "get", + "browse", + "room", + "message", + "portmap", + "skills", + "completions", + "wish", + ] { + assert!(help.contains(command), "--help should mention {command}"); + } +} + +#[test] +fn help_documents_the_exit_codes_a_script_branches_on() { + let help = stdout(&run(&["--help"])); + assert!(help.contains("Exit codes"), "help should list exit codes"); + for code in ["2", "3", "4", "5", "6", "7"] { + assert!(help.contains(code), "exit code {code} should be documented"); + } +} + +#[test] +fn an_unknown_flag_is_a_usage_error() { + let output = run(&["--definitely-not-a-flag"]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn missing_credentials_are_a_usage_error_with_a_pointer_to_the_fix() { + let output = run(&["--no-config", "search", "anything"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!(stderr(&output).contains("SOULSEEK_USERNAME")); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn a_falsey_boolean_environment_variable_is_accepted() { + // `DISABLE_LISTENER=1` used to abort every invocation with a clap parse + // error, which made the binary unusable from a container. + for value in ["1", "0", "true", "false", "yes", ""] { + let output = command(&["--no-config", "search", "anything"]) + .env("SOULSEEK_NO_LISTENER", value) + .output() + .expect("the binary should run"); + assert!( + !stderr(&output).contains("invalid value"), + "SOULSEEK_NO_LISTENER={value:?} should be accepted" + ); + } +} + +#[test] +fn global_flags_are_accepted_after_the_subcommand() { + let output = run(&["search", "anything", "--no-config", "--json"]); + assert_eq!( + code(&output), + EXIT_USAGE, + "should reach the credential check, not a parse error" + ); + assert!(!stderr(&output).contains("unexpected argument")); +} + +#[test] +fn the_password_environment_value_is_not_echoed_in_help() { + let output = command(&["--help"]) + .env("SOULSEEK_PASSWORD", "hunter2") + .output() + .expect("the binary should run"); + assert!( + !stdout(&output).contains("hunter2"), + "--help must not print the password it inherited" + ); +} + +#[test] +fn download_without_a_target_is_a_usage_error() { + assert_eq!(code(&run(&["download", "--no-config"])), EXIT_USAGE); + assert_eq!(code(&run(&["download", "--no-config", "bob"])), EXIT_USAGE); +} + +#[test] +fn download_stdin_cannot_be_combined_with_positional_arguments() { + let output = run(&["download", "--stdin", "bob", "file.mp3"]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn a_malformed_server_address_is_a_usage_error() { + let output = run(&[ + "--no-config", + "--username", + "u", + "--password", + "p", + "--server", + "no-port-here", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_USAGE); +} + +#[test] +fn an_unreachable_server_is_a_connection_error() { + // Port 1 is reserved and nothing listens on it, so the connect fails fast. + let output = run(&[ + "--no-config", + "--username", + "u", + "--password", + "p", + "--server", + "127.0.0.1:1", + "--no-listener", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_CONNECTION); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn without_a_terminal_the_tui_reports_instead_of_panicking() { + // Captured stdout is not a terminal, which is exactly the cron/CI case. + let output = run(&["--no-config"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!( + stderr(&output).contains("terminal"), + "should explain why, got: {}", + stderr(&output) + ); +} + +#[test] +fn portmap_emits_a_record_whose_verdict_matches_the_exit_code() { + let output = run(&["--no-config", "--quiet", "--json", "portmap"]); + let record = json_record(&output); + + assert!(record["port"].is_number()); + let ok = record["ok"].as_bool().expect("ok should be a boolean"); + // Whether a router answers depends on the network, but the exit code and + // the record must always agree. + assert_eq!( + ok, + code(&output) == EXIT_OK, + "exit code and the ok field must agree" + ); + if !ok { + assert_eq!(code(&output), EXIT_NO_RESULTS); + } +} + +#[test] +fn quiet_keeps_progress_off_stderr() { + let loud = run(&["--no-config", "--json", "portmap"]); + let quiet = run(&["--no-config", "--quiet", "--json", "portmap"]); + assert!( + stderr(&quiet).lines().count() < stderr(&loud).lines().count(), + "--quiet should suppress the progress narration" + ); + assert_eq!( + records(&loud).len(), + records(&quiet).len(), + "--quiet must not change the records" + ); +} + +/// What the first record says happened, parsed rather than matched as text. +fn action(output: &Output) -> String { + json_record(output)["action"] + .as_str() + .expect("a record carries an action") + .to_string() +} + +#[test] +fn installing_the_skill_is_repeatable_and_reversible() { + let target = Scratch::new("skills"); + let dir = target.display(); + let installed = target.path().join("soulseek-rs").join("SKILL.md"); + + let first = run(&["--json", "skills", "install", "--dir", &dir]); + assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first)); + assert_eq!(action(&first), "installed"); + let written = std::fs::read_to_string(&installed).expect("a skill file"); + assert!(written.contains("name: soulseek-rs")); + assert!(written.contains("--json")); + + let second = run(&["--json", "skills", "install", "--dir", &dir]); + assert_eq!( + action(&second), + "unchanged", + "reinstalling is how you update" + ); + + // A neighbour in the same folder proves the sweep removes our file rather + // than the directory it happened to be sitting in. + let neighbour = installed.with_file_name("notes.md"); + std::fs::write(&neighbour, "mine").expect("a neighbouring file"); + + let removed = run(&["--json", "skills", "uninstall", "--dir", &dir]); + assert_eq!(action(&removed), "removed"); + assert!(!installed.exists()); + assert!(neighbour.exists(), "a file we did not write must survive"); + + let again = run(&["--json", "skills", "uninstall", "--dir", &dir]); + assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure"); + assert_eq!(action(&again), "absent"); +} + +/// A mistyped `--dir` names somebody else's directory far more often than it +/// names one of ours, so uninstall has to leave it standing. +#[test] +fn uninstall_leaves_a_skill_it_did_not_write() { + let target = Scratch::new("skills-foreign"); + let foreign = target.path().join("soulseek-rs").join("SKILL.md"); + std::fs::create_dir_all(foreign.parent().expect("a parent")) + .expect("mkdir"); + std::fs::write(&foreign, "---\nname: someone-else\n---\n").expect("write"); + + let output = + run(&["--json", "skills", "uninstall", "--dir", &target.display()]); + assert_eq!(code(&output), EXIT_OK); + assert_eq!(action(&output), "absent"); + assert!(foreign.exists(), "a foreign skill must survive"); +} + +#[test] +fn listing_skills_reports_without_writing_anything() { + let target = Scratch::new("skills-list"); + + let output = run(&["--json", "skills", "list", "--dir", &target.display()]); + assert_eq!(code(&output), EXIT_OK); + assert_eq!(action(&output), "absent"); + assert!( + !target.path().join("soulseek-rs").exists(), + "list must not create anything" + ); +} + +/// fish is the shell the whole round trip can be driven for on every platform: +/// its completions directory hangs off `XDG_CONFIG_HOME`, so nothing outside +/// the scratch directory is read or written. bash and zsh also append a line +/// to a real rc file under `$HOME`, which is not a thing to point a test at on +/// Windows — the unit tests cover that half. +#[test] +fn installing_completions_is_repeatable_and_reversible() { + let target = Scratch::new("completions"); + let installed = target + .path() + .join("fish") + .join("completions") + .join("soulseek-rs.fish"); + let completions = |verb: &str| { + command(&["--json", "completions", verb, "--shell", "fish"]) + .env("XDG_CONFIG_HOME", target.path()) + .output() + .expect("the binary should run") + }; + + let first = completions("install"); + assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first)); + assert_eq!(action(&first), "installed"); + let script = std::fs::read_to_string(&installed).expect("a fish script"); + assert!(script.contains("complete -c soulseek-rs")); + assert!( + script.contains("-l download-dir"), + "flags are completed too" + ); + + assert_eq!( + action(&completions("install")), + "unchanged", + "reinstalling is how you update" + ); + + let removed = completions("uninstall"); + assert_eq!(action(&removed), "removed"); + assert!(!installed.exists()); + + let again = completions("uninstall"); + assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure"); + assert_eq!(action(&again), "absent"); +} + +#[test] +fn a_package_build_reads_completions_and_the_man_page_from_stdout() { + let dir = Scratch::new("package-build"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "not = [toml").expect("a config file"); + let broken = config.display().to_string(); + + for (shell, marker) in [ + ("bash", "complete -F"), + ("zsh", "#compdef soulseek-rs"), + ("fish", "complete -c soulseek-rs"), + ] { + let output = run(&["--config", &broken, "completions", "print", shell]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(marker), "{shell} script"); + } + + let man = run(&["--config", &broken, "man"]); + assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man)); + assert!(stdout(&man).contains(".TH soulseek-rs 1")); +} + +// --- wishlist, the half that only touches the config file ------------------ + +#[test] +fn a_wish_is_added_listed_and_removed_through_the_config_file() { + let dir = Scratch::new("wish"); + let config = dir.path().join("config.toml"); + + let added = with_config(&config, &["wish", "add", "aphex twin"]); + assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added)); + assert_eq!(records(&added), ["aphex twin"]); + + let listed = with_config(&config, &["wish", "list"]); + assert_eq!(code(&listed), EXIT_OK); + assert_eq!(records(&listed), ["aphex twin"]); + + let removed = with_config(&config, &["wish", "remove", "APHEX TWIN"]); + assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed)); + assert!(stdout(&removed).is_empty(), "remove prints nothing"); + + let empty = with_config(&config, &["wish", "list"]); + assert_eq!(code(&empty), EXIT_NO_RESULTS); + assert!(stdout(&empty).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn adding_the_same_wish_twice_is_not_an_error_and_stores_it_once() { + let dir = Scratch::new("wish"); + let config = dir.path().join("config.toml"); + + with_config(&config, &["wish", "add", "boards of canada"]); + let again = with_config(&config, &["wish", "add", "Boards Of Canada"]); + assert_eq!(code(&again), EXIT_OK, "a repeat is not a failure"); + + let listed = with_config(&config, &["wish", "list"]); + assert_eq!(records(&listed), ["boards of canada"]); +} + +#[test] +fn a_wish_holding_a_comma_survives_the_config_file() { + let dir = Scratch::new("wish"); + let config = dir.path().join("config.toml"); + + with_config(&config, &["wish", "add", "autechre, incunabula"]); + let listed = with_config(&config, &["--json", "wish", "list"]); + let stored: Vec = records(&listed) + .iter() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert_eq!(stored.len(), 1, "one wish, not two split on the comma"); + assert_eq!(stored[0]["query"], "autechre, incunabula"); +} + +#[test] +fn removing_a_wish_that_is_not_there_is_the_no_results_code() { + let dir = Scratch::new("wish"); + let config = dir.path().join("config.toml"); + + let output = with_config(&config, &["wish", "remove", "never added"]); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn an_empty_wish_query_is_a_usage_error() { + let dir = Scratch::new("wish"); + let config = dir.path().join("config.toml"); + + let output = with_config(&config, &["wish", "add", " "]); + assert_eq!(code(&output), EXIT_USAGE); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn wish_add_without_a_config_file_to_write_to_is_a_usage_error() { + let output = run(&["--no-config", "wish", "add", "nowhere to keep this"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!(stdout(&output).is_empty()); +} + +// --------------------------------------------------------------------------- +// Server required (soulfind) +// --------------------------------------------------------------------------- + +/// Only one test at a time may drive a server. +/// +/// Each of these tests runs a soulfind, one or more real clients, and the +/// binary itself. Letting the harness start a dozen of those at once starves +/// the servers, and a login that goes unanswered fails a test for a reason +/// that has nothing to do with the code under test. Serializing here keeps the +/// suite deterministic whatever `--test-threads` the runner picks. +static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A soulfind we spawned for one test, held for that test's lifetime. +struct TestServer { + host: String, + port: u16, + /// The soulfind spawned for this run; `None` against a server named by + /// `SOULSEEK_TEST_SERVER`. + _server: Option, + _gate: std::sync::MutexGuard<'static, ()>, + /// Daemons started for this server, one per (user, session flags). Dropped + /// with the server, which kills them. + daemons: std::sync::Mutex>, +} + +/// A `soulseek-rs daemon` running against the test server, with a state +/// directory of its own so its socket and token cannot collide with another +/// fixture's — or with the developer's. +struct DaemonFixture { + child: Child, + socket: PathBuf, + state: PathBuf, +} + +impl DaemonFixture { + fn start( + server: &TestServer, + user: &str, + session_flags: &[String], + ) -> Self { + static COUNTER: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let state = std::env::temp_dir() + .join(format!("soulseek-e2e-daemon-{}-{n}", std::process::id())); + std::fs::create_dir_all(&state).expect("daemon state directory"); + + let mut args = vec![ + "--no-config".to_string(), + "--server".to_string(), + server.address(), + "--username".to_string(), + user.to_string(), + "--password".to_string(), + "pw".to_string(), + ]; + // A listener of its own unless the test asked for something specific: + // transfers arrive on a connection the uploader opens back to us. + if !session_flags + .iter() + .any(|flag| flag == "--listener-port" || flag == "--no-listener") + { + args.push("--listener-port".to_string()); + args.push(free_port().expect("listener port").to_string()); + } + args.extend(session_flags.iter().cloned()); + args.push("daemon".to_string()); + + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_STATE_DIR", &state); + command.current_dir(std::env::temp_dir()); + command.args(&args); + command.stdout(Stdio::null()).stderr(Stdio::null()); + let child = command.spawn().expect("the daemon should start"); + + let socket = state.join("daemon.sock"); + let fixture = Self { + child, + socket, + state, + }; + fixture.wait_until_ready(); + fixture + } + + /// Wait until the socket actually accepts, not merely exists: the daemon + /// binds before it logs in, so a client that connects too early would be + /// answering questions about a session that is not there yet. + fn wait_until_ready(&self) { + let deadline = Instant::now() + Duration::from_secs(45); + while Instant::now() < deadline { + #[cfg(unix)] + if std::os::unix::net::UnixStream::connect(&self.socket).is_ok() { + // Binding happens first, so also wait for the login to land. + if self.answers() { + return; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("the daemon never became ready at {}", self.socket.display()); + } + + /// Whether the daemon answers `daemon status` with a session. + fn answers(&self) -> bool { + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_STATE_DIR", &self.state); + command.current_dir(std::env::temp_dir()); + command.args([ + "--no-config", + "--json", + "--daemon", + &self.socket.display().to_string(), + "daemon", + "status", + ]); + command.output().is_ok_and(|output| output.status.success()) + } +} + +impl Drop for DaemonFixture { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.state); + } +} + +impl TestServer { + fn resolve() -> Option { + // A test that panicked while holding the gate poisoned it; the lock + // guards nothing but scheduling, so take it back and carry on. + let gate = SERVER_GATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") { + let (host, port) = addr.rsplit_once(':')?; + let port = port.parse().ok()?; + wait_until_listening(host, port, Duration::from_secs(2))?; + return Some(Self { + host: host.to_string(), + port, + _server: None, + _gate: gate, + daemons: std::sync::Mutex::new(std::collections::HashMap::new()), + }); + } + + let server = Soulfind::start()?; + Some(Self { + host: "127.0.0.1".to_string(), + port: server.port(), + _server: Some(server), + _gate: gate, + daemons: std::sync::Mutex::new(std::collections::HashMap::new()), + }) + } + + fn address(&self) -> String { + format!("{}:{}", self.host, self.port) + } + + /// Arguments that point the binary at this server as `user` — or, in + /// daemon mode, at a daemon logged in as `user`. + /// + /// Every helper that builds a command goes through here, so routing at + /// this one point is what puts the streaming and wishlist tests through a + /// daemon too, rather than only the ones that happen to use [`cli`]. + /// + /// The listener stays on: a Soulseek transfer is delivered over a + /// connection the *uploader* opens back to us, so a downloader without a + /// listener never receives its file. + fn args(&self, user: &str) -> Vec { + self.args_with(user, &[]) + } + + /// The same, with flags that configure the *session* rather than the + /// command — shares, the listener, where downloads land. Locally they go + /// to the run itself; in daemon mode they configure the daemon, because + /// that is whose session they now describe. + fn args_with(&self, user: &str, session_flags: &[String]) -> Vec { + match mode() { + Mode::Local => { + let mut args = + self.args_on(user, free_port().expect("listener port")); + args.extend(session_flags.iter().cloned()); + args + } + Mode::Daemon => self.daemon_args(user, session_flags), + } + } + + /// The same arguments on a chosen listener port, for the tests about what + /// happens when two runs want one port. + fn args_on(&self, user: &str, port: u16) -> Vec { + vec![ + "--no-config".to_string(), + "--quiet".to_string(), + "--server".to_string(), + self.address(), + "--username".to_string(), + user.to_string(), + "--password".to_string(), + "pw".to_string(), + "--listener-port".to_string(), + port.to_string(), + ] + } + + /// Client arguments pointing at a daemon logged in as `user`, starting one + /// if this account has not been seen. + /// + /// Strictly one daemon per account: the server allows a single session per + /// name, so a second daemon under the same name would displace the first + /// and every command already routed to it would start failing with + /// "session lost". The session flags of the first call configure it — a + /// later call's are ignored, which is exactly what happens in real daemon + /// mode, where shares and the listener belong to the daemon. + fn daemon_args(&self, user: &str, session_flags: &[String]) -> Vec { + let socket = self.daemon_for(user, session_flags); + vec![ + "--no-config".to_string(), + "--quiet".to_string(), + "--daemon".to_string(), + socket.display().to_string(), + ] + } + + /// The state directory of the daemon serving `user`, where its socket and + /// token live. + fn daemon_state(&self, user: &str) -> PathBuf { + self.daemon_for(user, &[]); + let key = format!("{}|{user}", self.address()); + let daemons = self.daemons.lock().expect("daemon registry"); + daemons + .get(&key) + .expect("the daemon was just started") + .state + .clone() + } + + fn daemon_for(&self, user: &str, session_flags: &[String]) -> PathBuf { + let key = format!("{}|{user}", self.address()); + let mut daemons = self.daemons.lock().expect("daemon registry"); + if let Some(existing) = daemons.get(&key) { + return existing.socket.clone(); + } + let handle = DaemonFixture::start(self, user, session_flags); + let socket = handle.socket.clone(); + daemons.insert(key, handle); + socket + } + + /// An in-process client, used to stand up the other end of a test. + fn client(&self, user: &str, shares: Vec) -> Client { + login(&self.host, self.port, user, shares) + } +} + +fn wait_until_listening( + host: &str, + port: u16, + timeout: Duration, +) -> Option<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Ok(mut addrs) = format!("{host}:{port}").to_socket_addrs() + && let Some(addr) = addrs.next() + && TcpStream::connect_timeout(&addr, Duration::from_millis(200)) + .is_ok() + { + return Some(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +macro_rules! server_or_skip { + () => { + match TestServer::resolve() { + Some(server) => server, + None => { + common::no_server("cli e2e"); + return; + } + } + }; +} + +/// A scratch directory that cleans itself up. +struct Scratch(PathBuf); + +impl Scratch { + fn new(label: &str) -> Self { + static COUNTER: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "soulseek-cli-e2e-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("scratch directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn display(&self) -> String { + self.0.display().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Contents of the file every transfer test shares. +fn probe_bytes() -> Vec { + (0..4096u32).map(|i| (i % 251) as u8).collect() +} + +/// Run the binary against `server` as `user`, appending `args`. +/// +/// In daemon mode the same call is served by a daemon instead. The flags that +/// shape a *session* rather than a command — shares, the listener, where +/// downloads land — are handed to the daemon rather than the client, because +/// that is where they now belong. The test itself does not change, which is +/// the whole point: if the records and exit code come out the same either way, +/// daemon mode is equivalent. +fn cli(server: &TestServer, user: &str, args: &[&str]) -> Output { + let (session_flags, command_args) = match mode() { + // Locally a flag is a flag: the run configures its own session. + Mode::Local => { + (Vec::new(), args.iter().map(|a| (*a).to_string()).collect()) + } + Mode::Daemon => split_session_flags(args), + }; + let mut all = server.args_with(user, &session_flags); + all.extend(command_args); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + run(&refs) +} + +/// Split arguments into the ones that configure a session and the ones that +/// are the command. Only meaningful in daemon mode, where the two halves go to +/// different processes. +fn split_session_flags(args: &[&str]) -> (Vec, Vec) { + /// Flags that take a value and describe the session, not the command. + const SESSION_PAIRS: [&str; 4] = [ + "--shared-dir", + "--listener-port", + "--download-dir", + "--max-concurrent-downloads", + ]; + /// Session flags that stand alone. + const SESSION_SWITCHES: [&str; 2] = ["--no-listener", "--listener"]; + + let mut session = Vec::new(); + let mut command = Vec::new(); + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + if SESSION_PAIRS.contains(arg) { + session.push((*arg).to_string()); + if let Some(value) = rest.next() { + session.push((*value).to_string()); + } + } else if SESSION_SWITCHES.contains(arg) { + session.push((*arg).to_string()); + } else { + command.push((*arg).to_string()); + } + } + (session, command) +} + +/// Like [`cli`], but keeping a config file so a command that reads or writes +/// one (the wishlist) sees it. +/// +/// `--no-config` is dropped for the obvious reason, and `--quiet` with it: +/// these commands report the schedule the *server* chose on stderr, which is +/// the only place a test can see it. Session flags go to the daemon, as in +/// [`cli`]. +fn cli_with_config( + server: &TestServer, + user: &str, + config: &Path, + args: &[&str], +) -> Output { + let (session_flags, command_args) = match mode() { + Mode::Local => { + (Vec::new(), args.iter().map(|a| (*a).to_string()).collect()) + } + Mode::Daemon => split_session_flags(args), + }; + let mut all: Vec = server + .args_with(user, &session_flags) + .into_iter() + .filter(|arg| arg != "--no-config" && arg != "--quiet") + .collect(); + all.push("--config".to_string()); + all.push(config.to_str().expect("utf-8 path").to_string()); + all.extend(command_args); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + run(&refs) +} + +#[test] +fn a_wrong_password_is_a_connection_error() { + let server = server_or_skip!(); + // Registers the name with password "pw". + let _owner = server.client("cli_e2e_owner", Vec::new()); + + let output = run(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_owner", + "--password", + "definitely-wrong", + "--no-listener", + "room", + "list", + ]); + assert_eq!(code(&output), EXIT_CONNECTION); + assert!(stdout(&output).is_empty()); +} + +#[test] +fn search_prints_a_record_that_download_can_consume() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_qwix.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_a", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_a", + &["--search-timeout", "5", "search", "qwix"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = records(&output) + .into_iter() + .find(|line| line.contains("cli_probe_qwix")) + .expect("the sharer's file should be listed"); + + // user TAB size TAB bitrate TAB path — the columns `download --stdin` + // reads back. + let fields: Vec<&str> = record.split('\t').collect(); + assert_eq!(fields.len(), 4, "record was {record:?}"); + assert_eq!(fields[0], "cli_e2e_sharer_a"); + assert_eq!(fields[1], probe_bytes().len().to_string()); + assert!(fields[3].contains("cli_probe_qwix.bin")); +} + +#[test] +fn search_json_records_carry_the_fields_a_filter_needs() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_zorb.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_b", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_b", + &["--search-timeout", "5", "--json", "search", "zorb"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record: serde_json::Value = records(&output) + .iter() + .map(|line| { + serde_json::from_str::(line) + .expect("each line should be valid JSON") + }) + .find(|value| { + value["path"].as_str().is_some_and(|p| p.contains("zorb")) + }) + .expect("the sharer's file should be listed"); + + assert_eq!(record["user"], "cli_e2e_sharer_b"); + assert_eq!(record["size"], probe_bytes().len() as u64); + assert!(record["free_slot"].is_boolean()); + assert!(record.get("speed").is_some()); +} + +#[test] +fn a_search_with_no_matches_exits_with_the_no_results_code() { + let server = server_or_skip!(); + let _peer = server.client("cli_e2e_quiet_peer", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_seeker_c", + &["--search-timeout", "3", "search", "nothingmatchesthisqzx"], + ); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +/// One share holding three files that differ in name, extension and size, so +/// every result filter has something to keep and something to drop. +/// +/// `flt_beta_skipme.flac` is deliberately the small one and +/// `flt_gamma_keepme.ogg` the large one, so a size bound and an extension +/// bound select different files and a combination of the two is a real test +/// rather than two spellings of the same assertion. +struct FilterFixture { + _share: Scratch, +} + +impl FilterFixture { + const SMALL: usize = 128; + const MEDIUM: usize = 4096; + const LARGE: usize = 8192; + + fn new(server: &TestServer, sharer: &str) -> (Self, Client) { + let share = Scratch::new("filter-share"); + // The mp3 is a real 128 kbps stream so the bitrate filter has + // something to read; the others are padding of a known size. + std::fs::write(share.path().join("flt_alpha_keepme.mp3"), cbr_mp3(10)) + .expect("share file"); + for (name, size) in [ + ("flt_beta_skipme.flac", Self::SMALL), + ("flt_gamma_keepme.ogg", Self::LARGE), + ] { + std::fs::write(share.path().join(name), vec![7u8; size]) + .expect("share file"); + } + let client = server.client(sharer, vec![share.display()]); + settle(); + (Self { _share: share }, client) + } +} + +/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps and +/// 44.1 kHz, 417 bytes each; ten of them land between the fixture's SMALL +/// and LARGE sizes. +fn cbr_mp3(frames: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..frames { + out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); + out.resize(out.len() + 413, 0); + } + out +} + +/// Search for the fixture as a fresh user and return the matching file names, +/// sorted so the assertion does not depend on result order. +/// +/// A new username per call matters: soulfind answers a repeat of the same +/// search from the same user with nothing. +fn filtered_names( + server: &TestServer, + seeker: &str, + filter: &[&str], +) -> (i32, Vec) { + let mut args = vec!["--search-timeout", "5", "search", "flt"]; + args.extend_from_slice(filter); + let output = cli(server, seeker, &args); + let mut names: Vec = records(&output) + .iter() + .filter_map(|line| line.split('\t').nth(3)) + .filter_map(|path| path.rsplit(['/', '\\']).next()) + .filter(|name| name.starts_with("flt_")) + .map(String::from) + .collect(); + names.sort(); + (code(&output), names) +} + +#[test] +fn exclude_drops_the_results_whose_path_contains_the_term() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_excl"); + + let (exit, names) = + filtered_names(&server, "cli_e2e_seek_excl", &["--exclude", "skipme"]); + assert_eq!(exit, EXIT_OK); + assert_eq!(names, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]); +} + +#[test] +fn extension_keeps_one_type_and_repeats_into_a_union() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_ext"); + + let (exit, one) = filtered_names( + &server, + "cli_e2e_seek_ext_one", + &["--extension", "mp3"], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(one, ["flt_alpha_keepme.mp3"]); + + let (exit, several) = filtered_names( + &server, + "cli_e2e_seek_ext_many", + &["--extension", ".flac", "--extension", "OGG"], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]); +} + +#[test] +fn a_bitrate_floor_keeps_only_files_that_advertise_at_least_that_much() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_kbps"); + + // Only the mp3 carries a bitrate; the padding files have none and an + // unknown bitrate cannot satisfy a floor. + let (exit, enough) = filtered_names( + &server, + "cli_e2e_seek_kbps_128", + &["--min-bitrate", "128"], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(enough, ["flt_alpha_keepme.mp3"]); + + let (exit, none) = filtered_names( + &server, + "cli_e2e_seek_kbps_320", + &["--min-bitrate", "320"], + ); + assert_eq!(exit, EXIT_NO_RESULTS); + assert!(none.is_empty()); +} + +#[test] +fn size_bounds_narrow_the_result_set_from_either_end() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_size"); + + let (exit, big) = filtered_names( + &server, + "cli_e2e_seek_size_min", + &["--min-size", &FilterFixture::MEDIUM.to_string()], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(big, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]); + + let (exit, small) = filtered_names( + &server, + "cli_e2e_seek_size_max", + &["--max-size", &FilterFixture::SMALL.to_string()], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(small, ["flt_beta_skipme.flac"]); +} + +#[test] +fn filters_given_together_all_have_to_be_satisfied() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_comb"); + + let (exit, names) = filtered_names( + &server, + "cli_e2e_seek_comb", + &[ + "--exclude", + "skipme", + "--min-size", + &FilterFixture::LARGE.to_string(), + "--extension", + "ogg", + ], + ); + assert_eq!(exit, EXIT_OK); + assert_eq!(names, ["flt_gamma_keepme.ogg"]); +} + +#[test] +fn a_filter_that_matches_nothing_is_the_no_results_code() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_none"); + + let output = cli( + &server, + "cli_e2e_seek_none", + &[ + "--search-timeout", + "5", + "search", + "flt", + "--extension", + "aiff", + ], + ); + assert_eq!( + code(&output), + EXIT_NO_RESULTS, + "stderr: {}", + stderr(&output) + ); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn get_honours_the_same_filters_search_does() { + let server = server_or_skip!(); + let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_get"); + let target = Scratch::new("filter-downloads"); + + let output = cli( + &server, + "cli_e2e_seek_get", + &[ + "--search-timeout", + "5", + "--download-dir", + &target.display(), + "get", + "flt", + "--extension", + "ogg", + "--timeout", + "30", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + target.path().join("flt_gamma_keepme.ogg").exists(), + "the only .ogg should be what got fetched; stdout: {}", + stdout(&output) + ); +} + +#[test] +fn wish_run_finds_a_shared_file_through_a_wishlist_search() { + let server = server_or_skip!(); + let share = Scratch::new("wish-share"); + std::fs::write(share.path().join("wisher_probe_krel.mp3"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_wish_sharer", vec![share.display()]); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + settle(); + + let added = with_config(&config, &["wish", "add", "krel"]); + assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added)); + + let output = cli_with_config( + &server, + "cli_e2e_wish_seeker", + &config, + &["--search-timeout", "5", "wish", "run"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = records(&output) + .into_iter() + .find(|line| line.contains("wisher_probe_krel")) + .expect("the wishlist search should find the sharer's file"); + // The same four columns `search` prints, so this pipes into + // `download --stdin` with no reshaping. + let fields: Vec<&str> = record.split('\t').collect(); + assert_eq!(fields.len(), 4, "record was {record:?}"); + assert_eq!(fields[0], "cli_e2e_wish_sharer"); + assert_eq!(fields[1], probe_bytes().len().to_string()); +} + +#[test] +fn wish_run_output_feeds_download_stdin() { + let server = server_or_skip!(); + let share = Scratch::new("wish-share"); + std::fs::write(share.path().join("wisher_probe_vosk.mp3"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_wish_sharer_b", vec![share.display()]); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + let target = Scratch::new("wish-downloads"); + settle(); + + with_config(&config, &["wish", "add", "vosk"]); + let found = cli_with_config( + &server, + "cli_e2e_wish_seeker_b", + &config, + &["--search-timeout", "5", "--json", "wish", "run"], + ); + assert_eq!(code(&found), EXIT_OK, "stderr: {}", stderr(&found)); + + let mut child = command(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_wish_puller", + "--password", + "pw", + "--listener-port", + &free_port().expect("listener port").to_string(), + "--download-dir", + &target.display(), + "download", + "--stdin", + "--timeout", + "30", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("download should start"); + child + .stdin + .take() + .expect("stdin") + .write_all(stdout(&found).as_bytes()) + .expect("feed the records back"); + let pulled = child.wait_with_output().expect("download should finish"); + + assert_eq!(code(&pulled), EXIT_OK, "stderr: {}", stderr(&pulled)); + assert!( + target.path().join("wisher_probe_vosk.mp3").exists(), + "the wished-for file should have landed; stdout: {}", + stdout(&pulled) + ); +} + +#[test] +fn wish_run_with_an_empty_wishlist_is_the_no_results_code() { + let server = server_or_skip!(); + let _peer = server.client("cli_e2e_wish_quiet", Vec::new()); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + + let output = cli_with_config( + &server, + "cli_e2e_wish_none", + &config, + &["wish", "run"], + ); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); +} + +#[test] +fn wish_run_only_must_name_a_stored_wish() { + let server = server_or_skip!(); + let _peer = server.client("cli_e2e_wish_quiet_b", Vec::new()); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + with_config(&config, &["wish", "add", "stored one"]); + + let unknown = cli_with_config( + &server, + "cli_e2e_wish_only_bad", + &config, + &["wish", "run", "--only", "not stored"], + ); + assert_eq!(code(&unknown), EXIT_USAGE, "a typo is not an ad-hoc search"); + assert!(stdout(&unknown).is_empty()); +} + +#[test] +fn wish_run_only_narrows_the_sweep_to_that_wish() { + let server = server_or_skip!(); + let share = Scratch::new("wish-share"); + for name in ["wisher_probe_zant.mp3", "wisher_probe_hurl.mp3"] { + std::fs::write(share.path().join(name), probe_bytes()) + .expect("share file"); + } + let _sharer = server.client("cli_e2e_wish_sharer_c", vec![share.display()]); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + settle(); + + with_config(&config, &["wish", "add", "zant"]); + with_config(&config, &["wish", "add", "hurl"]); + + let output = cli_with_config( + &server, + "cli_e2e_wish_only_ok", + &config, + &["--search-timeout", "5", "wish", "run", "--only", "ZANT"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let paths = stdout(&output); + assert!(paths.contains("wisher_probe_zant"), "stdout was {paths:?}"); + assert!( + !paths.contains("wisher_probe_hurl"), + "--only must not run the other wish; stdout was {paths:?}" + ); +} + +#[test] +fn wish_run_filters_apply_to_a_wishlist_search_too() { + let server = server_or_skip!(); + let share = Scratch::new("wish-share"); + std::fs::write(share.path().join("wisher_probe_gleb.mp3"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("wisher_probe_gleb.flac"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_wish_sharer_d", vec![share.display()]); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + settle(); + + with_config(&config, &["wish", "add", "gleb"]); + let output = cli_with_config( + &server, + "cli_e2e_wish_filtered", + &config, + &[ + "--search-timeout", + "5", + "wish", + "run", + "--extension", + "flac", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let paths = stdout(&output); + assert!(paths.contains("wisher_probe_gleb.flac"), "{paths:?}"); + assert!(!paths.contains("wisher_probe_gleb.mp3"), "{paths:?}"); +} + +#[test] +fn serve_with_the_wishlist_sweeps_on_the_interval_the_server_announced() { + let server = server_or_skip!(); + let their_share = Scratch::new("wish-their-share"); + std::fs::write( + their_share.path().join("wisher_probe_yalt.mp3"), + probe_bytes(), + ) + .expect("share file"); + let _sharer = + server.client("cli_e2e_wish_sharer_e", vec![their_share.display()]); + + // A serving client needs something of its own to share. + let our_share = Scratch::new("wish-our-share"); + std::fs::write(our_share.path().join("ours.bin"), probe_bytes()) + .expect("share file"); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + settle(); + + with_config(&config, &["wish", "add", "yalt"]); + let output = cli_with_config( + &server, + "cli_e2e_wish_server", + &config, + &[ + "--search-timeout", + "5", + "--shared-dir", + &our_share.display(), + "serve", + "--wishlist", + "--duration", + "12", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).contains("wisher_probe_yalt"), + "the first sweep happens at startup; stdout: {}", + stdout(&output) + ); + // Soulfind announces 12 minutes for an unprivileged account, and the + // schedule is the server's to set — so that is what must be reported. + assert!( + stderr(&output).contains("next wishlist sweep in 720s"), + "the announced interval should drive the timer; stderr: {}", + stderr(&output) + ); +} + +#[test] +fn serve_wishlist_with_nothing_wished_for_still_serves() { + let server = server_or_skip!(); + let our_share = Scratch::new("wish-our-share"); + std::fs::write(our_share.path().join("ours.bin"), probe_bytes()) + .expect("share file"); + let dir = Scratch::new("wish-config"); + let config = dir.path().join("config.toml"); + + let output = cli_with_config( + &server, + "cli_e2e_wish_server_b", + &config, + &[ + "--shared-dir", + &our_share.display(), + "serve", + "--wishlist", + "--duration", + "2", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("nothing to look for"), + "an empty wishlist should be said once, not serve-and-be-silent; \ + stderr: {}", + stderr(&output) + ); +} + +#[test] +fn browse_lists_shares_as_paths_download_understands() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("browsable.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_c", vec![share.display()]); + settle(); + + let output = + cli(&server, "cli_e2e_browser", &["browse", "cli_e2e_sharer_c"]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = records(&output) + .into_iter() + .find(|line| line.contains("browsable.bin")) + .expect("the shared file should be listed"); + + // user TAB size TAB path + let fields: Vec<&str> = record.split('\t').collect(); + assert_eq!(fields.len(), 3, "record was {record:?}"); + assert_eq!(fields[0], "cli_e2e_sharer_c"); + assert_eq!(fields[1], probe_bytes().len().to_string()); +} + +#[test] +fn transfer_cancel_without_a_matching_transfer_is_the_no_results_verdict() { + let server = server_or_skip!(); + let output = cli( + &server, + "cli_e2e_canceller", + &["transfer", "cancel", "nobody", "nothing.mp3"], + ); + assert_eq!( + code(&output), + EXIT_NO_RESULTS, + "stderr: {}", + stderr(&output) + ); + assert!( + stderr(&output).contains("nothing.mp3"), + "the verdict names the transfer: {}", + stderr(&output) + ); +} + +#[test] +fn transfer_cancel_stops_a_download_another_client_is_waiting_on() { + if mode() != Mode::Daemon { + println!("skipped: a one-shot download owns its own session"); + return; + } + let server = server_or_skip!(); + let _peer = + silent_peer(&server.host, server.port, "cli_e2e_cancel_silent_peer"); + let mut all = server.args("cli_e2e_cancel_waiter"); + all.extend( + [ + "download", + "cli_e2e_cancel_silent_peer", + "@@silent\\never.mp3", + "--size", + "10", + "--timeout", + "60", + ] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + let waiting = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should start"); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut cancelled = None; + while Instant::now() < deadline { + let output = cli( + &server, + "cli_e2e_cancel_waiter", + &[ + "transfer", + "cancel", + "cli_e2e_cancel_silent_peer", + "@@silent\\never.mp3", + ], + ); + if code(&output) == EXIT_OK { + cancelled = Some(output); + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + assert!( + cancelled.is_some(), + "the queued transfer should be cancellable" + ); + + let waited = waiting.wait_with_output().expect("wait"); + assert_eq!(code(&waited), EXIT_TRANSFER, "stderr: {}", stderr(&waited)); + assert!(stderr(&waited).contains("cancelled"), "{}", stderr(&waited)); +} + +#[test] +fn download_fetches_a_file_and_prints_where_it_landed() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("fetch_me.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_d", vec![share.display()]); + settle(); + + // Learn the peer-side path the way a script would. + let listing = + cli(&server, "cli_e2e_fetcher", &["browse", "cli_e2e_sharer_d"]); + assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing)); + let row = records(&listing) + .into_iter() + .find(|line| line.contains("fetch_me.bin")) + .expect("the shared file should be listed"); + let remote_path = row.rsplit('\t').next().expect("path column").to_string(); + + let output = cli( + &server, + "cli_e2e_fetcher", + &[ + "--download-dir", + &target.display(), + "download", + "cli_e2e_sharer_d", + &remote_path, + "--size", + &probe_bytes().len().to_string(), + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1, "one file, one record"); + let written = std::fs::read(reported[0].trim()) + .expect("the reported path should be the file that was written"); + assert_eq!(written, probe_bytes()); +} + +#[test] +fn download_without_a_size_looks_it_up_in_the_peers_shares() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("sizeless.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_g", vec![share.display()]); + settle(); + + let listing = + cli(&server, "cli_e2e_sizeless", &["browse", "cli_e2e_sharer_g"]); + let remote_path = records(&listing) + .into_iter() + .find(|line| line.contains("sizeless.bin")) + .and_then(|row| row.rsplit('\t').next().map(String::from)) + .expect("the shared file should be listed"); + + // No --size: the size has to come from the peer's listing, and the remote + // path there is the full backslash path, not the bare file name. + let output = cli( + &server, + "cli_e2e_sizeless", + &[ + "--download-dir", + &target.display(), + "download", + "cli_e2e_sharer_g", + &remote_path, + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn download_reads_the_records_search_emits_from_stdin() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("piped.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_e", vec![share.display()]); + settle(); + + let listing = + cli(&server, "cli_e2e_piper", &["browse", "cli_e2e_sharer_e"]); + let row = records(&listing) + .into_iter() + .find(|line| line.contains("piped.bin")) + .expect("the shared file should be listed"); + + let mut args = server.args("cli_e2e_piper"); + args.extend( + [ + "--download-dir", + &target.display(), + "download", + "--stdin", + "--timeout", + "40", + ] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let mut child = command(&refs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(format!("{row}\n").as_bytes()) + .expect("write the record"); + let output = child.wait_with_output().expect("the binary should finish"); + + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let reported = records(&output); + assert_eq!(reported.len(), 1); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn get_searches_picks_and_downloads_in_one_command() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("cli_probe_vroom.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_f", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_getter", + &[ + "--search-timeout", + "5", + "--download-dir", + &target.display(), + "get", + "vroom", + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let reported = records(&output); + assert_eq!(reported.len(), 1, "--pick best downloads one file"); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +#[test] +fn get_without_matches_exits_no_results_and_downloads_nothing() { + let server = server_or_skip!(); + let target = Scratch::new("downloads"); + let _peer = server.client("cli_e2e_silent_peer", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_getter_b", + &[ + "--search-timeout", + "3", + "--download-dir", + &target.display(), + "get", + "nothingmatchesthisqzx", + ], + ); + assert_eq!(code(&output), EXIT_NO_RESULTS); + assert_eq!( + std::fs::read_dir(target.path()) + .expect("download dir") + .count(), + 0, + "nothing should have been written" + ); +} + +#[test] +fn room_say_reaches_a_listener_and_room_list_shows_the_room() { + let server = server_or_skip!(); + let listener = server.client("cli_e2e_room_listener", Vec::new()); + listener.join_room("cli_e2e_lobby").expect("join"); + settle(); + + let said = cli( + &server, + "cli_e2e_speaker", + &["room", "say", "cli_e2e_lobby", "hello from a script"], + ); + assert_eq!(code(&said), EXIT_OK, "stderr: {}", stderr(&said)); + assert!( + stdout(&said).is_empty(), + "an action command emits no records" + ); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut heard = false; + while Instant::now() < deadline && !heard { + heard = listener.take_room_events().iter().any(|event| { + matches!( + event, + soulseek_rs::types::RoomEvent::Message { message, .. } + if message == "hello from a script" + ) + }); + std::thread::sleep(Duration::from_millis(100)); + } + assert!(heard, "the room should have received the message"); + + let listed = cli(&server, "cli_e2e_lister", &["room", "list"]); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + let row = records(&listed) + .into_iter() + .find(|line| line.ends_with("cli_e2e_lobby")) + .expect("the joined room should be listed"); + let (users, _) = row.split_once('\t').expect("users TAB room"); + assert!( + users.parse::().is_ok(), + "the user count should be a bare number, got {users:?}" + ); +} + +#[test] +fn room_listen_streams_what_is_said_in_the_room() { + let server = server_or_skip!(); + let speaker = server.client("cli_e2e_room_speaker", Vec::new()); + speaker.join_room("cli_e2e_lobby_b").expect("join"); + settle(); + + let mut args = server.args("cli_e2e_room_reader"); + args.extend( + [ + "room", + "listen", + "cli_e2e_lobby_b", + "--duration", + "8", + "--json", + ] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let child = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + + // The room announces the listener once the server has it in; a line + // said before that never reaches it. + let deadline = Instant::now() + Duration::from_secs(30); + while !speaker.take_room_events().iter().any(|event| { + matches!( + event, + soulseek_rs::types::RoomEvent::UserJoined { room, username } + if room == "cli_e2e_lobby_b" && username == "cli_e2e_room_reader" + ) + }) { + assert!(Instant::now() < deadline, "the listener never joined"); + std::thread::sleep(Duration::from_millis(50)); + } + speaker + .say_in_room("cli_e2e_lobby_b", "streamed line") + .expect("say"); + + // A second arrival: the README says a join is its own record type, with an + // empty message field. + let latecomer = server.client("cli_e2e_room_joiner", Vec::new()); + latecomer.join_room("cli_e2e_lobby_b").expect("join"); + + let output = child.wait_with_output().expect("the binary should finish"); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let events: Vec = records(&output) + .iter() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + + let said = events + .iter() + .find(|event| event["message"] == "streamed line") + .unwrap_or_else(|| panic!("stdout was: {:?}", stdout(&output))); + assert_eq!(said["type"], "message"); + assert_eq!(said["room"], "cli_e2e_lobby_b"); + assert_eq!(said["user"], "cli_e2e_room_speaker"); + + // The listener's own arrival is a join too, so pick the one that matters. + let joined = events + .iter() + .find(|event| event["user"] == "cli_e2e_room_joiner") + .unwrap_or_else(|| panic!("no join record in {:?}", stdout(&output))); + assert_eq!(joined["type"], "join"); + assert_eq!(joined["room"], "cli_e2e_lobby_b"); + assert_eq!(joined["message"], ""); +} + +#[test] +fn a_private_message_reaches_its_recipient() { + let server = server_or_skip!(); + let recipient = server.client("cli_e2e_recipient", Vec::new()); + settle(); + + let output = cli( + &server, + "cli_e2e_sender", + &["message", "send", "cli_e2e_recipient", "ping from a script"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).is_empty(), + "an action command emits no records" + ); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut received = false; + while Instant::now() < deadline && !received { + received = recipient + .take_private_messages() + .iter() + .any(|message| message.message() == "ping from a script"); + std::thread::sleep(Duration::from_millis(100)); + } + assert!(received, "the recipient should have received the message"); +} + +#[test] +fn message_read_streams_incoming_private_messages() { + let server = server_or_skip!(); + let sender = server.client("cli_e2e_pm_sender", Vec::new()); + // soulfind drops a message to a name it has never seen but keeps one for + // a known user until they log in, so however long the reader takes to + // get online, the line below reaches it. Registered before the reader's + // own session exists: a later login as them would take its place. + drop(server.client("cli_e2e_pm_reader", Vec::new())); + settle(); + + let mut args = server.args("cli_e2e_pm_reader"); + args.extend( + ["--json", "message", "read", "--duration", "8"] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let child = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + + std::thread::sleep(Duration::from_secs(3)); + sender + .send_private_message("cli_e2e_pm_reader", "inbox line") + .expect("send"); + + let output = child.wait_with_output().expect("the binary should finish"); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let read = records(&output).iter().any(|line| { + serde_json::from_str::(line).is_ok_and(|value| { + value["message"] == "inbox line" + && value["user"] == "cli_e2e_pm_sender" + }) + }); + assert!(read, "stdout was: {:?}", stdout(&output)); +} + +// --------------------------------------------------------------------------- +// Being a peer: whoami, user, serve, shares, room users +// --------------------------------------------------------------------------- + +#[test] +fn whoami_confirms_the_login_and_reports_what_we_offer() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("offered.bin"), probe_bytes()) + .expect("share file"); + + let output = cli( + &server, + "cli_e2e_whoami", + &["--shared-dir", &share.display(), "--json", "whoami"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = json_record(&output); + assert_eq!(record["user"], "cli_e2e_whoami"); + assert_eq!(record["shared_files"], 1, "the shared file is indexed"); + assert!(record["server"].as_str().is_some_and(|s| s.contains(':'))); +} + +#[test] +fn whoami_reports_how_much_privilege_time_the_account_has() { + let server = server_or_skip!(); + let _owner = server.client("cli_e2e_priv_owner", Vec::new()); + + let output = cli(&server, "cli_e2e_priv_owner", &["--json", "whoami"]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = json_record(&output); + // Zero, not null: the server answered, and the answer is "no privileges". + // Null would mean it never said, which is a different fact. + assert_eq!( + record["privilege_seconds"], 0, + "a plain account should read as zero seconds, got {record}" + ); +} + +#[test] +fn serve_reports_a_queued_upload_once_the_slots_are_full() { + let server = server_or_skip!(); + let share = Scratch::new("queue-share"); + for name in ["q_one.bin", "q_two.bin"] { + std::fs::write(share.path().join(name), probe_bytes()) + .expect("share file"); + } + let target_a = Scratch::new("queue-dl-a"); + let target_b = Scratch::new("queue-dl-b"); + + let serving = command(&[ + "--no-config", + "--server", + &server.address(), + "--username", + "cli_e2e_queue_server", + "--password", + "pw", + "--listener-port", + &free_port().expect("listener port").to_string(), + "--shared-dir", + &share.display(), + "--json", + "serve", + "--upload-slots", + "1", + "--duration", + "18", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("serve should start"); + settle(); + settle(); + + // Two peers pull at once against a single slot, so one of them has to wait. + let mut pulls = Vec::new(); + for (user, target) in [ + ("cli_e2e_queue_a", &target_a), + ("cli_e2e_queue_b", &target_b), + ] { + pulls.push( + command(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + user, + "--password", + "pw", + "--listener-port", + &free_port().expect("listener port").to_string(), + "--download-dir", + &target.display(), + "--search-timeout", + "5", + "get", + "q_", + "--pick", + "all", + "--timeout", + "30", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("puller should start"), + ); + } + for mut pull in pulls { + let _ = pull.wait(); + } + + let uploaded = serving.wait_with_output().expect("serve should finish"); + let statuses: Vec = stdout(&uploaded) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|value| { + value["status"] + .as_str() + .map(std::string::ToString::to_string) + }) + .collect(); + assert!( + statuses.iter().any(|status| status == "completed"), + "at least one upload should have gone through; stdout: {}", + stdout(&uploaded) + ); + assert!( + statuses.iter().any(|status| status == "queued"), + "with one slot and two peers pulling, `serve` should report a queued \ + upload; statuses were {statuses:?}" + ); +} + +#[test] +fn whoami_fails_with_the_connection_code_on_a_bad_password() { + let server = server_or_skip!(); + let _owner = server.client("cli_e2e_whoami_owner", Vec::new()); + + let output = run(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_whoami_owner", + "--password", + "wrong", + "--no-listener", + "whoami", + ]); + assert_eq!(code(&output), EXIT_CONNECTION); +} + +#[test] +fn user_reports_a_peers_status_and_share_counts() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::create_dir_all(share.path().join("album")).expect("album"); + for name in ["a.bin", "b.bin"] { + std::fs::write(share.path().join("album").join(name), probe_bytes()) + .expect("share file"); + } + let _subject = server.client("cli_e2e_subject", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_asker", + &["--json", "user", "cli_e2e_subject"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = json_record(&output); + assert_eq!(record["user"], "cli_e2e_subject"); + assert_ne!(record["status"], "offline", "the subject is logged in"); + assert_eq!(record["shared_files"], 2); +} + +#[test] +fn serve_stays_online_and_reports_an_upload_a_peer_pulls() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("served.bin"), probe_bytes()) + .expect("share file"); + + // A serving process: online, sharing, streaming upload records. The share + // is a session flag: in daemon mode it must reach the daemon, whose + // session does the sharing — handed to the run itself it would be + // silently ignored and the daemon would serve its default folder. + let mut args = server.args_with( + "cli_e2e_server", + &["--shared-dir".to_string(), share.display()], + ); + args.retain(|arg| arg != "--quiet"); + args.extend( + ["--json", "serve", "--duration", "25"] + .iter() + .map(|a| (*a).to_string()), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let child = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + + // Give it time to log in and register its listen port, then pull a file + // from it the way any peer would: browse to learn the path, then download. + std::thread::sleep(Duration::from_secs(4)); + let listing = cli(&server, "cli_e2e_puller", &["browse", "cli_e2e_server"]); + let remote_path = records(&listing) + .into_iter() + .find(|line| line.contains("served.bin")) + .and_then(|row| row.rsplit('\t').next().map(String::from)) + .unwrap_or_else(|| { + panic!( + "a serving process should answer a browse; exit={} stderr={}", + code(&listing), + stderr(&listing) + ) + }); + + let puller = cli( + &server, + "cli_e2e_puller", + &[ + "--download-dir", + &target.display(), + "download", + "cli_e2e_server", + &remote_path, + "--size", + &probe_bytes().len().to_string(), + "--timeout", + "20", + ], + ); + + let output = child.wait_with_output().expect("serve should finish"); + assert_eq!(code(&output), EXIT_OK, "serve stderr: {}", stderr(&output)); + + // The download may take the browse path for its remote name; what this + // test pins is that serve REPORTED the upload it served. + let uploads: Vec = records(&output) + .iter() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert!( + !uploads.is_empty(), + "serve should report the upload. puller exit={} stderr={} serve stdout={:?}", + code(&puller), + stderr(&puller), + stdout(&output) + ); + let last = &uploads[uploads.len() - 1]; + assert_eq!(last["user"], "cli_e2e_puller"); + assert!( + last["path"] + .as_str() + .is_some_and(|p| p.contains("served.bin")) + ); + // The fields the README promises a JSON upload record carries. + for key in ["status", "user", "path", "bytes_sent", "size", "speed"] { + assert!( + last.get(key).is_some(), + "serve should carry `{key}`: {last}" + ); + } +} + +#[test] +fn serve_refuses_to_run_with_nothing_to_share() { + let server = server_or_skip!(); + let output = cli( + &server, + "cli_e2e_empty_server", + &["--shared-dir", "", "serve", "--duration", "5"], + ); + assert_eq!(code(&output), EXIT_USAGE); + assert!(stderr(&output).contains("share"), "got {}", stderr(&output)); +} + +#[test] +fn shares_status_reports_what_the_network_will_see() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + for name in ["one.bin", "two.bin", "three.bin"] { + std::fs::write(share.path().join(name), probe_bytes()) + .expect("share file"); + } + + let output = cli( + &server, + "cli_e2e_shares", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "status", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = json_record(&output); + assert_eq!(record["files"], 3); + assert!(record["folders"].as_u64().is_some_and(|n| n >= 1)); +} + +/// The index, file by file, in the records `browse` gives for a peer — the +/// point being that the two are comparable: what the network sees of us +/// reads exactly like what it sees of anyone else. +#[test] +fn shares_files_lists_the_index_as_a_peer_would_receive_it() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write(share.path().join("top.bin"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("album").join("deep.bin"), probe_bytes()) + .expect("share file"); + + let listed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + ], + ); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + + let paths: Vec = records(&listed) + .iter() + .map(|line| { + serde_json::from_str::(line).expect("JSON") + ["path"] + .as_str() + .expect("a path") + .to_string() + }) + .collect(); + assert_eq!(paths.len(), 2, "both files, got {paths:?}"); + assert!( + paths.iter().any(|p| p.ends_with("album\\deep.bin")), + "the nested file carries the backslash path peers download by: \ + {paths:?}" + ); + + let first = json_record(&listed); + assert_eq!(first["user"], "cli_e2e_share_files"); + assert!(first["size"].as_u64().is_some_and(|size| size > 0)); + + // The filter narrows to matching paths, and finding nothing is exit 4 + // rather than an empty success. + let narrowed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "DEEP", + ], + ); + assert_eq!(code(&narrowed), EXIT_OK, "stderr: {}", stderr(&narrowed)); + assert_eq!(records(&narrowed).len(), 1, "case-insensitively"); + + let missing = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "nothing-like-this", + ], + ); + assert_eq!(code(&missing), EXIT_NO_RESULTS); +} + +#[test] +fn shares_reindex_picks_up_a_file_added_after_startup() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("first.bin"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("second.bin"), probe_bytes()) + .expect("share file"); + + let output = cli( + &server, + "cli_e2e_reindex", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "reindex", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let record = json_record(&output); + assert_eq!(record["files"], 2, "re-indexing should see both files"); +} + +#[test] +fn room_users_lists_who_is_in_the_room() { + let server = server_or_skip!(); + let resident = server.client("cli_e2e_resident", Vec::new()); + resident.join_room("cli_e2e_roster").expect("join"); + // The member list reaches the resident once the server has them in. + let deadline = Instant::now() + Duration::from_secs(10); + while resident.room_members("cli_e2e_roster").is_empty() { + assert!(Instant::now() < deadline, "the resident never got in"); + std::thread::sleep(Duration::from_millis(50)); + } + + let output = cli( + &server, + "cli_e2e_roster_asker", + &["room", "users", "cli_e2e_roster"], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + records(&output) + .iter() + .any(|line| line == "cli_e2e_resident"), + "the resident should be listed, got {:?}", + records(&output) + ); +} + +// --------------------------------------------------------------------------- +// Configuration (no server needed) +// --------------------------------------------------------------------------- + +/// Run the binary against a throwaway config file. +fn with_config(config: &Path, args: &[&str]) -> Output { + let mut all = vec!["--config", config.to_str().expect("utf-8 path")]; + all.extend_from_slice(args); + run(&all) +} + +#[test] +fn config_set_writes_a_value_that_get_reads_back() { + let dir = Scratch::new("config"); + let config = dir.path().join("config.toml"); + + let set = + with_config(&config, &["config", "set", "download_dir", "/music"]); + assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set)); + + let got = with_config(&config, &["config", "get", "download_dir"]); + assert_eq!(code(&got), EXIT_OK); + assert_eq!(records(&got), ["download_dir\t/music"]); + + // And the next run actually starts from it. + let listed = with_config(&config, &["--json", "config", "list"]); + let values: Vec = records(&listed) + .iter() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert!( + values + .iter() + .any(|v| v["key"] == "download_dir" && v["value"] == "/music"), + "config list should show the saved value" + ); +} + +#[test] +fn config_rejects_an_unknown_key_and_a_bad_value() { + let dir = Scratch::new("config"); + let config = dir.path().join("config.toml"); + + let unknown = with_config(&config, &["config", "set", "colour", "blue"]); + assert_eq!(code(&unknown), EXIT_USAGE); + assert!(stderr(&unknown).contains("unknown setting")); + + let bad = + with_config(&config, &["config", "set", "listener_port", "not-a-port"]); + assert_eq!(code(&bad), EXIT_USAGE); +} + +#[test] +fn config_get_of_an_unset_key_is_the_no_results_code() { + let dir = Scratch::new("config"); + let config = dir.path().join("config.toml"); + let output = with_config(&config, &["config", "get", "username"]); + assert_eq!(code(&output), EXIT_NO_RESULTS); +} + +#[test] +fn config_path_reports_the_file_in_use() { + let dir = Scratch::new("config"); + let config = dir.path().join("config.toml"); + let output = with_config(&config, &["config", "path"]); + assert_eq!(code(&output), EXIT_OK); + assert!( + records(&output)[0].contains("config.toml"), + "got {:?}", + records(&output) + ); +} + +#[test] +fn shares_add_and_remove_persist_to_the_config_file() { + let dir = Scratch::new("config"); + let shared = Scratch::new("shared"); + let config = dir.path().join("config.toml"); + + let added = with_config(&config, &["shares", "add", &shared.display()]); + assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added)); + + let listed = with_config(&config, &["shares", "list"]); + assert_eq!(code(&listed), EXIT_OK); + assert!( + records(&listed) + .iter() + .any(|line| line.contains(&shared.display())), + "the added folder should be listed, got {:?}", + records(&listed) + ); + + let removed = + with_config(&config, &["shares", "remove", &shared.display()]); + assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed)); + + let after = with_config(&config, &["shares", "list"]); + assert!( + !records(&after) + .iter() + .any(|line| line.contains(&shared.display())), + "the folder should be gone, got {:?}", + records(&after) + ); +} + +#[test] +fn shares_add_refuses_a_folder_that_is_not_there() { + let dir = Scratch::new("config"); + let config = dir.path().join("config.toml"); + let output = + with_config(&config, &["shares", "add", "/definitely/not/here"]); + assert_eq!(code(&output), EXIT_USAGE); + assert!( + stderr(&output).contains("does not exist"), + "got {}", + stderr(&output) + ); +} + +/// The contract every scripted consumer relies on: with `--json`, stdout +/// carries newline-delimited JSON and nothing else, while the progress +/// narration still goes to stderr. +#[test] +fn json_mode_keeps_stdout_pure_for_every_command() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("purity_probe.bin"), probe_bytes()) + .expect("share file"); + let peer = server.client("cli_e2e_purity_peer", vec![share.display()]); + peer.join_room("cli_e2e_purity_room").expect("join"); + settle(); + + // Deliberately NOT --quiet: progress must be produced, and must not land + // on stdout. Commands are run for their output shape, not their verdict — + // several legitimately exit non-zero here. + let cases: Vec> = vec![ + vec!["whoami"], + vec!["search", "purity_probe"], + vec!["browse", "cli_e2e_purity_peer"], + vec!["user", "cli_e2e_purity_peer"], + vec!["room", "list"], + vec!["room", "users", "cli_e2e_purity_room"], + vec!["room", "listen", "cli_e2e_purity_room", "--duration", "2"], + vec!["message", "read", "--duration", "2"], + vec!["shares", "status"], + vec!["shares", "list"], + vec!["config", "list"], + vec!["config", "path"], + vec!["portmap"], + vec!["search", "nothing_matches_this_qzx"], + vec!["browse", "no_such_user_qzx"], + ]; + + let mut narrated = false; + for case in cases { + let mut args = server.args("cli_e2e_purity"); + args.retain(|arg| arg != "--quiet"); + args.push("--json".to_string()); + args.push("--search-timeout".to_string()); + args.push("3".to_string()); + args.extend(case.iter().map(|a| (*a).to_string())); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = run(&refs); + + for line in stdout(&output).lines() { + assert!( + serde_json::from_str::(line).is_ok(), + "`{}` put a non-JSON line on stdout: {line:?}", + case.join(" ") + ); + } + narrated |= !stderr(&output).trim().is_empty(); + } + + assert!( + narrated, + "progress should still be reported — on stderr, where it belongs" + ); +} + +/// Failure must never leave half a record behind for a parser to trip on. +#[test] +fn a_failing_command_writes_nothing_at_all_to_stdout() { + let server = server_or_skip!(); + for case in [ + vec!["search", "nothing_matches_this_qzx"], + vec!["browse", "no_such_user_qzx"], + ] { + let mut args = server.args("cli_e2e_empty_stdout"); + args.push("--json".to_string()); + args.push("--search-timeout".to_string()); + args.push("3".to_string()); + args.extend(case.iter().map(|a| (*a).to_string())); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = run(&refs); + + assert_ne!(code(&output), EXIT_OK, "`{}` should fail", case.join(" ")); + assert!( + stdout(&output).trim().is_empty(), + "`{}` failed but still wrote to stdout: {:?}", + case.join(" "), + stdout(&output) + ); + } +} + +/// Asking about a user nobody has heard of is a question the server answers, +/// not an error: the answer is "offline", and a script branches on the record +/// rather than on a failure. +#[test] +fn asking_about_an_unknown_user_succeeds_with_an_offline_record() { + let server = server_or_skip!(); + let output = cli( + &server, + "cli_e2e_unknown_asker", + &["--json", "user", "nobody_qzx"], + ); + + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let record = json_record(&output); + assert_eq!(record["status"], "offline"); + assert_eq!(record["shared_files"], 0); +} + +/// Assert that `output` succeeded and that its first record carries every key +/// the documentation promises. `label` names the invocation in the failure. +fn assert_record_keys(output: &Output, label: &str, keys: &[&str]) { + assert_eq!( + code(output), + EXIT_OK, + "`{label}` should succeed, stderr: {}", + stderr(output) + ); + let record = json_record(output); + for key in keys { + assert!( + record.get(*key).is_some(), + "`{label}` should carry `{key}`, got {record}" + ); + } +} + +/// "Thirteen of these need no credentials, because they never touch the +/// network." A script's first call is one of these, so an account must not be +/// a prerequisite for any of them. +#[test] +fn the_commands_advertised_as_credential_free_run_without_an_account() { + let dir = Scratch::new("nocred"); + let config = dir.path().join("config.toml"); + let target = Scratch::new("nocred-skills"); + let skills = target.display(); + let shared = Scratch::new("nocred-shared"); + let shared_path = shared.display(); + // `completions` writes where the shell looks, which is under the home + // directory: give it a throwaway one rather than the developer's. + let home = Scratch::new("nocred-home"); + + let cases: Vec> = vec![ + vec!["config", "path"], + vec!["config", "list"], + vec!["config", "set", "search_timeout", "12"], + vec!["config", "get", "search_timeout"], + vec!["shares", "add", &shared_path], + vec!["shares", "list"], + vec!["shares", "remove", &shared_path], + // The wishlist is stored, not searched, until `wish run` asks the + // network — so managing it must not demand an account either. + vec!["wish", "add", "something rare"], + vec!["wish", "list"], + vec!["wish", "remove", "something rare"], + vec!["skills", "install", "--dir", &skills], + vec!["skills", "list", "--dir", &skills], + vec!["skills", "uninstall", "--dir", &skills], + vec!["completions", "install", "--shell", "fish"], + vec!["completions", "uninstall", "--shell", "fish"], + ]; + + for case in cases { + let mut all = vec!["--config", config.to_str().expect("utf-8 path")]; + all.extend_from_slice(&case); + let mut invocation = command(&all); + invocation.env("HOME", home.path()); + invocation.env("XDG_CONFIG_HOME", home.path().join("config")); + let output = invocation.output().expect("the binary should run"); + assert_eq!( + code(&output), + EXIT_OK, + "`{}` should not need credentials, stderr: {}", + case.join(" "), + stderr(&output) + ); + } + + // `portmap` is the thirteenth: it talks to the router, never to the + // server, so it answers with its own verdict rather than a credentials + // error. + let portmap = with_config(&config, &["portmap"]); + assert!( + matches!(code(&portmap), EXIT_OK | EXIT_NO_RESULTS), + "portmap should not need credentials, got {} / {}", + code(&portmap), + stderr(&portmap) + ); +} + +/// "Every wait expressed in seconds is bounded to one day, so a mistyped flag +/// is rejected rather than hanging until the heat death of the universe." +#[test] +fn a_wait_longer_than_a_day_is_rejected_rather_than_accepted() { + let day = 86_400; + let over = (day + 1).to_string(); + let cases: Vec> = vec![ + vec!["--no-config", "--search-timeout", &over, "search", "x"], + vec![ + "--no-config", + "room", + "listen", + "lobby", + "--duration", + &over, + ], + vec!["--no-config", "message", "read", "--duration", &over], + vec!["--no-config", "serve", "--duration", &over], + vec!["--no-config", "get", "x", "--timeout", &over], + ]; + + // Missing credentials are a usage error too, so the exit code alone would + // not prove the bound rejected anything: the range has to be named. + for case in cases { + let output = run(&case); + assert_eq!( + code(&output), + EXIT_USAGE, + "`{}` should be a usage error, stderr: {}", + case.join(" "), + stderr(&output) + ); + assert!( + stderr(&output).contains(&format!("1..={day}")), + "`{}` should be rejected by the one-day bound, stderr: {}", + case.join(" "), + stderr(&output) + ); + } +} + +/// "`SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the config and +/// state directories wholesale" — what a containerised run relies on. +#[test] +fn the_config_directory_can_be_relocated_wholesale() { + let home = Scratch::new("relocated"); + let config_dir = home.path().join("config"); + let state_dir = home.path().join("state"); + + let mut set = command(&["config", "set", "search_timeout", "17"]); + set.env("SOULSEEK_CONFIG_DIR", &config_dir); + set.env("SOULSEEK_STATE_DIR", &state_dir); + let output = set.output().expect("the binary should run"); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + + let written = config_dir.join("config.toml"); + assert!( + written.exists(), + "the setting should land under SOULSEEK_CONFIG_DIR, not the default" + ); + + let mut path = command(&["config", "path"]); + path.env("SOULSEEK_CONFIG_DIR", &config_dir); + let reported = path.output().expect("the binary should run"); + assert!( + records(&reported)[0].ends_with(written.to_str().expect("utf-8 path")), + "got {:?}", + records(&reported) + ); +} + +/// The shipped SKILL.md tells an agent, key by key, what the offline commands +/// print. An agent has no way to notice a rename, so pin them here. +#[test] +fn the_offline_json_records_carry_the_keys_the_skill_file_names() { + let dir = Scratch::new("skill-keys"); + let config = dir.path().join("config.toml"); + let shared = Scratch::new("skill-keys-shared"); + let shared_path = shared.display(); + let skills = Scratch::new("skill-keys-agent"); + let skills_path = skills.display(); + + with_config(&config, &["shares", "add", &shared_path]); + + let cases: Vec<(Vec<&str>, Vec<&str>)> = vec![ + ( + vec!["--json", "shares", "list"], + vec!["directory", "usable"], + ), + ( + vec!["--json", "skills", "list", "--dir", &skills_path], + vec!["agent", "path", "action"], + ), + (vec!["--json", "config", "list"], vec!["key", "value"]), + (vec!["--json", "config", "path"], vec!["key", "value"]), + ]; + + for (case, keys) in cases { + let output = with_config(&config, &case); + assert_record_keys(&output, &case.join(" "), &keys); + } + + // `completions` writes into the shell's own directory, so it only ever + // runs against a throwaway home. + let home = Scratch::new("skill-keys-home"); + for verb in ["install", "uninstall"] { + let mut invocation = command(&[ + "--no-config", + "--json", + "completions", + verb, + "--shell", + "fish", + ]); + invocation.env("HOME", home.path()); + invocation.env("XDG_CONFIG_HOME", home.path().join("config")); + let output = invocation.output().expect("the binary should run"); + assert_record_keys( + &output, + &format!("completions {verb}"), + &["shell", "path", "action"], + ); + } +} + +/// The website's connectivity section: "`serve` refuses to start with the +/// listener off", because a source nobody can connect to serves nothing. +#[test] +fn serve_refuses_to_run_without_a_listener() { + let share = Scratch::new("serve-nolistener"); + let output = run(&[ + "--no-config", + "--username", + "someone", + "--password", + "pw", + "--no-listener", + "--shared-dir", + &share.display(), + "serve", + "--duration", + "5", + ]); + assert_eq!(code(&output), EXIT_USAGE, "stdout: {}", stdout(&output)); + assert!( + stderr(&output).contains("listener"), + "the error should name the listener, got {}", + stderr(&output) + ); +} + +/// The four commands the README calls out as acting rather than reporting: +/// "say nothing on stdout and answer with their exit code alone". +#[test] +fn the_action_commands_answer_with_their_exit_code_alone() { + let server = server_or_skip!(); + let shared = Scratch::new("action-shared"); + let shared_path = shared.display(); + let dir = Scratch::new("action-config"); + let config = dir.path().join("config.toml"); + let listener = server.client("cli_e2e_action_peer", Vec::new()); + listener.join_room("cli_e2e_action_room").expect("join"); + settle(); + + for case in [ + vec!["room", "say", "cli_e2e_action_room", "hello room"], + vec!["message", "send", "cli_e2e_action_peer", "hello there"], + ] { + let output = cli(&server, "cli_e2e_action", &case); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).is_empty(), + "`{}` should print nothing on stdout, got {:?}", + case.join(" "), + stdout(&output) + ); + } + + for case in [ + vec!["shares", "add", shared_path.as_str()], + vec!["shares", "remove", shared_path.as_str()], + ] { + let output = with_config(&config, &case); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + stdout(&output).is_empty(), + "`{}` should print nothing on stdout, got {:?}", + case.join(" "), + stdout(&output) + ); + } +} + +/// The recipe the README leads with: `--min-bitrate` and `--free-slots` keep +/// only what a script asked for, rather than filtering nothing at all. +#[test] +fn search_filters_drop_what_they_advertise_dropping() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_filt.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_filt", vec![share.display()]); + settle(); + + let unfiltered = cli( + &server, + "cli_e2e_filterer", + &["--search-timeout", "5", "--json", "search", "filt"], + ); + assert_eq!( + code(&unfiltered), + EXIT_OK, + "stderr: {}", + stderr(&unfiltered) + ); + assert!( + records(&unfiltered) + .iter() + .any(|line| line.contains("cli_probe_filt")), + "the probe should be findable before filtering" + ); + + // A .bin carries no bitrate, so a bitrate floor must exclude it rather + // than let an unknown through. + let filtered = cli( + &server, + "cli_e2e_filterer", + &[ + "--search-timeout", + "5", + "search", + "filt", + "--min-bitrate", + "320", + ], + ); + assert_eq!( + code(&filtered), + EXIT_NO_RESULTS, + "a bitrate floor should drop a result with no bitrate, got {:?}", + records(&filtered) + ); + + // `--free-slots` is a narrowing filter too: it may keep everything, but it + // must never invent results the unfiltered search did not have. + let free = cli( + &server, + "cli_e2e_filterer", + &["--search-timeout", "5", "search", "filt", "--free-slots"], + ); + assert!( + matches!(code(&free), EXIT_OK | EXIT_NO_RESULTS), + "stderr: {}", + stderr(&free) + ); + assert!( + records(&free).len() <= records(&unfiltered).len(), + "a filter must not add results" + ); +} + +/// "Nothing sensitive on the command line or in the environment": +/// `--password-stdin` reads the first line of stdin, `docker login` style. +#[test] +fn the_password_can_arrive_on_stdin_instead_of_in_argv() { + let server = server_or_skip!(); + let _owner = server.client("cli_e2e_stdin_pw", Vec::new()); + + let args = [ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_stdin_pw", + "--password-stdin", + "--no-listener", + "whoami", + ]; + let mut child = command(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should run"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(b"pw\n") + .expect("write the password"); + let output = child.wait_with_output().expect("the binary should finish"); + + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + records(&output)[0].starts_with("cli_e2e_stdin_pw\t"), + "got {:?}", + records(&output) + ); +} + +/// The `--json` paragraph names, field by field, what JSON carries that text +/// does not. A missing key silently breaks the `jq` filters the README sells. +#[test] +fn json_records_carry_every_extra_field_the_readme_names() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_keys.bin"), probe_bytes()) + .expect("share file"); + let target = Scratch::new("downloads"); + let share_path = share.display(); + let sharer = server.client("cli_e2e_sharer_keys", vec![share.display()]); + sharer.join_room("cli_e2e_keys_room").expect("join"); + settle(); + + let mut expected: Vec<(Vec<&str>, Vec<&str>)> = vec![ + ( + vec!["search", "keys", "--search-timeout", "5"], + vec![ + "user", + "size", + "bitrate", + "path", + "duration", + "slots", + "speed", + "free_slot", + ], + ), + ( + vec!["browse", "cli_e2e_sharer_keys"], + vec!["user", "size", "path", "directory"], + ), + ( + vec!["whoami"], + vec![ + "user", + "server", + "shared_folders", + "shared_files", + "listening", + "listen_port", + "download_dir", + ], + ), + ( + vec!["user", "cli_e2e_sharer_keys"], + vec![ + "user", + "status", + "average_speed", + "shared_files", + "privileged", + "shared_folders", + ], + ), + ( + vec!["shares", "status", "--shared-dir", &share_path], + vec!["folders", "files", "directories"], + ), + ( + vec!["shares", "reindex", "--shared-dir", &share_path], + vec!["folders", "files", "directories"], + ), + ]; + expected.push((vec!["room", "list"], vec!["users", "room"])); + expected.push(( + vec!["room", "users", "cli_e2e_keys_room"], + vec!["room", "user"], + )); + + for (case, keys) in expected { + let mut args: Vec<&str> = vec!["--json"]; + args.extend(case.iter().copied()); + let output = cli(&server, "cli_e2e_keys", &args); + assert_record_keys(&output, &case.join(" "), &keys); + } + + // `download` adds the user, remote path and size alongside the local file. + let listing = + cli(&server, "cli_e2e_keys", &["browse", "cli_e2e_sharer_keys"]); + let remote_path = records(&listing) + .into_iter() + .find(|line| line.contains("cli_probe_keys.bin")) + .and_then(|row| row.rsplit('\t').next().map(String::from)) + .expect("the shared file should be listed"); + // A fresh name: the peer connects back to the port this user announced at + // login, and the browse runs above left an older one registered. + let downloaded = cli( + &server, + "cli_e2e_keys_fetcher", + &[ + "--json", + "--download-dir", + &target.display(), + "download", + "cli_e2e_sharer_keys", + &remote_path, + "--timeout", + "40", + ], + ); + assert_record_keys( + &downloaded, + "download", + &["file", "user", "path", "size"], + ); + + // `portmap` adds the port it probed; the verdict lives in the exit code, + // which is `EXIT_NO_RESULTS` on a network with no cooperating router. + let portmap = cli(&server, "cli_e2e_keys", &["--json", "portmap"]); + let record = json_record(&portmap); + for key in ["ok", "backend", "external", "port"] { + assert!(record.get(key).is_some(), "portmap should carry `{key}`"); + } +} + +// --------------------------------------------------------------------------- +// Several invocations at once: what an agent driving this CLI actually does. +// --------------------------------------------------------------------------- + +/// Spawn the binary as `user` with an explicit listener port, without waiting. +fn spawn_cli( + server: &TestServer, + user: &str, + port: u16, + args: &[&str], +) -> std::process::Child { + let mut all = server.args_on(user, port); + all.extend(args.iter().map(|a| (*a).to_string())); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should start") +} + +#[test] +fn concurrent_searches_sharing_a_listener_port_all_find_the_file() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_multi.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_multi", vec![share.display()]); + settle(); + + // One configured port between them, which is what a config file gives + // three agents started at the same moment. Losing the race for it used to + // cost that invocation its listener, and with it every search result. + let port = free_port().expect("shared listener port"); + // Collected on purpose: all three have to be running before any is waited + // on, which is the whole point of the test. + #[allow(clippy::needless_collect)] + let running: Vec<_> = (0..3) + .map(|i| { + spawn_cli( + &server, + &format!("cli_e2e_par_{i}"), + port, + &["--search-timeout", "10", "search", "multi"], + ) + }) + .collect(); + + for (i, child) in running.into_iter().enumerate() { + let output = child.wait_with_output().expect("wait"); + assert_eq!( + code(&output), + EXIT_OK, + "run {i} failed: {}", + stderr(&output) + ); + assert!( + records(&output) + .iter() + .any(|line| line.contains("cli_probe_multi")), + "run {i} listed nothing" + ); + } +} + +#[test] +fn a_session_taken_over_mid_search_is_not_reported_as_no_results() { + let server = server_or_skip!(); + let name = "cli_e2e_evicted"; + + // Run it loud, so its own progress line says when it is logged in: the + // eviction has to land after that, or this test would race the login it is + // trying to interrupt. + let mut args = server.args_on(name, free_port().expect("listener port")); + args.retain(|arg| arg != "--quiet"); + args.extend( + ["--search-timeout", "20", "search", "nothingmatchesthisqzx"] + .map(String::from), + ); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let mut searching = command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should start"); + + let (logged_in, wait_for_login) = std::sync::mpsc::channel(); + let stderr = searching.stderr.take().expect("piped stderr"); + let progress = std::thread::spawn(move || { + let mut seen = String::new(); + for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)) + .map_while(Result::ok) + { + if line.contains("logged in as") { + let _ = logged_in.send(()); + } + seen.push_str(&line); + seen.push('\n'); + } + seen + }); + wait_for_login + .recv_timeout(Duration::from_mins(1)) + .expect("the search should log in"); + + // Now take the username away, the way a second invocation under one + // account does. + let _thief = server.client(name, Vec::new()); + + let status = searching.wait().expect("wait"); + let seen = progress.join().expect("progress reader"); + assert_eq!( + status.code(), + Some(EXIT_SESSION_LOST), + "a killed session must not look like an answered query: {seen}" + ); + + let mut written = String::new(); + std::io::Read::read_to_string( + &mut searching.stdout.take().expect("piped stdout"), + &mut written, + ) + .expect("read stdout"); + assert!(written.trim().is_empty(), "stdout stays data-only"); +} + +#[test] +fn concurrent_downloads_sharing_a_listener_port_all_land() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_dual.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_dual", vec![share.display()]); + settle(); + + // A transfer arrives over a connection the *uploader* opens back to us, so + // a run without a listener never receives its file however well its search + // went. Two `get` runs, one configured port between them. + let port = free_port().expect("shared listener port"); + let targets: Vec = + (0..2).map(|i| Scratch::new(&format!("dl{i}"))).collect(); + // Collected on purpose: both have to be running before either is waited + // on, which is the whole point of the test. + #[allow(clippy::needless_collect)] + let running: Vec<_> = targets + .iter() + .enumerate() + .map(|(i, target)| { + spawn_cli( + &server, + &format!("cli_e2e_dual_{i}"), + port, + &[ + "--download-dir", + &target.display(), + "--search-timeout", + "10", + "get", + "dual", + "--timeout", + "40", + ], + ) + }) + .collect(); + + for (i, (child, target)) in running.into_iter().zip(&targets).enumerate() { + let output = child.wait_with_output().expect("wait"); + assert_eq!( + code(&output), + EXIT_OK, + "run {i} failed: {}", + stderr(&output) + ); + let written = std::fs::read(target.path().join("cli_probe_dual.bin")) + .unwrap_or_else(|e| panic!("run {i} wrote no file: {e}")); + assert_eq!(written, probe_bytes(), "run {i} wrote the wrong bytes"); + } +} + +// --------------------------------------------------------------------------- +// Daemon mode +// +// Everything above proves a command behaves the same whichever kind of session +// serves it. These prove the things that only exist *because* there is a +// daemon: one session shared by several clients at once, the remote transport +// and its token, and the gate that decides who may talk to it at all. +// --------------------------------------------------------------------------- + +/// Two clients, one session, at the same time. +/// +/// This is the whole reason the daemon exists: the server allows one login per +/// account, so without it a second command would displace the first and both +/// would start reporting a lost session. +#[test] +fn two_clients_share_one_daemon_session_at_once() { + if mode() != Mode::Daemon { + println!("skipped: this is a daemon-mode property"); + return; + } + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_shared.bin"), probe_bytes()) + .expect("share file"); + let _sharer = + server.client("cli_e2e_two_client_peer", vec![share.display()]); + settle(); + + // One daemon, because both runs use the same account. + let args = server.args("cli_e2e_two_clients"); + let spawn = |extra: &[&str]| { + let mut all = args.clone(); + all.extend(extra.iter().map(|a| (*a).to_string())); + let refs: Vec<&str> = all.iter().map(String::as_str).collect(); + command(&refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary should start") + }; + + let first = spawn(&["--json", "--search-timeout", "8", "search", "shared"]); + let second = spawn(&["--json", "browse", "cli_e2e_two_client_peer"]); + + let first = first.wait_with_output().expect("wait"); + let second = second.wait_with_output().expect("wait"); + + assert_eq!( + code(&first), + EXIT_OK, + "the search failed while another client was attached: {}", + stderr(&first) + ); + assert_eq!( + code(&second), + EXIT_OK, + "the browse failed while another client was attached: {}", + stderr(&second) + ); + assert!( + !records(&first).is_empty() && !records(&second).is_empty(), + "both clients should have got answers" + ); + // Neither may report the session being taken over: that is exactly what a + // second *login* would have caused, and what borrowing one avoids. + for output in [&first, &second] { + assert_ne!(code(output), EXIT_SESSION_LOST, "{}", stderr(output)); + } +} + +/// A daemon reports itself, and stops when asked. +#[test] +fn a_daemon_reports_its_session_and_stops_on_request() { + if mode() != Mode::Daemon { + println!("skipped: there is no daemon to ask in local mode"); + return; + } + let server = server_or_skip!(); + // A daemon of its own: this test stops it, and the shared fixtures are + // still serving the rest of the suite. + let user = "cli_e2e_daemon_status"; + let state = server.daemon_state(user); + let socket = state.join("daemon.sock"); + let at = socket.display().to_string(); + + let status = + run(&["--no-config", "--json", "--daemon", &at, "daemon", "status"]); + assert_eq!(code(&status), EXIT_OK, "stderr: {}", stderr(&status)); + let record = json_record(&status); + assert_eq!(record["user"], user); + assert_eq!(record["server"], server.address()); + assert!( + record["clients"].as_u64().is_some_and(|n| n >= 1), + "the asking client counts as attached: {record}" + ); + + let stopped = run(&["--no-config", "--daemon", &at, "daemon", "stop"]); + assert_eq!(code(&stopped), EXIT_OK, "stderr: {}", stderr(&stopped)); + + // The socket goes away, and a command that finds nothing there says so + // rather than hanging. + let deadline = Instant::now() + Duration::from_secs(20); + while socket.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(200)); + } + let after = run(&["--no-config", "--daemon", &at, "daemon", "status"]); + assert_eq!( + code(&after), + EXIT_CONNECTION, + "a stopped daemon is a connection error, not a hang: {}", + stderr(&after) + ); +} + +/// `daemon token` prints a secret, and the same one every time. +#[test] +fn the_daemon_token_is_stable_across_reads() { + let state = Scratch::new("token"); + let read = || { + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_STATE_DIR", state.path()); + command.current_dir(std::env::temp_dir()); + command.args(["--no-config", "daemon", "token"]); + command.output().expect("the binary should run") + }; + + let first = read(); + assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first)); + let token = records(&first).first().expect("a token").clone(); + assert_eq!(token.len(), 64, "256 bits, hex encoded"); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!( + records(&read()).first().expect("a token"), + &token, + "a restart must not lock existing clients out" + ); +} + +/// Adding a folder reaches a daemon that is already running. +/// +/// Locally `shares add` means "shared from the next run on", because there is +/// no session yet. A daemon *is* that next run and it is already going, so +/// without pushing the change the command would look like it had worked while +/// the network still saw nothing. +#[test] +fn adding_a_share_takes_effect_on_a_running_daemon() { + if mode() != Mode::Daemon { + println!( + "skipped: there is no running session to update in local mode" + ); + return; + } + let server = server_or_skip!(); + let user = "cli_e2e_live_shares"; + let socket = server.daemon_state(user).join("daemon.sock"); + let at = socket.display().to_string(); + let config = Scratch::new("live-shares-config"); + let config_file = config.path().join("config.toml"); + let share = Scratch::new("live-shares"); + std::fs::write(share.path().join("cli_probe_live.bin"), probe_bytes()) + .expect("share file"); + + let before = + run(&["--no-config", "--json", "--daemon", &at, "shares", "status"]); + assert_eq!(code(&before), EXIT_OK, "stderr: {}", stderr(&before)); + + let added = run(&[ + "--config", + config_file.to_str().expect("utf-8 path"), + "--daemon", + &at, + "shares", + "add", + &share.display(), + ]); + assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added)); + + // The daemon re-indexes on the way through, so the folder is visible to + // the network now rather than after a restart. + let after = json_record(&run(&[ + "--no-config", + "--json", + "--daemon", + &at, + "shares", + "status", + ])); + let listed = after["directories"] + .as_array() + .expect("directories is an array"); + assert!( + listed.iter().any(|d| d == &share.display()), + "the daemon should be sharing the folder that was just added: {after}" + ); + assert!( + after["files"].as_u64().is_some_and(|files| files >= 1), + "and should have indexed what is in it: {after}" + ); +} + +/// Setting the download folder reaches a daemon that is already running. +/// +/// Locally `config set download_dir` means "from the next run on". A daemon +/// *is* that next run and it is already going, so without pushing the change +/// every transfer would keep landing in the old folder while the config file +/// claims otherwise — the trap `shares add` already closes. +#[test] +fn setting_the_download_folder_takes_effect_on_a_running_daemon() { + if mode() != Mode::Daemon { + println!( + "skipped: there is no running session to update in local mode" + ); + return; + } + let server = server_or_skip!(); + let user = "cli_e2e_live_dl_dir"; + let socket = server.daemon_state(user).join("daemon.sock"); + let at = socket.display().to_string(); + let config = Scratch::new("live-dl-config"); + let config_file = config.path().join("config.toml"); + let moved = Scratch::new("live-dl-target"); + + let set = run(&[ + "--config", + config_file.to_str().expect("utf-8 path"), + "--daemon", + &at, + "config", + "set", + "download_dir", + &moved.display(), + ]); + assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set)); + + // The daemon reports the new folder as its own... + let status = + run(&["--no-config", "--json", "--daemon", &at, "daemon", "status"]); + assert_eq!(code(&status), EXIT_OK, "stderr: {}", stderr(&status)); + assert_eq!( + json_record(&status)["download_dir"], + moved.display(), + "status must name the folder transfers will now land in" + ); + + // `config set shared_dirs` reaches the same running daemon: the folder + // is indexed now, not at the next restart. + let offered = Scratch::new("live-dl-offered"); + std::fs::write(offered.path().join("cli_probe_offered.bin"), probe_bytes()) + .expect("share file"); + let set_shares = run(&[ + "--config", + config_file.to_str().expect("utf-8 path"), + "--daemon", + &at, + "config", + "set", + "shared_dirs", + &offered.display(), + ]); + assert_eq!( + code(&set_shares), + EXIT_OK, + "stderr: {}", + stderr(&set_shares) + ); + let listing = + run(&["--no-config", "--json", "--daemon", &at, "shares", "status"]); + assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing)); + let indexed = json_record(&listing); + assert!( + indexed["directories"] + .as_array() + .expect("directories is an array") + .iter() + .any(|d| d == &offered.display()), + "the daemon should serve the folder the config now names: {indexed}" + ); + + // ...and the next transfer actually lands there. + let share = Scratch::new("live-dl-share"); + std::fs::write(share.path().join("cli_probe_moved.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_moved", vec![share.display()]); + settle(); + + let fetched = run(&[ + "--no-config", + "--quiet", + "--daemon", + &at, + "get", + "cli_probe_moved", + "--timeout", + "40", + ]); + assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched)); + let reported = records(&fetched); + assert_eq!(reported.len(), 1, "one file, one record"); + assert!( + reported[0].trim().starts_with(&moved.display()), + "the bytes must land in the folder that was just set, got {}", + reported[0] + ); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +/// The configured `download_dir` is where a download lands when no flag names +/// one — the local half of the promise the daemon test above makes. +#[test] +fn a_download_lands_in_the_folder_the_config_names() { + if mode() != Mode::Local { + println!("skipped: the daemon's folder wins in daemon mode"); + return; + } + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("configured-downloads"); + std::fs::write(share.path().join("cli_probe_cfgdir.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_cfg", vec![share.display()]); + settle(); + + let config = Scratch::new("dl-dir-config"); + let config_file = config.path().join("config.toml"); + let set = run(&[ + "--config", + config_file.to_str().expect("utf-8 path"), + "config", + "set", + "download_dir", + &target.display(), + ]); + assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set)); + + let fetched = cli_with_config( + &server, + "cli_e2e_cfg_fetcher", + &config_file, + &["get", "cli_probe_cfgdir", "--timeout", "40"], + ); + assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched)); + let reported = records(&fetched); + assert_eq!(reported.len(), 1, "one file, one record"); + assert!( + reported[0].trim().starts_with(&target.display()), + "the config file names where downloads go, got {}", + reported[0] + ); +} + +/// With nothing running, the daemon control commands say so and exit with the +/// connection code — a script must be able to branch on "is it up?". +#[test] +fn daemon_status_and_stop_without_a_daemon_are_connection_errors() { + for command in ["status", "stop"] { + let output = run(&["--no-config", "daemon", command]); + assert_eq!( + code(&output), + EXIT_CONNECTION, + "daemon {command}: {}", + stderr(&output) + ); + assert!( + stderr(&output).contains("no daemon is running"), + "daemon {command} should say what is wrong, got: {}", + stderr(&output) + ); + assert!(stdout(&output).is_empty(), "stdout must stay data-only"); + } +} + +/// `--sort size` leads with the largest file, which is what a script that +/// wants "the full album rip, not the sample" branches on. +#[test] +fn search_sorted_by_size_leads_with_the_largest_file() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::write(share.path().join("cli_probe_sort_small.bin"), [7u8; 64]) + .expect("share file"); + std::fs::write( + share.path().join("cli_probe_sort_large.bin"), + vec![7u8; 4096], + ) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_sort", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_sorter", + &[ + "--search-timeout", + "5", + "search", + "cli_probe_sort", + "--sort", + "size", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let reported = records(&output); + assert!(reported.len() >= 2, "both files answer, got {reported:?}"); + assert!( + reported[0].contains("cli_probe_sort_large"), + "largest first under --sort size, got {reported:?}" + ); + + // `--limit 1` keeps only the leader. + let capped = cli( + &server, + "cli_e2e_sorter", + &[ + "--search-timeout", + "5", + "search", + "cli_probe_sort", + "--sort", + "size", + "--limit", + "1", + ], + ); + assert_eq!(code(&capped), EXIT_OK, "stderr: {}", stderr(&capped)); + let capped = records(&capped); + assert_eq!(capped.len(), 1, "--limit 1 keeps one record"); + assert!( + capped[0].contains("cli_probe_sort_large"), + "and it is the ranked leader, got {capped:?}" + ); +} + +/// `--pick first` takes the first arrival without waiting out the window. +#[test] +fn get_pick_first_downloads_the_first_arrival() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + let target = Scratch::new("downloads"); + std::fs::write(share.path().join("cli_probe_first.bin"), probe_bytes()) + .expect("share file"); + let _sharer = server.client("cli_e2e_sharer_first", vec![share.display()]); + settle(); + + let output = cli( + &server, + "cli_e2e_first_picker", + &[ + "--download-dir", + &target.display(), + "get", + "cli_probe_first", + "--pick", + "first", + "--timeout", + "40", + ], + ); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let reported = records(&output); + assert_eq!(reported.len(), 1, "--pick first downloads one file"); + assert_eq!( + std::fs::read(reported[0].trim()).expect("downloaded file"), + probe_bytes() + ); +} + +/// The password can come from a command, `pass`-manager style: nothing +/// sensitive in argv, nothing in the environment. +#[test] +fn the_password_can_come_from_a_command() { + let server = server_or_skip!(); + let _owner = server.client("cli_e2e_cmd_pw", Vec::new()); + + let output = run(&[ + "--no-config", + "--quiet", + "--server", + &server.address(), + "--username", + "cli_e2e_cmd_pw", + "--password-cmd", + "echo pw", + "--no-listener", + "whoami", + ]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!( + records(&output)[0].starts_with("cli_e2e_cmd_pw\t"), + "got {:?}", + records(&output) + ); +} + +/// `--log-file` keeps diagnostics out of stderr and in a file a bug report +/// can attach. +#[test] +fn a_log_file_captures_what_verbose_would_print() { + let server = server_or_skip!(); + let _owner = server.client("cli_e2e_logged", Vec::new()); + let logs = Scratch::new("logs"); + let log_file = logs.path().join("run.log"); + let output = run(&[ + "--no-config", + "--quiet", + "-vvv", + "--log-file", + log_file.to_str().expect("utf-8 path"), + "--username", + "cli_e2e_logged", + "--password", + "pw", + "--server", + &server.address(), + "--no-listener", + "whoami", + ]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + let written = + std::fs::read_to_string(&log_file).expect("the log file is created"); + assert!( + !written.trim().is_empty(), + "-vvv must leave diagnostics in the file" + ); + assert!( + !stderr(&output).contains("DEBUG"), + "with a log file, diagnostics stay out of stderr: {}", + stderr(&output) + ); +} diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs new file mode 100644 index 0000000..82232af --- /dev/null +++ b/soulseek-rs/tests/common/mod.rs @@ -0,0 +1,221 @@ +//! Scaffolding both CLI end-to-end binaries need: the environment they scrub, +//! the soulfind they run against, and a port nobody else holds. + +#![allow(dead_code)] + +use soulseek_rs::message::server::MessageFactory; +use soulseek_rs::{Client, ClientSettings, PeerAddress}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Every environment variable the binary reads, cleared for each run so a +/// developer's shell (or a stray `.env`) cannot change what a test observes — +/// and so no run finds a real daemon. +pub const CLI_ENV_VARS: [&str; 15] = [ + "SOULSEEK_CONFIG_DIR", + "SOULSEEK_STATE_DIR", + "SOULSEEK_DAEMON", + "SOULSEEK_DAEMON_TOKEN", + "SOULSEEK_USERNAME", + "SOULSEEK_PASSWORD", + "SOULSEEK_PASSWORD_CMD", + "SOULSEEK_SERVER", + "SOULSEEK_NO_LISTENER", + "SOULSEEK_LISTENER_PORT", + "SOULSEEK_DOWNLOAD_DIR", + "SOULSEEK_SHARED_DIR", + "SOULSEEK_MAX_CONCURRENT_DOWNLOADS", + "SOULSEEK_SEARCH_TIMEOUT", + "SOULSEEK_CONFIG", +]; + +pub fn soulfind_binary() -> Option { + if let Ok(path) = std::env::var("SOULFIND_BIN") { + let path = PathBuf::from(path); + return path.exists().then_some(path); + } + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .map(|dir| dir.join("soulfind/bin/soulfind")) + .find(|candidate| candidate.exists()) +} + +pub fn free_port() -> Option { + std::net::TcpListener::bind("127.0.0.1:0") + .ok()? + .local_addr() + .ok() + .map(|addr| addr.port()) +} + +/// A soulfind spawned for one test, on a port nobody else holds, with a +/// throwaway database that goes with it. +pub struct Soulfind { + child: Child, + db: PathBuf, + port: u16, +} + +impl Soulfind { + /// soulfind exits if its port is taken between `free_port()` handing it + /// out and soulfind binding it, so a start that fails gets a fresh port. + pub fn start() -> Option { + (0..3).find_map(|_| { + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); + let _ = std::fs::remove_file(&db); + let child = Self::spawn(port, &db)?; + Some(Self { child, db, port }) + }) + } + + fn spawn(port: u16, db: &Path) -> Option { + let mut child = Command::new(soulfind_binary()?) + .args(["-d", db.to_str()?, "-p", &port.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + // A child that has exited is never taken as up: soulfind exits + // when its port is gone, and whoever took it may answer instead. + if child.try_wait().ok().flatten().is_some() { + return None; + } + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Some(child); + } + std::thread::sleep(Duration::from_millis(100)); + } + let _ = child.kill(); + let _ = child.wait(); + None + } + + pub const fn port(&self) -> u16 { + self.port + } + + pub fn address(&self) -> String { + format!("127.0.0.1:{}", self.port) + } + + pub fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + /// Bring it back on the same port and database. + pub fn restart(&mut self) -> Option<()> { + self.stop(); + self.child = Self::spawn(self.port, &self.db)?; + Some(()) + } + + /// A client logged in to this server as `user`, sharing `shares`. + pub fn client(&self, user: &str, shares: Vec) -> Client { + login("127.0.0.1", self.port, user, shares) + } +} + +impl Drop for Soulfind { + fn drop(&mut self) { + self.stop(); + let _ = std::fs::remove_file(&self.db); + } +} + +/// An in-process client logged in to the server at `host:port` as `user`, +/// sharing `shares`: the other end of a test. +/// +/// The listener stays on: a peer delivers search results and transfers over +/// a connection it opens back to us, so a client without one hears nothing. +pub fn login(host: &str, port: u16, user: &str, shares: Vec) -> Client { + let mut client = Client::with_settings(ClientSettings { + username: user.to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new(host.to_string(), port), + enable_listen: true, + listen_port: free_port().expect("peer port"), + shared_directories: shares, + accept_children: false, + version: soulseek_rs::ClientVersion::default(), + }); + client.connect().expect("peer connect"); + assert!(client.login().expect("peer login"), "peer should log in"); + client +} + +/// A user who is online and reachable but never answers, held for as long as +/// the value lives. +/// +/// Their listener accepts a connection and then says nothing, so a download +/// from them waits in the queue the way one waits behind a busy peer, with no +/// timer to race. A user who does not exist cannot stand in for this: the +/// server refuses to broker a connection to them, and their download fails +/// before a test gets to act on it. +pub struct SilentPeer { + _session: TcpStream, + _listener: TcpListener, +} + +pub fn silent_peer(host: &str, port: u16, user: &str) -> SilentPeer { + // Not loopback: soulfind hands a loopback login's address out as the + // host's LAN address, where a 127.0.0.1 listener is never reached. + let listener = TcpListener::bind("0.0.0.0:0").expect("silent listener"); + let wait_port = listener.local_addr().expect("listener address").port(); + let mut session = TcpStream::connect((host, port)).expect("server"); + let login = MessageFactory::build_login_message( + user, + "pw", + soulseek_rs::ClientVersion::default(), + ); + session + .write_all(&login.get_buffer()) + .expect("silent login"); + // soulfind drops everything but a login until the login has landed, so a + // port announced any sooner is announced to nobody. + session + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("read timeout"); + loop { + let mut len = [0; 4]; + session.read_exact(&mut len).expect("login reply"); + let mut body = vec![0; u32::from_le_bytes(len) as usize]; + session.read_exact(&mut body).expect("login reply"); + if body[..4] == 1u32.to_le_bytes() { + assert_eq!(body[4], 1, "{user} should log in"); + break; + } + } + let wait = MessageFactory::build_set_wait_port_message(wait_port); + session.write_all(&wait.get_buffer()).expect("wait port"); + settle(); + SilentPeer { + _session: session, + _listener: listener, + } +} + +/// Wait out the SetWaitPort registrations so peer lookups resolve. +pub fn settle() { + std::thread::sleep(Duration::from_secs(1)); +} + +/// What a suite does without a server: fail when the run said one is +/// required, otherwise say it skipped. The caller returns afterwards. +pub fn no_server(suite: &str) { + let required = std::env::var("SOULSEEK_E2E_REQUIRED") + .is_ok_and(|v| v != "0" && !v.is_empty()); + assert!( + !required, + "SOULSEEK_E2E_REQUIRED is set but no soulfind server could be \ + started for the {suite} suite (set SOULFIND_BIN)" + ); + println!("{suite} skipped: no soulfind server (set SOULFIND_BIN to run)"); +} diff --git a/soulseek-rs/tests/daemon_protocol_e2e.rs b/soulseek-rs/tests/daemon_protocol_e2e.rs new file mode 100644 index 0000000..c67063d --- /dev/null +++ b/soulseek-rs/tests/daemon_protocol_e2e.rs @@ -0,0 +1,949 @@ +//! The control protocol as a third party meets it: a socket, JSON-RPC lines, +//! and the gate that decides who gets past `auth`. +//! +//! The CLI suite drives the daemon through this binary, which means it only +//! ever sends well-formed requests over a Unix socket. Everything here is the +//! other half — the TCP transport and its token, and what happens to a caller +//! who is hostile, broken, or simply not authenticated. None of it is +//! reachable through the command surface, and all of it is load-bearing: on +//! the socket, connecting *is* authenticating. +//! +//! Like the rest of the e2e suite this needs a soulfind server and skips +//! without one; `SOULSEEK_E2E_REQUIRED=1` turns that skip into a failure. + +mod common; + +use common::{CLI_ENV_VARS, Soulfind, free_port, silent_peer}; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +const BIN: &str = env!("CARGO_BIN_EXE_soulseek-rs"); + +/// A daemon bound to loopback TCP as well as its socket, so both transports +/// can be exercised against one session. +struct Daemon { + child: Child, + state: TempDir, + port: u16, + server: Soulfind, +} + +/// The binary with a scrubbed environment: no test may see the developer's +/// real daemon, and `state` decides which one it does see. +fn cli_command(state: &Path) -> Command { + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_STATE_DIR", state); + command.current_dir(std::env::temp_dir()); + command +} + +impl Daemon { + fn start() -> Option { + let server = Soulfind::start()?; + let state = tempfile::tempdir().ok()?; + let port = free_port()?; + + let mut command = cli_command(state.path()); + command.args([ + "--no-config", + "--server", + &server.address(), + "--username", + "cli_e2e_proto", + "--password", + "pw", + "--listener-port", + &free_port()?.to_string(), + "--download-dir", + state.path().join("downloads").to_str()?, + "daemon", + "--bind", + &format!("127.0.0.1:{port}"), + // Exercised on every boot: the limit must parse and apply + // without disturbing anything else the suite observes. + "--upload-slots", + "8", + ]); + command.stdout(Stdio::null()).stderr(Stdio::null()); + let child = command.spawn().ok()?; + + let daemon = Self { + child, + state, + port, + server, + }; + daemon.wait_until_ready()?; + Some(daemon) + } + + /// Ready means logged in, not merely bound: the daemon binds before it + /// logs in, and a client that connects between the two would be asking + /// about a session that is not there yet. + fn wait_until_ready(&self) -> Option<()> { + let deadline = Instant::now() + Duration::from_mins(1); + while Instant::now() < deadline { + if let Some(mut connection) = self.connect_tcp() + && let Some(reply) = connection.call(&format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"auth","params":{{"protocol":1,"token":"{}"}}}}"#, + self.token() + )) + && reply.contains("\"username\"") + { + return Some(()); + } + std::thread::sleep(Duration::from_millis(250)); + } + None + } + + fn token(&self) -> String { + std::fs::read_to_string(self.state.path().join("daemon.token")) + .map(|token| token.trim().to_string()) + .unwrap_or_default() + } + + fn socket_path(&self) -> PathBuf { + self.state.path().join("daemon.sock") + } + + fn connect_tcp(&self) -> Option { + TcpStream::connect(("127.0.0.1", self.port)) + .ok() + .map(|stream| { + let read = stream.try_clone().expect("a second handle"); + Connection::from_parts(Box::new(read), Box::new(stream)) + }) + } + + #[cfg(unix)] + fn connect_socket(&self) -> Option { + let stream = + std::os::unix::net::UnixStream::connect(self.socket_path()).ok()?; + let read = stream.try_clone().expect("a second handle"); + Some(Connection::from_parts(Box::new(read), Box::new(stream))) + } + + fn status(&self) -> Option { + let mut connection = self.connect_tcp()?; + connection.authenticate(Some(&self.token()))?; + let reply = connection + .call(r#"{"jsonrpc":"2.0","id":1,"method":"daemon.status"}"#)?; + serde_json::from_str::(&reply) + .ok()? + .get_mut("result") + .map(serde_json::Value::take) + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// One control connection, spoken to by hand. +struct Connection { + reader: BufReader>, + writer: Box, +} + +impl Connection { + /// Boxed halves, so one type covers both transports. + fn from_parts( + read: Box, + write: Box, + ) -> Self { + Self { + reader: BufReader::new(read), + writer: write, + } + } + + /// Send one line and read the reply, `None` if the daemon hung up first. + /// + /// Events share the socket with replies, so a subscribed connection can + /// have a notification land between the request and its answer. Only a + /// reply carries an `id`; skip everything that does not. + fn call(&mut self, line: &str) -> Option { + self.send(line)?; + loop { + let reply = self.read()?; + let is_notification = + serde_json::from_str::(&reply) + .is_ok_and(|parsed| parsed.get("id").is_none()); + if !is_notification { + return Some(reply); + } + } + } + + fn send(&mut self, line: &str) -> Option<()> { + writeln!(self.writer, "{line}").ok()?; + self.writer.flush().ok() + } + + fn read(&mut self) -> Option { + let mut reply = String::new(); + match self.reader.read_line(&mut reply) { + Ok(0) | Err(_) => None, + Ok(_) => Some(reply), + } + } + + fn authenticate(&mut self, token: Option<&str>) -> Option { + let params = match token { + Some(token) => format!(r#"{{"protocol":1,"token":"{token}"}}"#), + None => r#"{"protocol":1}"#.to_string(), + }; + self.call(&format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"auth","params":{params}}}"# + )) + } +} + +macro_rules! daemon_or_skip { + () => { + match Daemon::start() { + Some(daemon) => daemon, + None => { + common::no_server("daemon protocol e2e"); + return; + } + } + }; +} + +#[test] +fn tcp_needs_the_token_and_the_socket_does_not() { + let daemon = daemon_or_skip!(); + + let mut anonymous = daemon.connect_tcp().expect("connect"); + let refused = anonymous.authenticate(None).expect("an answer"); + assert!( + refused.contains("\"error\""), + "TCP without a token must be refused: {refused}" + ); + + let mut wrong = daemon.connect_tcp().expect("connect"); + let bad = wrong + .authenticate(Some(&"0".repeat(64))) + .expect("an answer"); + assert!( + bad.contains("\"error\""), + "a wrong token must be refused: {bad}" + ); + + let mut good = daemon.connect_tcp().expect("connect"); + let accepted = good.authenticate(Some(&daemon.token())).expect("an answer"); + assert!( + accepted.contains("\"result\"") && accepted.contains("cli_e2e_proto"), + "the right token must be accepted: {accepted}" + ); + + #[cfg(unix)] + { + // The socket's permissions have already answered the question. + let mut local = daemon.connect_socket().expect("connect"); + let reply = local.authenticate(None).expect("an answer"); + assert!( + reply.contains("\"result\""), + "a Unix socket needs no token: {reply}" + ); + } +} + +#[test] +fn nothing_can_be_asked_before_authenticating() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + + let reply = connection + .call(r#"{"jsonrpc":"2.0","id":1,"method":"daemon.status"}"#) + .expect("an answer"); + assert!( + reply.contains("\"error\""), + "a request before auth must be refused: {reply}" + ); + assert!( + !reply.contains("cli_e2e_proto"), + "an unauthenticated caller must learn nothing about the session: \ + {reply}" + ); + assert!( + connection + .call(r#"{"jsonrpc":"2.0","id":2,"method":"daemon.status"}"#) + .is_none(), + "the connection must be dropped, not left open to try again" + ); +} + +#[test] +fn an_error_carries_an_id_so_a_client_can_match_it() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + connection + .authenticate(Some(&daemon.token())) + .expect("authenticated"); + + let reply = connection + .call(r#"{"jsonrpc":"2.0","id":7,"method":"no.such.method"}"#) + .expect("an answer"); + assert!(reply.contains("\"id\":7"), "{reply}"); + assert!(reply.contains("-32601"), "{reply}"); + + // A malformed line still gets an id — null — rather than looking like a + // notification the client should ignore. + let malformed = connection.call("{ this is not json").expect("an answer"); + assert!(malformed.contains("\"id\":null"), "{malformed}"); + assert!(malformed.contains("-32700"), "{malformed}"); +} + +#[test] +fn a_notification_is_answered_with_silence() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + connection + .authenticate(Some(&daemon.token())) + .expect("authenticated"); + + // No id, so no reply — the next line read must be the answer to the + // request that follows it, not a reply to this one. + connection + .send(r#"{"jsonrpc":"2.0","method":"no.such.method"}"#) + .expect("sent"); + let reply = connection + .call(r#"{"jsonrpc":"2.0","id":9,"method":"daemon.status"}"#) + .expect("an answer"); + assert!( + reply.contains("\"id\":9"), + "a notification must not produce a reply of its own: {reply}" + ); +} + +#[test] +fn an_oversized_line_cannot_make_the_daemon_allocate() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + + // Over the 1 MiB cap, and never terminated: an unauthenticated peer must + // not be able to make the daemon buffer without bound. + let flood = "x".repeat(2 * 1024 * 1024); + let _ = connection.send(&flood); + assert!( + connection.read().is_none(), + "an oversized request must end the connection" + ); + + // The daemon is still serving everyone else. + let mut healthy = daemon.connect_tcp().expect("connect"); + let reply = healthy + .authenticate(Some(&daemon.token())) + .expect("an answer"); + assert!(reply.contains("\"result\""), "{reply}"); +} + +#[test] +fn a_silent_connection_is_closed_rather_than_held_open() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + + // Says nothing at all. The handshake timeout is 30s; well inside a minute. + let deadline = Instant::now() + Duration::from_mins(1); + let mut closed = false; + while Instant::now() < deadline { + if connection.read().is_none() { + closed = true; + break; + } + } + assert!( + closed, + "a peer that connects and never authenticates must not hold a thread \ + forever" + ); +} + +#[test] +fn discover_serves_a_contract_whose_parameters_match_what_is_accepted() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("connect"); + connection + .authenticate(Some(&daemon.token())) + .expect("authenticated"); + + let reply = connection + .call(r#"{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}"#) + .expect("an answer"); + let document: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + let methods = document["result"]["methods"] + .as_array() + .expect("the contract lists methods"); + + // The published parameter names have to be the keys the daemon reads. + // Published as one descriptor named `params`, a generated client would + // send {"params":{"params":{…}}} and every call would fail -32602 — which + // is exactly what this asserts cannot happen. + let join = methods + .iter() + .find(|m| m["name"] == "room.join") + .expect("room.join is published"); + let names: Vec<&str> = join["params"] + .as_array() + .expect("params is an array") + .iter() + .map(|p| p["name"].as_str().expect("a name")) + .collect(); + assert_eq!(names, ["room"], "{join}"); + + // And a call built from that description is accepted. + let accepted = connection + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"room.join","params":{"room":"cli_e2e_proto_room"}}"#, + ) + .expect("an answer"); + assert!( + accepted.contains("\"result\""), + "a call shaped by the published contract must be accepted: {accepted}" + ); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if f() { + return true; + } + std::thread::sleep(Duration::from_millis(250)); + } + false +} + +#[test] +fn the_daemon_logs_back_in_after_the_server_drops_it() { + let mut daemon = daemon_or_skip!(); + assert!( + daemon.status().expect("a status")["session_loss"].is_null(), + "the daemon starts with a live session" + ); + + daemon.server.stop(); + assert!( + wait_until(Duration::from_secs(15), || daemon + .status() + .is_some_and(|status| status["session_loss"] == "disconnected")), + "the daemon never noticed the server going away" + ); + + daemon + .server + .restart() + .expect("soulfind comes back on the same port"); + assert!( + wait_until(Duration::from_secs(45), || daemon + .status() + .is_some_and(|status| status["session_loss"].is_null())), + "the daemon never logged back in once the server was reachable again" + ); +} + +/// A client configured for a remote daemon reports the *daemon's* world. +/// +/// This is the download-box workflow: the token lives in the client's config +/// file rather than on its command line, and it has no Soulseek account of its +/// own. Reading the token from the flag alone made the status probe fail +/// silently, and every command that names a path or a server then quietly fell +/// back to this machine's defaults — `whoami` claiming downloads land in the +/// laptop's home directory when they land on the box. +#[test] +fn a_config_driven_remote_client_reports_the_daemons_world() { + let daemon = daemon_or_skip!(); + let config = tempfile::tempdir().expect("config directory"); + let state = tempfile::tempdir().expect("state directory"); + + // A laptop that knows only where the daemon is. No username, no password, + // no server — exactly what the documented setup produces. + std::fs::write( + config.path().join("config.toml"), + format!( + "daemon = \"127.0.0.1:{}\"\ndaemon_token = \"{}\"\n", + daemon.port, + daemon.token() + ), + ) + .expect("config file"); + + let mut command = cli_command(state.path()); + command.env("SOULSEEK_CONFIG_DIR", config.path()); + command.args(["--json", "whoami"]); + let output = command.output().expect("the binary should run"); + + assert!( + output.status.success(), + "a client configured for a remote daemon should just work: {}", + String::from_utf8_lossy(&output.stderr) + ); + let record: serde_json::Value = serde_json::from_str( + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| !line.trim().is_empty()) + .expect("one record"), + ) + .expect("valid JSON"); + + assert_eq!( + record["user"], "cli_e2e_proto", + "the daemon's account, not this machine's" + ); + assert!( + record["server"] + .as_str() + .is_some_and(|server| server.starts_with("127.0.0.1:")), + "the server the daemon is connected to, not a local default: {record}" + ); + assert!( + record["download_dir"].as_str().is_some_and( + |dir| dir.starts_with(std::env::temp_dir().to_str().unwrap()) + ), + "the directory the bytes actually land in, on the daemon's host, not \ + this machine's Downloads folder: {record}" + ); +} + +/// Several clients at once, all seeing the same session. +/// +/// This is the promise daemon mode makes and the reason the fan-out exists: +/// the server allows one login, so the alternative to sharing is displacing. +/// Each client gets its own connection, its own event queue, and the same +/// answers. +#[test] +fn many_clients_share_one_session_and_agree_about_it() { + let daemon = daemon_or_skip!(); + + // Held open together, not one after another: the point is concurrency. + let mut clients: Vec = (0..8) + .map(|_| { + let mut connection = daemon.connect_tcp().expect("connect"); + let reply = connection + .authenticate(Some(&daemon.token())) + .expect("an answer"); + assert!(reply.contains("\"result\""), "client rejected: {reply}"); + connection + }) + .collect(); + + // Every one of them is told the same thing about the same session. + let mut seen = Vec::new(); + for (i, client) in clients.iter_mut().enumerate() { + let reply = client + .call(&format!( + r#"{{"jsonrpc":"2.0","id":{},"method":"daemon.status"}}"#, + 100 + i + )) + .unwrap_or_else(|| panic!("client {i} lost its connection")); + let status: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + assert_eq!( + status["id"], + serde_json::json!(100 + i), + "replies must not be crossed between clients: {reply}" + ); + seen.push(( + status["result"]["username"].clone(), + status["result"]["server"].clone(), + )); + } + assert!( + seen.windows(2).all(|pair| pair[0] == pair[1]), + "every client should describe the same session: {seen:?}" + ); + + // And the daemon counts them all, rather than having quietly dropped some. + let reply = clients[0] + .call(r#"{"jsonrpc":"2.0","id":200,"method":"daemon.status"}"#) + .expect("still connected"); + let status: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + assert_eq!( + status["result"]["clients"], + serde_json::json!(8), + "all eight should still be attached: {reply}" + ); +} + +/// Connecting and dropping, over and over, must not degrade the daemon. +/// +/// Every command makes at least one throwaway connection while it works out +/// whether a daemon is there, so a per-connection resource that is not +/// released shows up as a daemon that works for a while and then refuses +/// everyone. +#[test] +fn rapid_connect_and_drop_does_not_wear_the_daemon_out() { + let daemon = daemon_or_skip!(); + + // Comfortably more than the connection cap, so a leak of one slot per + // connection would have exhausted it well before the end. + for _ in 0..200 { + drop(daemon.connect_tcp().expect("connect")); + } + // Half-open ones too: authenticated, then abandoned mid-conversation. + for _ in 0..50 { + let mut connection = daemon.connect_tcp().expect("connect"); + connection.authenticate(Some(&daemon.token())); + drop(connection); + } + + // Give the daemon a moment to notice the hangups, then prove it is well. + std::thread::sleep(Duration::from_secs(2)); + let mut connection = daemon.connect_tcp().expect("connect"); + let reply = connection + .authenticate(Some(&daemon.token())) + .expect("the daemon should still be answering"); + assert!(reply.contains("\"result\""), "{reply}"); + let status = connection + .call(r#"{"jsonrpc":"2.0","id":1,"method":"daemon.status"}"#) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&status).expect("valid JSON"); + assert_eq!( + parsed["result"]["clients"], + serde_json::json!(1), + "abandoned connections must not still be counted: {status}" + ); +} + +/// What one client does, the other sees. +/// +/// Two TUIs on one daemon are two views of a single session, not two sessions +/// that happen to share a login. A search run in one appears in the other's +/// list, and a transfer queued in one appears in the other's queue — which is +/// what makes closing one window harmless. +#[test] +fn one_clients_work_shows_up_in_another() { + let daemon = daemon_or_skip!(); + let mut first = daemon.connect_tcp().expect("connect"); + first.authenticate(Some(&daemon.token())).expect("first in"); + let mut second = daemon.connect_tcp().expect("connect"); + second + .authenticate(Some(&daemon.token())) + .expect("second in"); + + // Nothing searched yet, so neither sees anything. + let queries = |connection: &mut Connection, id: u32| -> Vec { + let reply = connection + .call(&format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"search.list"}}"# + )) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + parsed["result"]["searches"] + .as_array() + .expect("searches is an array") + .iter() + .filter_map(|search| search["query"].as_str().map(String::from)) + .collect() + }; + assert!(!queries(&mut second, 1).contains(&"shared_probe".to_string())); + + // The first client searches. + first + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"search.start","params":{"query":"shared_probe"}}"#, + ) + .expect("an answer"); + + // The second client sees that search without having run it. + assert!( + queries(&mut second, 3).contains(&"shared_probe".to_string()), + "a search in one window must appear in the other" + ); + + // And its results are the same set, because there is one search cache. + let results = |connection: &mut Connection, id: u32| -> usize { + let reply = connection + .call(&format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"search.results","params":{{"query":"shared_probe"}}}}"# + )) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + parsed["result"]["results"] + .as_array() + .expect("results is an array") + .len() + }; + assert_eq!( + results(&mut first, 4), + results(&mut second, 5), + "both windows read one search cache" + ); +} + +/// A client that attaches later starts from the session's state. +/// +/// This is what makes a second window useful at all: it opens showing what is +/// already going on rather than a blank slate that fills only with what it +/// does itself. +#[test] +fn a_client_that_attaches_later_sees_what_is_already_there() { + let daemon = daemon_or_skip!(); + + // An early client does some work. + let mut early = daemon.connect_tcp().expect("connect"); + early.authenticate(Some(&daemon.token())).expect("in"); + early + .call( + r#"{"jsonrpc":"2.0","id":1,"method":"search.start","params":{"query":"earlier_probe"}}"#, + ) + .expect("an answer"); + early + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"room.join","params":{"room":"cli_e2e_late_room"}}"#, + ) + .expect("an answer"); + + // A window opened afterwards is told about it. + let mut late = daemon.connect_tcp().expect("connect"); + late.authenticate(Some(&daemon.token())).expect("in"); + + let reply = late + .call(r#"{"jsonrpc":"2.0","id":3,"method":"search.list"}"#) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + let mut queries = parsed["result"]["searches"] + .as_array() + .expect("searches is an array") + .iter() + .filter_map(|search| search["query"].as_str()); + assert!( + queries.any(|query| query == "earlier_probe"), + "a window that opens later must see the searches already run: {reply}" + ); + + // The transfer queue is the session's, so both read the same one. + for (connection, id) in [(&mut early, 4), (&mut late, 5)] { + let reply = connection + .call(&format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"download.list"}}"# + )) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&reply).expect("valid JSON"); + assert!( + parsed["result"]["downloads"].is_array(), + "both windows read one queue: {reply}" + ); + } +} + +/// The download folder is a session setting, settable over the wire. +/// +/// `download.start` deliberately refuses a per-transfer destination, so +/// `download.set_dir` is the only way a remote owner can move where bytes +/// land — and `daemon.status` has to report the move, because that status is +/// what every attached client trusts for "where did my file go". +#[test] +#[cfg(unix)] +fn the_download_folder_moves_when_asked_and_status_says_so() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_socket().expect("socket"); + connection.authenticate(None).expect("an answer"); + + let downloads = tempfile::tempdir().expect("downloads directory"); + let moved = downloads.path().join("deeper"); + let reply = connection + .call(&format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"download.set_dir","params":{{"directory":{}}}}}"#, + serde_json::json!(moved.to_str().expect("utf-8 path")) + )) + .expect("an answer"); + assert!(reply.contains(r#""ok":true"#), "got {reply}"); + assert!( + moved.is_dir(), + "the daemon creates the folder it will download into" + ); + + let status = connection + .call(r#"{"jsonrpc":"2.0","id":3,"method":"daemon.status"}"#) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&status).expect("valid JSON"); + assert_eq!( + parsed["result"]["download_dir"], + moved.to_str().expect("utf-8 path"), + "status must name the folder transfers now land in: {status}" + ); + + // An empty path is refused as bad parameters, not applied as "here". + let refused = connection + .call( + r#"{"jsonrpc":"2.0","id":4,"method":"download.set_dir","params":{"directory":" "}}"#, + ) + .expect("an answer"); + assert!(refused.contains("-32602"), "got {refused}"); +} + +/// A wishlist search must read as *running* to the other windows. +/// +/// The daemon can only tell a running search from a finished one by the start +/// time it recorded; a wishlist search that never records one renders as +/// completed everywhere the moment it goes out. +#[test] +#[cfg(unix)] +fn a_wishlist_search_is_reported_as_just_started() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_socket().expect("socket"); + connection.authenticate(None).expect("an answer"); + + let sent = connection + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"search.wishlist","params":{"query":"wishlist_probe"}}"#, + ) + .expect("an answer"); + assert!(sent.contains(r#""ok":true"#), "got {sent}"); + + let listed = connection + .call(r#"{"jsonrpc":"2.0","id":3,"method":"search.list"}"#) + .expect("an answer"); + let parsed: serde_json::Value = + serde_json::from_str(&listed).expect("valid JSON"); + let age = parsed["result"]["searches"] + .as_array() + .expect("searches is an array") + .iter() + .find(|search| search["query"] == "wishlist_probe") + .and_then(|search| search["started_secs_ago"].as_u64()) + .expect("the wishlist search is listed with an age"); + assert!( + age < 60, + "a search sent moments ago must read as just started, got {age}s" + ); +} + +#[test] +fn a_queued_download_is_cancelled_and_listed_as_such() { + let daemon = daemon_or_skip!(); + let _peer = + silent_peer("127.0.0.1", daemon.server.port(), "cli_e2e_silent_peer"); + let mut connection = daemon.connect_tcp().expect("tcp"); + connection + .authenticate(Some(&daemon.token())) + .expect("an answer"); + let started = connection + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"download.start","params":{"username":"cli_e2e_silent_peer","filename":"@@silent\\never.mp3","size":10}}"#, + ) + .expect("an answer"); + assert!(!started.contains("error"), "got {started}"); + let reply = connection + .call( + r#"{"jsonrpc":"2.0","id":3,"method":"download.cancel","params":{"username":"cli_e2e_silent_peer","filename":"@@silent\\never.mp3"}}"#, + ) + .expect("an answer"); + assert!(reply.contains(r#""ok":true"#), "got {reply}"); + let listed = connection + .call( + r#"{"jsonrpc":"2.0","id":4,"method":"download.list","params":{}}"#, + ) + .expect("an answer"); + assert!(listed.contains(r#""state":"cancelled""#), "got {listed}"); +} + +#[test] +fn cancelling_an_unknown_download_is_answered_with_a_plain_no() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("tcp"); + connection + .authenticate(Some(&daemon.token())) + .expect("an answer"); + let reply = connection + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"download.cancel","params":{"username":"nobody","filename":"nothing.mp3"}}"#, + ) + .expect("an answer"); + assert!(reply.contains(r#""ok":false"#), "got {reply}"); +} + +/// The upload-slot limit is retunable on a live session. +#[test] +fn the_upload_slot_limit_can_be_retuned_live() { + let daemon = daemon_or_skip!(); + let mut connection = daemon.connect_tcp().expect("tcp"); + connection + .authenticate(Some(&daemon.token())) + .expect("an answer"); + let reply = connection + .call( + r#"{"jsonrpc":"2.0","id":2,"method":"upload.slots","params":{"slots":3}}"#, + ) + .expect("an answer"); + assert!(reply.contains(r#""ok":true"#), "got {reply}"); +} + +/// A wrong or missing token is the CLI's problem to explain, not just the +/// wire's: the user typed `--daemon-token`, so the failure has to come back +/// as a connection error rather than a hang or a fallback to local defaults. +#[test] +fn the_cli_reports_a_bad_token_as_a_connection_error() { + let daemon = daemon_or_skip!(); + let state = tempfile::tempdir().expect("state directory"); + let run = |token: Option<&str>| { + let mut command = cli_command(state.path()); + command.args([ + "--no-config", + "--daemon", + &format!("127.0.0.1:{}", daemon.port), + ]); + if let Some(token) = token { + command.args(["--daemon-token", token]); + } + command.arg("whoami"); + command.output().expect("the binary should run") + }; + + let wrong = run(Some(&"0".repeat(64))); + assert_eq!( + wrong.status.code(), + Some(3), + "a rejected token is a connection error: {}", + String::from_utf8_lossy(&wrong.stderr) + ); + assert!( + String::from_utf8_lossy(&wrong.stdout).trim().is_empty(), + "a failing command must write nothing to stdout" + ); + + // No token at all: this state dir never ran a daemon, so there is no + // cookie file to fall back on, and TCP without a token is a hard no. + let missing = run(None); + assert_eq!( + missing.status.code(), + Some(3), + "TCP with no token to offer is a connection error: {}", + String::from_utf8_lossy(&missing.stderr) + ); + + // The right token still works from the same machine state. + let right = run(Some(&daemon.token())); + assert_eq!( + right.status.code(), + Some(0), + "the real token must keep working: {}", + String::from_utf8_lossy(&right.stderr) + ); +} diff --git a/soulseek-rs/tests/persist_e2e.rs b/soulseek-rs/tests/persist_e2e.rs new file mode 100644 index 0000000..c678b23 --- /dev/null +++ b/soulseek-rs/tests/persist_e2e.rs @@ -0,0 +1,103 @@ +//! End-to-end test of the persistence pipeline across a simulated restart: +//! capture live-ish app state, write it through the real state files, load +//! it back in a fresh store (a "second session"), and restore it. + +use soulseek_rs::DownloadStatus; +use soulseek_rs::types::{Download, DownloadMetadata}; +use soulseek_rs_tui::models::{AppState, DownloadEntry}; +use soulseek_rs_tui::persist::config::FileConfig; +use soulseek_rs_tui::persist::snapshot::{Snapshot, restore_searches}; +use soulseek_rs_tui::persist::state::StateStore; + +fn download_entry(filename: &str, status: DownloadStatus) -> DownloadEntry { + DownloadEntry { + download: Download { + username: "peer".into(), + filename: filename.into(), + token: 1, + size: 1000, + download_directory: "/music".into(), + status, + sender: std::sync::mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } +} + +#[test] +fn state_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let state_dir = tmp.path().join("state"); + + // Session 1: user searched, joined rooms, and has one finished and one + // in-flight download when the app exits. + { + let mut state = AppState::new(); + state + .downloads + .push(download_entry("done.flac", DownloadStatus::Completed)); + state.downloads.push(download_entry( + "half.flac", + DownloadStatus::InProgress { + bytes_downloaded: 500, + total_bytes: 1000, + speed_bytes_per_sec: 1.0, + }, + )); + restore_searches(&mut state, &["beatles".to_string()]); + state.rooms.focus_or_open("indie"); + + let store = StateStore::new(state_dir.clone()); + let snapshot = Snapshot::capture(&state); + store.save_downloads(&snapshot.downloads).unwrap(); + store.save_search_queries(&snapshot.queries).unwrap(); + store.save_rooms(&snapshot.rooms).unwrap(); + } + + // The files on disk are versioned envelopes. + let raw = + std::fs::read_to_string(state_dir.join("downloads.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed.get("version").is_some(), "missing version envelope"); + + // Session 2: a fresh store (new process) sees the same state; the + // incomplete download is flagged for auto-resume. + let store = StateStore::new(state_dir); + let downloads = store.load_downloads(); + assert_eq!(downloads.len(), 2); + assert!( + downloads + .iter() + .any(|d| d.filename == "done.flac" && d.completed) + ); + assert!( + downloads + .iter() + .any(|d| d.filename == "half.flac" && !d.completed) + ); + assert_eq!(store.load_search_queries(), vec!["beatles".to_string()]); + assert_eq!(store.load_rooms(), vec!["indie".to_string()]); + + let mut state = AppState::new(); + restore_searches(&mut state, &store.load_search_queries()); + assert_eq!(state.searches[0].query, "beatles"); +} + +#[test] +fn config_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config").join("config.toml"); + + let config = FileConfig { + username: Some("alice".into()), + server: Some("localhost:2242".into()), + ..FileConfig::default() + }; + config.save(&path).unwrap(); + + // "Second session": reload from disk. + let loaded = FileConfig::load(&path).unwrap(); + assert_eq!(loaded, config); +} diff --git a/soulseek-rs/tests/tui_e2e.rs b/soulseek-rs/tests/tui_e2e.rs new file mode 100644 index 0000000..3b265b4 --- /dev/null +++ b/soulseek-rs/tests/tui_e2e.rs @@ -0,0 +1,233 @@ +//! End-to-end tests of the window itself against a real Soulseek server: +//! key presses in, a screen out, with soulfind and a real sharing peer on the +//! other end of the wire. +//! +//! The CLI suite proves the command surface; the unit tests under `ui` prove +//! the window's keys against a stand-in session. What neither covers is the +//! two together — a key that is supposed to put a search on the network and +//! bring its answers back onto the screen. The window is driven in-process +//! through the same handlers the terminal feeds, rendered onto a test +//! backend, and the session is a real client logged in to a real server. +//! +//! Like the rest of the e2e suite this needs a soulfind server and skips +//! without one; `SOULSEEK_E2E_REQUIRED=1` turns that skip into a failure. + +mod common; + +use common::{Soulfind, settle}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use soulseek_rs::Client; +use soulseek_rs_tui::MainTui; +use soulseek_rs_tui::models::SearchStatus; +use soulseek_rs_tui::persist::state::StateStore; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// How long a search collects for. Short, because every run here has to wait +/// it out; long enough for a peer on loopback to answer many times over. +const SEARCH_WINDOW: Duration = Duration::from_secs(3); + +macro_rules! server_or_skip { + () => { + match Soulfind::start() { + Some(server) => server, + None => { + common::no_server("tui e2e"); + return; + } + } + }; +} + +/// The window under test: the real `MainTui` over a real session, drawn onto +/// a buffer instead of a terminal. +struct Window { + tui: MainTui, + terminal: Terminal, +} + +impl Window { + /// Open the window as the terminal would on start-up, over `state` — the + /// directory a previous run left its search list in. + fn open(client: Arc, state: &Path, downloads: &Path) -> Self { + let tui = MainTui::new( + client, + downloads.display().to_string(), + false, + SEARCH_WINDOW, + Some(StateStore::new(state.to_path_buf())), + None, + ); + let terminal = Terminal::new(TestBackend::new(160, 40)) + .expect("a test backend always opens"); + Self { tui, terminal } + } + + fn press(&mut self, code: KeyCode) { + self.tui + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + + /// One turn of the event loop: catch up with the session, then draw. + /// Returns the screen as the user would see it. + fn frame(&mut self) -> String { + self.tui.poll_session(); + self.terminal + .draw(|frame| self.tui.render(frame)) + .expect("draw"); + self.terminal.backend().to_string() + } + + /// Keep the window running until `done` says so, and hand back the + /// screen it stopped on. Past `timeout` the test fails showing the last + /// screen, what the user would have been looking at, under `what`. + fn run_until( + &mut self, + timeout: Duration, + what: &str, + mut done: impl FnMut(&MainTui, &str) -> bool, + ) -> String { + let deadline = Instant::now() + timeout; + loop { + let screen = self.frame(); + if done(&self.tui, &screen) { + return screen; + } + assert!(Instant::now() <= deadline, "{what}:\n{screen}"); + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// File names in the Results pane right now. + fn result_names(&self) -> Vec { + self.tui + .state() + .results_items + .iter() + .map(|file| file.filename.clone()) + .collect() + } +} + +/// Whether the file the sharer offers has reached the Results pane, on +/// screen as well as in the state behind it. +fn shows_the_shared_file(tui: &MainTui, screen: &str) -> bool { + tui.state() + .results_items + .iter() + .any(|file| file.filename.contains("tui_probe_rerun")) + && screen.contains("tui_probe_rerun") +} + +/// Whether the one search has run its window out. +fn finished(tui: &MainTui, _: &str) -> bool { + tui.state().searches[0].status == SearchStatus::Completed +} + +/// The window's answer to "am I actually sharing that?", against a real +/// scan of a real folder rather than a stand-in listing. +#[test] +fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() { + let server = server_or_skip!(); + + let share = tempfile::tempdir().expect("share dir"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write( + share.path().join("album").join("tui_probe_own.bin"), + [3u8; 32], + ) + .expect("share file"); + + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + let me = Arc::new(server.client( + "tui_e2e_own_shares", + vec![share.path().display().to_string()], + )); + let mut window = Window::open(me, state.path(), downloads.path()); + + window.press(KeyCode::Char('B')); + window.press(KeyCode::Char('L')); + let screen = window.frame(); + assert!(screen.contains("My shares"), "{screen}"); + assert!( + screen.contains("album") && screen.contains("tui_probe_own.bin"), + "the scanned tree, folder by folder:\n{screen}" + ); + + window.press(KeyCode::Esc); + assert!(!window.tui.state().show_browse, "Esc hides the popup"); +} + +#[test] +fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() { + let server = server_or_skip!(); + + // The other end of the wire: a peer sharing one file with a name only + // this test searches for. + let share = tempfile::tempdir().expect("share dir"); + std::fs::write(share.path().join("tui_probe_rerun_vexq.bin"), [7u8; 64]) + .expect("share file"); + let _sharer = server + .client("tui_e2e_sharer", vec![share.path().display().to_string()]); + settle(); + + // Last time this user ran the window they searched for "vexq" and quit. + // The list came back with them; the results did not. + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + StateStore::new(state.path().to_path_buf()) + .save_search_queries(&["vexq".to_string()]) + .expect("a previous run's search list"); + + let seeker = Arc::new(server.client("tui_e2e_seeker", Vec::new())); + let mut window = Window::open(seeker, state.path(), downloads.path()); + + let screen = window.frame(); + assert!( + screen.contains("vexq"), + "the old query is listed:\n{screen}" + ); + + // One key, no typing: the window opens on the list with the query + // highlighted. + window.press(KeyCode::Char('S')); + window.run_until( + Duration::from_secs(15), + "the sharer's file should reach the screen", + shows_the_shared_file, + ); + // The search runs its window out and settles, keeping what it found. + let screen = window.run_until( + SEARCH_WINDOW + Duration::from_secs(5), + "the search should finish", + finished, + ); + assert!(shows_the_shared_file(&window.tui, &screen)); + let first_run = window.result_names(); + + // Again, now that the query has been on the wire once in this session: + // the same peer has to answer the same searcher a second time, and the + // window has to start the display over rather than stack the answers. + window.press(KeyCode::Char('1')); + window.press(KeyCode::Char('S')); + window.run_until( + Duration::from_secs(15), + "the second run should be answered too", + shows_the_shared_file, + ); + window.run_until( + SEARCH_WINDOW + Duration::from_secs(5), + "the second run should finish", + finished, + ); + assert_eq!( + window.result_names(), + first_run, + "the same answer, once, not the old set with the new one on top" + ); + assert_eq!(window.tui.state().searches.len(), 1, "still one row"); +} diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..1ad3950 --- /dev/null +++ b/website/README.md @@ -0,0 +1,50 @@ +# website + +The soulseek-rs project site. Vite + React + TypeScript + Tailwind v4, +deployed to GitHub Pages by `.github/workflows/pages.yml`. + +```bash +bun install +bun run dev # http://localhost:5173/soulseek-rs/ +bun run build # client build, SSR build, then prerender +bun run typecheck +bun run lint +``` + +## How the static site is produced + +`bun run build` runs three steps: + +1. `vite build` — the client bundle, plus `dist/index.html` as the template. +2. `vite build --ssr src/entry-server.tsx` — the same app compiled for Node. +3. `bun scripts/prerender.ts` — renders every route in `src/lib/routes.ts` to + its own file (`dist/install/index.html`, and so on) with that route's title + and description substituted in, plus a `404.html`. + +Without step 3 GitHub Pages has no file at `/install` and answers a direct link +with a 404. With it, every URL returns 200 with fully rendered markup, and the +client hydrates that markup rather than replacing it. + +Adding a page means: a component in `src/pages/`, a `` in `src/App.tsx`, +and an entry in `src/lib/routes.ts`. The last one is what the prerenderer walks. + +## Design system + +Tokens live in `src/index.css`, ported from the Claude Design project's +`tokens/colors.css`. They are the same values that back the TUI in +`soulseek-rs/src/ui/styles.rs` — change one, change both. + +Dark is the native theme; light re-cuts the semantic hues roughly 10% darker, +because phosphor and tape are legible on vinyl but wash out on manila. Theme +selection is `[data-theme]` on ``, defaulting to the OS setting, with an +inline script in `index.html` applying it before first paint so the page never +flashes the wrong background. + +## Notes + +- `base` in `vite.config.ts` is `/soulseek-rs/` because this is a project site. + Pointing a custom domain at it means setting that to `/` (the router reads + `import.meta.env.BASE_URL`, so it follows automatically). +- Copy in `src/pages/` follows the project's tone of voice: plainspoken, + precise, honest about limits first. No copyrighted artist or album names + anywhere, including the TUI demo data in `src/components/tui/data.ts`. diff --git a/website/bun.lock b/website/bun.lock new file mode 100644 index 0000000..e524df0 --- /dev/null +++ b/website/bun.lock @@ -0,0 +1,713 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "soulseek-rs-website", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", + "tailwind-merge": "^3.6.0", + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "eslint": "^10.7.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.1", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A=="], + + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], + + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-router": ["react-router@8.3.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/website/components.json b/website/components.json new file mode 100644 index 0000000..ba0c18c --- /dev/null +++ b/website/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/website/eslint.config.ts b/website/eslint.config.ts new file mode 100644 index 0000000..755a0fc --- /dev/null +++ b/website/eslint.config.ts @@ -0,0 +1,88 @@ +import js from '@eslint/js' +import { defineConfig } from 'eslint/config' +import jsxA11y from 'eslint-plugin-jsx-a11y' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default defineConfig( + { ignores: ['dist', 'dist-ssr', 'node_modules'] }, + + js.configs.recommended, + + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + + reactHooks.configs.flat['recommended-latest'], + jsxA11y.flatConfigs.recommended, + + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2023, + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + 'react-refresh': reactRefresh, + }, + rules: { + 'react-refresh/only-export-components': [ + 'error', + { allowConstantExport: true }, + ], + + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + + 'no-console': ['error', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'prefer-const': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + }, + }, + + { + files: ['src/components/tui/hero-terminal.tsx'], + rules: { + 'jsx-a11y/no-noninteractive-tabindex': 'off', + 'jsx-a11y/no-noninteractive-element-interactions': 'off', + }, + }, + + { + files: ['*.config.ts', 'scripts/**/*.ts'], + languageOptions: { + globals: globals.node, + }, + rules: { + 'no-console': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, + { + files: ['src/entry-server.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, +) diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..f384c80 --- /dev/null +++ b/website/index.html @@ -0,0 +1,61 @@ + + + + + + + + soulseek-rs, a Soulseek client for your terminal + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..2030bb0 --- /dev/null +++ b/website/package.json @@ -0,0 +1,42 @@ +{ + "name": "soulseek-rs-website", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build && bun run build:ssr && bun run prerender", + "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr", + "prerender": "bun scripts/prerender.ts", + "preview": "vite preview", + "test:install": "sh scripts/test-install.sh", + "typecheck": "tsc -b", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "eslint": "^10.7.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5" + } +} diff --git a/website/public/agent-demo.svg b/website/public/agent-demo.svg new file mode 100644 index 0000000..545112a --- /dev/null +++ b/website/public/agent-demo.svg @@ -0,0 +1 @@ +▐▛███▜▌ClaudeCodev2.1.220▝▜█████▛▘▘▘▝▝~/src/rust/soulseek-rs─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────❯download the 4 latest tracks of Save Your Atoll using soulseek-rs❯--INSERT--⏺⏺I'llusethesoulseek-rsskillforthis.⏺Skill(soulseek-rs)⎿Successfullyloadedskill⏺Running1shellcommand…Running1shellcommand…⎿$soulseek-rswhoami--json2>&1;echo"EXIT:$status"You'veused82%ofyourweeklylimit·resets10am(Europe/Amsterdam)⏺Running2shellcommands…Running2shellcommands…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(3s)(ctrl+bctrl+b(twice)toruninbackground)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(4s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(5s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(7s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(8s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(9s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(11s)Pluginsupdated:3plugins·Run/reload-pluginstoapply⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(12s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(14s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(15s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(17s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(18s)⏺Running2shellcommands·19s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(19s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(21s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(22s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(23s)⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(25s)Ran2shellcommands⏺Two2026releasesexist(DisconnectEPandRidondanza).Letmepindownwhichisnewer.⎿$soulseek-rssearch'Save_Your_AtollRidondanza'--json--search-timeout20-n02>/dev/null|jq-r'.path'|sort-u;echo"EXIT:$status"⏺Running1shellcommand·3s…Running1shellcommand·4s…"EXIT:$status"(4s)Running1shellcommand·5s…"EXIT:$status"(5s)⏺Running1shellcommand·5s…Running1shellcommand·6s…"EXIT:$status"(6s)⏺Running1shellcommand·6s…Running1shellcommand·7s…Running1shellcommand·8s…"EXIT:$status"(8s)⏺Running1shellcommand·8s…Running1shellcommand·9s…"EXIT:$status"(9s)⏺Running1shellcommand·9s…Running1shellcommand·10s…⏺Running1shellcommand·11s…"EXIT:$status"(11s)Running1shellcommand·11s…⏺Running1shellcommand·12s…"EXIT:$status"(12s)Running1shellcommand·12s…⏺Running1shellcommand·13s…Running1shellcommand·14s…"EXIT:$status"(14s)⏺Running1shellcommand·14s…Running1shellcommand·15s…"EXIT:$status"(15s)⏺Running1shellcommand·15s…Running1shellcommand·16s…"EXIT:$status"(16s)⏺Running1shellcommand·16s…Running1shellcommand·17s…⏺Running1shellcommand·18s…"EXIT:$status"(18s)⏺Running1shellcommand·19s…Running1shellcommand·20s…"EXIT:$status"(20s)⏺Running1shellcommand·20s…Running1shellcommand·21s…Ran1shellcommand⏺Ridondanza(2026-05-23)isnewerthanDisconnectEP(2026-04-17).Its4SaveYourAtolloriginalsarethelatesttracks—the5thisanAlderaanremix.GrabbingthosefourinFLAC:⎿$soulseek-rssearch'Save_Your_AtollRidondanza'--json--search-timeout20-n02>/dev/null\|jq-c'select((.path|test("\\.flac$"))and(.path|test("ridondanza";"i"))and(.path|test("alderaan";"i")|not))'\|jq-s'sort_by(-.free_slot_n//0)'2>/dev/null||trueecho"---raw---"soulseek-rs…⏺Running1shellcommand·2s…soulseek-rs…(4s)soulseek-rs…(5s)soulseek-rs…(6s)soulseek-rs…(8s)soulseek-rs…(10s)⏺Running1shellcommand·10s…soulseek-rs…(11s)soulseek-rs…(12s)soulseek-rs…(14s)soulseek-rs…(15s)soulseek-rs…(16s)⏺Running1shellcommand·17s…soulseek-rs…(18s)soulseek-rs…(19s)soulseek-rs…(21s)Running1shellcommand·22s…soulseek-rs…(22s·2lines)⏺Running1shellcommand·22s…Running1shellcommand·23s…⏺Running1shellcommand·24s…soulseek-rs…(24s·2lines)Running1shellcommand·24s…⏺Running1shellcommand·25s…soulseek-rs…(25s·2lines)⏺Running1shellcommand·26s…soulseek-rs…(26s·2lines)Running1shellcommand·26s…Running1shellcommand·28s…soulseek-rs…(28s·2lines)⏺Running1shellcommand·28s…soulseek-rs…(29s·2lines)⏺Running1shellcommand·29s…Running1shellcommand·30s…soulseek-rs…(30s·2lines)⏺Running1shellcommand·30s…⏺Running1shellcommand·31s…soulseek-rs…(31s·2lines)Running1shellcommand·31s…Running1shellcommand·32s…Running1shellcommand·33s…soulseek-rs…(33s·2lines)Running1shellcommand·34s…soulseek-rs…(34s·2lines)⏺Running1shellcommand·34s…Running1shellcommand·35s…⏺Running1shellcommand·36s…soulseek-rs…(36s·2lines)⏺Running1shellcommand·37s…Running1shellcommand·38s…soulseek-rs…(38s·2lines)⏺Running1shellcommand·38s…Running1shellcommand·39s…soulseek-rs…(39s·2lines)⏺Running1shellcommand·39s…Running1shellcommand·40s…soulseek-rs…(40s·2lines)soulseek-rs…(42s·2lines)Running1shellcommand·42s…⏺Fourtracks,onepeer,freeslots,~132MBtotal.Downloading:|jq-c'select((.path|test("\\.flac$"))and(.path|test("alderaan";"i")|not)and.user=="beatrice.songbird2")'\|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(3s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(5s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(6s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(7s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(9s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(10s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(12s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(14s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(15s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(17s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(19s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(20s)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(22s·2lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(23s·7lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(24s·7lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(26s·7lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(28s·7lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(30s·8lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(31s·9lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(32s·10lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(34s·13lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(35s·14lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(36s·15lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(38s·17lines)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(39s·19lines)⏺Running1shellcommand·41s…|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(41s·20lines)⏺Listing1directory,running2shellcommands…⎿$ls-lh~/Downloads/Soulseek/*santucci*.flacListed1directory,ran2shellcommands⏺Done—4filesin~/Downloads/Soulseek/,16-bitWEBFLAC,~126MBtotal:┌─────┬────────────┬──────┐│#│Track│Size│├─────┼────────────┼──────┤│01│Abisso│32M││02│Ridondanza│31M││03│Emersione│31M││04│Ascesa│32M│└─────┴────────────┴──────┘FromRidondanza(TS006,w/GabrieleSantucci)—released2026-05-23,theartist'snewest.VerifiednewerthantheDisconnectEP(2026-04-17)bycross-checkingscenefolderdates.├─────┼────────────┼──────┤❯ downloadthe4latesttracksofSaveYourAtollusingsoulseek-rs--INSERT--·Hashing…✳Hashing…✽Hashing…✶Hashing…·Levitating…✶Levitating…(2s·↓5tokens)✽Levitating…(3s·↓14tokens)✳Levitating…(3s·↓16tokens)·Levitating…(4s·↓16tokens·thinking)✽Levitating…(4s·↓16tokens·thinking)✶Levitating…(5s·↓16tokens·thinking)·Levitating…(6s·↓16tokens·thinking)✶Levitating…(6s·↓16tokens·thinkingeffort)✽Levitating…(7s·↓16tokens·thinking)✢Levitating…(7s·↓154tokens·thoughtfor3s)·Levitating…(8s·↓304tokens·thoughtfor3s)✻Levitating…(8s·↓341tokens·thoughtfor3s)✻Levitating…(9s·↓349tokens)·Levitating…(9s·↓404tokens)✳Levitating…(10s·↓412tokens)✽Levitating…(11s·↓419tokens)✳Levitating…(11s·↓419tokens·thinking)·Levitating…(12s·↓419tokens·thinking)✶Levitating…(12s·↓419tokens·thinkingeffort)✽Levitating…(13s·↓538tokens·thinking)✢Levitating…(13s·↓577tokens·thoughtfor1s)✢Levitating…(14s·↓603tokens·thoughtfor1s)✽Levitating…(14s·↓611tokens·thoughtfor1s)✶Levitating…(15s·↓614tokens)·Levitating…(16s·↓614tokens)⏺Running2shellcommands·2s…✶Levitating…(16s·↓614tokens)Running2shellcommands·3s…✽Levitating…(17s·↓679tokens)⏺Running2shellcommands·3s…✢Levitating…(17s·↓693tokens)Running2shellcommands·4s…✢Levitating…(18s·↓701tokens)⏺Running2shellcommands·4s…✽Levitating…(18s·↓703tokens)Running2shellcommands·5s…✶Levitating…(19s·↓703tokens)⏺Running2shellcommands·5s…·Levitating…(19s·↓703tokens)⏺Running2shellcommands·6s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(6s)✳Levitating…(20s·↓703tokens)Running2shellcommands·7s…✽Levitating…(20s·↓703tokens)⏺Running2shellcommands·7s…✳Levitating…(21s·↓703tokens)Running2shellcommands·8s…·Levitating…(22s·↓703tokens)⏺Running2shellcommands·8s…✻Levitating…(22s·↓703tokens)Running2shellcommands·9s…✻Levitating…(23s·↓703tokens)⏺Running2shellcommands·9s…·Levitating…(23s·↓703tokens)Running2shellcommands·10s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(10s)✳Levitating…(24s·↓703tokens)⏺Running2shellcommands·11s…✽Levitating…(25s·↓703tokens)Running2shellcommands·11s…✳Levitating…(25s·↓703tokens)⏺Running2shellcommands·12s…✢Levitating…(26s·↓703tokens)Running2shellcommands·12s…✻Levitating…(26s·↓703tokens)⏺Running2shellcommands·13s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(13s)✶Levitating…(27s·↓703tokens)Running2shellcommands·14s…·Levitating…(28s·↓703tokens)⏺Running2shellcommands·14s…✶Levitating…(28s·↓703tokens)Running2shellcommands·15s…✻Levitating…(29s·↓703tokens)⏺Running2shellcommands·15s…·Levitating…(29s·↓703tokens)Running2shellcommands·16s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(16s)✳Levitating…(30s·↓703tokens)⏺Running2shellcommands·17s…✽Levitating…(31s·↓703tokens)Running2shellcommands·17s…✳Levitating…(31s·↓703tokens)⏺Running2shellcommands·18s…·Levitating…(32s·↓703tokens)Running2shellcommands·18s…✶Levitating…(32s·↓703tokens)✽Levitating…(33s·↓703tokens)·Levitating…(33s·↓703tokens)Running2shellcommands·20s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(20s)✳Levitating…(34s·↓703tokens)⏺Running2shellcommands·21s…✽Levitating…(34s·↓703tokens)Running2shellcommands·21s…✶Levitating…(35s·↓703tokens)⏺Running2shellcommands·22s…·Levitating…(36s·↓703tokens)Running2shellcommands·22s…✻Levitating…(36s·↓703tokens)⏺Running2shellcommands·23s…✻Levitating…(37s·↓703tokens)Running2shellcommands·23s…·Levitating…(37s·↓703tokens)⏺Running2shellcommands·24s…⎿$soulseek-rssearch'SaveYourAtoll'--json--search-timeout25-n02>/dev/null;echo"EXIT:$status"(24s)✳Levitating…(38s·↓703tokens)Running2shellcommands·25s…✽Levitating…(39s·↓703tokens)⏺Running2shellcommands·25s…✳Levitating…(39s·↓703tokens)✢Levitating…(40s·↓703tokens)✻Levitating…(40s·↓703tokens)✻Levitating…(41s·↓703tokens·thinking)·Levitating…(41s·↓703tokens·thinking)✳Levitating…(42s·↓703tokens·thinkingeffort)✽Levitating…(42s·↓703tokens·thinking)✳Levitating…(43s·↓703tokens·thinking)·Levitating…(44s·↓703tokens·thinking)✻Levitating…(44s·↓703tokens·thinkingeffort)✻Levitating…(45s·↓703tokens·thinking)·Levitating…(45s·↓703tokens·thinking)✳Levitating…(46s·↓703tokens·thinkingeffort)✽Levitating…(46s·↓703tokens·thinking)✳Levitating…(47s·↓703tokens·thinking)·Levitating…(48s·↓703tokens·thinking)✻Levitating…(48s·↓703tokens·thinkingeffort)✻Levitating…(49s·↓703tokens·thinking)·Levitating…(49s·↓703tokens·thinking)✳Levitating…(50s·↓703tokens·thinkingeffort)✽Levitating…(50s·↓703tokens·thinking)✶Levitating…(51s·↓703tokens·stillthinking)·Levitating…(52s·↓791tokens·thoughtfor10s)✻Levitating…(52s·↓928tokens·thoughtfor10s)✻Levitating…(53s·↓1.1ktokens·thoughtfor10s)✢Levitating…(53s·↓1.2ktokens·thoughtfor10s)✳Levitating…(54s·↓1.4ktokens)✽Levitating…(55s·↓1.5ktokens)✳Levitating…(55s·↓1.5ktokens)·Levitating…(56s·↓1.5ktokens)Running1shellcommand·2s…✻Levitating…(56s·↓1.5ktokens)"EXIT:$status"(3s)✻Levitating…(57s·↓1.7ktokens)✢Levitating…(57s·↓1.7ktokens)⏺Running1shellcommand·4s…✳Levitating…(58s·↓1.7ktokens)✽Levitating…(59s·↓1.7ktokens)✳Levitating…(59s·↓1.7ktokens)·Levitating…(1m0s·↓1.7ktokens)✻Levitating…(1m1s·↓1.7ktokens)"EXIT:$status"(7s)✽Levitating…(1m1s·↓1.7ktokens)✢Levitating…(1m2s·↓1.7ktokens)✢Levitating…(1m2s·↓1.7ktokens)✽Levitating…(1m3s·↓1.7ktokens)✳Levitating…(1m4s·↓1.7ktokens)"EXIT:$status"(10s)·Levitating…(1m4s·↓1.7ktokens)✻Levitating…(1m5s·↓1.7ktokens)✻Levitating…(1m5s·↓1.7ktokens)·Levitating…(1m6s·↓1.7ktokens)✳Levitating…(1m6s·↓1.7ktokens)"EXIT:$status"(13s)✽Levitating…(1m7s·↓1.7ktokens)✢Levitating…(1m8s·↓1.7ktokens)✳Levitating…(1m8s·↓1.7ktokens)✽Levitating…(1m9s·↓1.7ktokens)✢Levitating…(1m10s·↓1.7ktokens)✢Levitating…(1m10s·↓1.7ktokens)✽Levitating…(1m11s·↓1.7ktokens)"EXIT:$status"(17s)✶Levitating…(1m11s·↓1.7ktokens)·Levitating…(1m12s·↓1.7ktokens)Running1shellcommand·18s…✳Levitating…(1m13s·↓1.7ktokens)"EXIT:$status"(19s)✻Levitating…(1m13s·↓1.7ktokens)✢Levitating…(1m14s·↓1.7ktokens)✳Levitating…(1m14s·↓1.7ktokens)"EXIT:$status"(21s)✽Levitating…(1m15s·↓1.7ktokens)✳Levitating…(1m16s·↓1.7ktokens)·Levitating…(1m16s·↓1.7ktokens)✻Levitating…(1m17s·↓1.7ktokens)✻Levitating…(1m17s·↓1.7ktokens)·Levitating…(1m18s·↓1.7ktokens·thinking)✳Levitating…(1m18s·↓1.7ktokens·thinkingeffort)✽Levitating…(1m19s·↓1.7ktokens·thinking)✳Levitating…(1m20s·↓1.7ktokens·thinking)·Levitating…(1m20s·↓1.7ktokens·thinking)✶Levitating…(1m21s·↓1.7ktokens·thinkingeffort)✻Levitating…(1m21s·↓1.7ktokens·thinking)·Levitating…(1m22s·↓1.7ktokens·thinking)✳Levitating…(1m22s·↓1.7ktokens·thinkingeffort)✽Levitating…(1m23s·↓1.7ktokens·thinking)✳Levitating…(1m24s·↓1.7ktokens·thinking)·Levitating…(1m24s·↓1.7ktokens·thinking)✻Levitating…(1m25s·↓1.7ktokens·thinkingeffort)✻Levitating…(1m25s·↓1.7ktokens·thinking)·Levitating…(1m26s·↓1.7ktokens·thinking)✢Levitating…(1m26s·↓1.8ktokens·thoughtfor9s)✽Levitating…(1m27s·↓2.0ktokens·thoughtfor9s)✶Levitating…(1m28s·↓2.1ktokens·thoughtfor9s)·Levitating…(1m28s·↓2.2ktokens·thoughtfor9s)✳Levitating…(1m29s·↓2.3ktokens)✽Levitating…(1m29s·↓2.4ktokens)✢Levitating…(1m30s·↓2.5ktokens)✳Levitating…(1m30s·↓2.5ktokens)✽Levitating…(1m31s·↓2.5ktokens)✢Levitating…(1m32s·↓2.5ktokens)·Levitating…(1m32s·↓2.6ktokens)✻Levitating…(1m33s·↓2.6ktokens)✶Levitating…(1m33s·↓2.6ktokens)·Levitating…(1m34s·↓2.6ktokens)soulseek-rs…(3s)✻Levitating…(1m35s·↓2.7ktokens)✽Levitating…(1m35s·↓2.8ktokens)·Levitating…(1m36s·↓2.8ktokens)✳Levitating…(1m36s·↓2.9ktokens)✽Levitating…(1m37s·↓2.9ktokens)✳Levitating…(1m38s·↓2.9ktokens)·Levitating…(1m38s·↓2.9ktokens)soulseek-rs…(7s)✻Levitating…(1m39s·↓2.9ktokens)✽Levitating…(1m39s·↓2.9ktokens)·Levitating…(1m40s·↓2.9ktokens)soulseek-rs…(9s)✳Levitating…(1m40s·↓2.9ktokens)✽Levitating…(1m41s·↓2.9ktokens)✢Levitating…(1m42s·↓2.9ktokens)✳Levitating…(1m42s·↓2.9ktokens)✽Levitating…(1m43s·↓2.9ktokens)✳Levitating…(1m44s·↓2.9ktokens)·Levitating…(1m44s·↓2.9ktokens)Running1shellcommand·13s…soulseek-rs…(13s)✻Levitating…(1m45s·↓2.9ktokens)✻Levitating…(1m45s·↓2.9ktokens)·Levitating…(1m46s·↓2.9ktokens)✳Levitating…(1m46s·↓2.9ktokens)✽Levitating…(1m47s·↓2.9ktokens)✳Levitating…(1m48s·↓2.9ktokens)·Levitating…(1m48s·↓2.9ktokens)soulseek-rs…(17s)✻Levitating…(1m49s·↓2.9ktokens)✽Levitating…(1m49s·↓2.9ktokens)✢Levitating…(1m50s·↓2.9ktokens)✢Levitating…(1m50s·↓2.9ktokens)✽Levitating…(1m51s·↓2.9ktokens)soulseek-rs…(20s)✢Levitating…(1m52s·↓2.9ktokens)·Levitating…(1m52s·↓2.9ktokens)⏺Running1shellcommand·21s…✽Levitating…(1m53s·↓2.9ktokens)✻Levitating…(1m53s·↓2.9ktokens)·Levitating…(1m54s·↓2.9ktokens)soulseek-rs…(23s·2lines)✳Levitating…(1m54s·↓2.9ktokens)✽Levitating…(1m55s·↓2.9ktokens)·Levitating…(1m56s·↓2.9ktokens)✳Levitating…(1m56s·↓2.9ktokens)Running1shellcommand·25s…✽Levitating…(1m57s·↓2.9ktokens)✳Levitating…(1m58s·↓2.9ktokens)·Levitating…(1m58s·↓2.9ktokens)⏺Running1shellcommand·27s…soulseek-rs…(27s·2lines)✻Levitating…(1m59s·↓2.9ktokens)✻Levitating…(1m59s·↓2.9ktokens)·Levitating…(2m0s·↓2.9ktokens)Running1shellcommand·29s…✳Levitating…(2m0s·↓2.9ktokens)✽Levitating…(2m1s·↓2.9ktokens)✳Levitating…(2m2s·↓2.9ktokens)·Levitating…(2m2s·↓2.9ktokens)✶Levitating…(2m3s·↓2.9ktokens)✻Levitating…(2m3s·↓2.9ktokens)soulseek-rs…(32s·2lines)✢Levitating…(2m4s·↓2.9ktokens)✳Levitating…(2m4s·↓2.9ktokens)✽Levitating…(2m5s·↓2.9ktokens)✳Levitating…(2m6s·↓2.9ktokens)·Levitating…(2m6s·↓2.9ktokens)soulseek-rs…(35s·2lines)✻Levitating…(2m7s·↓2.9ktokens)✽Levitating…(2m7s·↓2.9ktokens)Running1shellcommand·36s…·Levitating…(2m8s·↓2.9ktokens)soulseek-rs…(37s·2lines)✳Levitating…(2m9s·↓2.9ktokens)✽Levitating…(2m9s·↓2.9ktokens)·Levitating…(2m10s·↓2.9ktokens)✳Levitating…(2m10s·↓2.9ktokens)✽Levitating…(2m11s·↓2.9ktokens)✳Levitating…(2m12s·↓2.9ktokens)⏺Running1shellcommand·40s…·Levitating…(2m12s·↓2.9ktokens)Running1shellcommand·41s…soulseek-rs…(41s·2lines)✻Levitating…(2m13s·↓2.9ktokens)⏺Running1shellcommand·42s…✻Levitating…(2m13s·↓2.9ktokens)·Levitating…(2m14s·↓2.9ktokens)✳Levitating…(2m14s·↓2.9ktokens)✽Levitating…(2m15s·↓2.9ktokens)✳Levitating…(2m16s·↓2.9ktokens)·Levitating…(2m16s·↓2.9ktokens·thinking)✻Levitating…(2m17s·↓2.9ktokens·thinkingeffort)✻Levitating…(2m17s·↓3.0ktokens·thinking)✢Levitating…(2m18s·↓3.0ktokens·thoughtfor1s)✳Levitating…(2m18s·↓3.0ktokens·thoughtfor1s)✽Levitating…(2m19s·↓3.1ktokens·thoughtfor1s)✳Levitating…(2m20s·↓3.1ktokens·thoughtfor1s)·Levitating…(2m20s·↓3.1ktokens)✻Levitating…(2m21s·↓3.1ktokens)✻Levitating…(2m21s·↓3.1ktokens)Running1shellcommand·3s…·Levitating…(2m22s·↓3.2ktokens)✳Levitating…(2m22s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(4s)✽Levitating…(2m23s·↓3.2ktokens)✢Levitating…(2m24s·↓3.2ktokens)✢Levitating…(2m24s·↓3.2ktokens)✽Levitating…(2m25s·↓3.2ktokens)✶Levitating…(2m25s·↓3.2ktokens)⏺Running1shellcommand·7s…·Levitating…(2m26s·↓3.2ktokens)✶Levitating…(2m27s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(8s)✻Levitating…(2m27s·↓3.2ktokens)·Levitating…(2m28s·↓3.2ktokens)✳Levitating…(2m28s·↓3.2ktokens)✽Levitating…(2m29s·↓3.2ktokens)✳Levitating…(2m30s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(11s)✢Levitating…(2m30s·↓3.2ktokens)✻Levitating…(2m31s·↓3.2ktokens)✶Levitating…(2m31s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(13s)✢Levitating…(2m32s·↓3.2ktokens)✻Levitating…(2m33s·↓3.2ktokens)✻Levitating…(2m33s·↓3.2ktokens)✢Levitating…(2m34s·↓3.2ktokens)✳Levitating…(2m34s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(16s)✻Levitating…(2m35s·↓3.2ktokens)·Levitating…(2m36s·↓3.2ktokens)✳Levitating…(2m36s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(18s)✻Levitating…(2m37s·↓3.2ktokens)Running1shellcommand·19s…·Levitating…(2m38s·↓3.2ktokens)✳Levitating…(2m38s·↓3.2ktokens)✽Levitating…(2m39s·↓3.2ktokens)✶Levitating…(2m39s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(21s)·Levitating…(2m40s·↓3.2ktokens)✻Levitating…(2m41s·↓3.2ktokens)✻Levitating…(2m41s·↓3.2ktokens)⏺Running1shellcommand·23s…·Levitating…(2m42s·↓3.2ktokens)✳Levitating…(2m42s·↓3.2ktokens)✽Levitating…(2m43s·↓3.2ktokens)✳Levitating…(2m44s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(25s·7lines)✢Levitating…(2m44s·↓3.2ktokens)✽Levitating…(2m45s·↓3.2ktokens)✶Levitating…(2m45s·↓3.2ktokens)Running1shellcommand·27s…|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(27s·7lines)·Levitating…(2m46s·↓3.2ktokens)✽Levitating…(2m47s·↓3.2ktokens)✶Levitating…(2m47s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(29s·7lines)·Levitating…(2m48s·↓3.2ktokens)✻Levitating…(2m49s·↓3.2ktokens)✻Levitating…(2m49s·↓3.2ktokens)·Levitating…(2m50s·↓3.2ktokens)✳Levitating…(2m50s·↓3.2ktokens)✽Levitating…(2m51s·↓3.2ktokens)⏺Running1shellcommand·32s…✶Levitating…(2m52s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(33s·12lines)·Levitating…(2m52s·↓3.2ktokens)✻Levitating…(2m53s·↓3.2ktokens)✻Levitating…(2m53s·↓3.2ktokens)⏺Running1shellcommand·35s…·Levitating…(2m54s·↓3.2ktokens)✳Levitating…(2m54s·↓3.2ktokens)✽Levitating…(2m55s·↓3.2ktokens)✳Levitating…(2m56s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(37s·16lines)·Levitating…(2m56s·↓3.2ktokens)✻Levitating…(2m57s·↓3.2ktokens)✻Levitating…(2m57s·↓3.2ktokens)·Levitating…(2m58s·↓3.2ktokens)✳Levitating…(2m58s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(40s·20lines)✽Levitating…(2m59s·↓3.2ktokens)✢Levitating…(3m0s·↓3.2ktokens)✳Levitating…(3m0s·↓3.2ktokens)|soulseek-rsdownload--stdin--json--timeout300;echo"EXIT:$status"(42s·21lines)✽Levitating…(3m1s·↓3.2ktokens)✳Levitating…(3m2s·↓3.2ktokens)·Levitating…(3m2s·↓3.2ktokens)✻Levitating…(3m3s·↓3.2ktokens)✻Levitating…(3m3s·↓3.2ktokens)·Levitating…(3m4s·↓3.3ktokens·thinking)✢Levitating…(3m4s·↓3.3ktokens·thinkingeffort)Listing1directory,running2shellcommands…✽Levitating…(3m5s·↓3.3ktokens·thinking)✳Levitating…(3m6s·↓3.3ktokens·thoughtfor1s)✢Levitating…(3m6s·↓3.3ktokens·thoughtfor1s)┌─────┬────────┬──────┐│#│Track│Size│├─────┼────────┼──────┤│01│Abisso│32M│└─────┴────────┴──────┘⏺Done—4filesin~/Downloads/Soulseek/,16-bitWEBFLAC,~├─────│Track│Size│Skipped:the5thtrack,Ridondanza(AlderaanRemix)—aremixbysomeoneelse,sonotaSaveYourAtolltrack.Saythewordifyouwantit,ortheDisconnectEP's4tracksinstead.✻Brewedfor3m9s❯⏺Ridondanza(2026-05-23)isnewerthanDisconnectEP(2026-04-17).Its4SaveYourAtolloriginalsarethelatesttracks—the5thisanAlderaanremix.GrabbingthosefourinFLAC:Ran1shellcommand⏺Fourtracks,onepeer,freeslots,~132MBtotal.Downloading:Listed1directory,ran2shellcommands⏺Done—4filesin~/Downloads/Soulseek/,16-bitWEBFLAC,~126MBtotal:┌─────┬────────────┬──────┐│#│Track│Size││01│Abisso│32M││02│Ridondanza│31M││03│Emersione│31M││04│Ascesa│32M│└─────┴────────────┴──────┘FromRidondanza(TS006,w/GabrieleSantucci)—released2026-05-23,theartist'snewest.VerifiednewerthantheDisconnectEP(2026-04-17)bycross-checkingscenefolderdates.Skipped:the5thtrack,Ridondanza(AlderaanRemix)—aremixbysomeoneelse,sonotaSaveYourAtolltrack.Saythewordifyouwantit,ortheDisconnectEP's4tracksinstead.✻Brewedfor3m9s───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────❯────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000..2c42539 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/website/public/install.sh b/website/public/install.sh new file mode 100644 index 0000000..165d778 --- /dev/null +++ b/website/public/install.sh @@ -0,0 +1,176 @@ +#!/bin/sh +# Installs soulseek-rs from GitHub releases. +# +# Latest stable release: +# curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh +# +# Latest successful build from develop: +# curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly +# +# Stable installs use Homebrew when it is on PATH (macOS and Linux). Nightly +# installs always download the prebuilt binary for this platform. Direct +# downloads are sha256-verified and replace an existing binary atomically. +# +# SOULSEEK_RS_INSTALL_DIR overrides where the binary lands. +set -eu + +REPO="michel/soulseek-rs" + +say() { printf '%s\n' "$*"; } +die() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 +} +usage() { + cat <<'EOF' +Usage: install.sh [--nightly] + +Install the latest stable soulseek-rs release. Options: + --nightly Install the latest successful build from the develop branch. + -h, --help Show this help. + +When piping the script, pass the option after `sh -s --`: + curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly + +SOULSEEK_RS_INSTALL_DIR overrides the destination directory. +EOF +} + +channel="stable" +while [ "$#" -gt 0 ]; do + case "$1" in + --nightly) channel="nightly" ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + [ "$#" -eq 0 ] || die "unexpected argument '$1' (try --help)" + break + ;; + *) die "unknown option '$1' (try --help)" ;; + esac + shift +done + +if [ "$channel" = "nightly" ]; then + tag="nightly" + release_page="https://github.com/$REPO/releases/tag/nightly" +else + tag="" + release_page="https://github.com/$REPO/releases/latest" +fi + +system=$(uname -s) +case "$system" in + Darwin) os="apple-darwin" ;; + Linux) os="unknown-linux-musl" ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + die "no install script for Windows: take the pc-windows-msvc zip from $release_page, or run 'cargo install soulseek-rs'" ;; + *) die "unsupported OS '$system': see $release_page" ;; +esac + +machine=$(uname -m) +case "$machine" in + x86_64 | amd64) arch="x86_64" ;; + arm64 | aarch64) arch="aarch64" ;; + *) die "unsupported architecture '$machine': builds cover x86_64 and aarch64" ;; +esac +target="$arch-$os" + +if [ "$channel" = "stable" ] && [ -z "${SOULSEEK_RS_INSTALL_DIR:-}" ] && + command -v brew >/dev/null 2>&1; then + say "Homebrew found: installing michel/tap/soulseek-rs (brew also handles upgrades)" + exec brew install michel/tap/soulseek-rs +fi + +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO- "$1"; } +else + die "need curl or wget" +fi + +command -v tar >/dev/null 2>&1 || die "need tar to unpack the download" +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1"; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1"; } +else + die "need sha256sum or shasum to verify the download" +fi + +if [ "$channel" = "stable" ]; then + tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) + [ -n "$tag" ] || die "could not resolve the latest release; downloads are at $release_page" + case "$tag" in + v*.*.*) ;; + *) die "latest release returned an unexpected tag '$tag'" ;; + esac + case "$tag" in + *[!0-9A-Za-z._+-]*) die "latest release returned an unsafe tag '$tag'" ;; + esac +fi + +archive="soulseek-rs-$tag-$target.tar.gz" +base_url="https://github.com/$REPO/releases/download/$tag" + +tmp="" +staged="" +cleanup() { + [ -z "$staged" ] || rm -f "$staged" >/dev/null 2>&1 || : + [ -z "$tmp" ] || rm -rf "$tmp" >/dev/null 2>&1 || : +} +trap cleanup EXIT HUP INT TERM + +tmp=$(mktemp -d) || die "could not create a temporary directory" +say "Downloading $archive" +fetch "$base_url/$archive" >"$tmp/$archive" || die "could not download $archive from $release_page" +fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" || + die "could not download the sha256 checksum from $release_page" + +got=$(sha256 "$tmp/$archive") || die "could not calculate the sha256 for $archive" +got=${got%% *} +want="" +IFS=' ' read -r want _ <"$tmp/expected.sha256" || : +[ -n "$want" ] || die "the sha256 checksum from $release_page was empty" +[ "$got" = "$want" ] || die "sha256 mismatch for $archive: expected $want, got $got" + +tar -xzf "$tmp/$archive" -C "$tmp" || die "could not unpack $archive" +[ -f "$tmp/soulseek-rs" ] || die "archive $archive did not contain a soulseek-rs binary" + +dir="${SOULSEEK_RS_INSTALL_DIR:-}" +if [ -z "$dir" ]; then + dir="/usr/local/bin" + if ! { [ -d "$dir" ] && [ -w "$dir" ]; }; then + [ -n "${HOME:-}" ] || + die "HOME is not set and /usr/local/bin is not writable; set SOULSEEK_RS_INSTALL_DIR" + dir="$HOME/.local/bin" + fi +fi +mkdir -p "$dir" || die "could not create install directory '$dir'" +[ -d "$dir" ] || die "install destination '$dir' is not a directory" +[ ! -d "$dir/soulseek-rs" ] || die "install destination '$dir/soulseek-rs' is a directory" + +staged=$(mktemp "$dir/.soulseek-rs.XXXXXX") || + die "could not create a temporary file in '$dir'" +cp "$tmp/soulseek-rs" "$staged" || die "could not stage the downloaded binary in '$dir'" +chmod 755 "$staged" || die "could not make the downloaded binary executable" +version=$("$staged" --version) || + die "the downloaded binary could not run on this machine; the existing install was not changed" +case "$version" in + soulseek-rs*) ;; + *) die "the downloaded binary returned unexpected version output '$version'; the existing install was not changed" ;; +esac + +mv -f "$staged" "$dir/soulseek-rs" || die "could not replace '$dir/soulseek-rs'" +staged="" + +say "Installed $version ($channel) to $dir/soulseek-rs" +case ":${PATH:-}:" in + *:"$dir":*) ;; + *) say "note: $dir is not on your PATH; add it with: export PATH=\"$dir:\$PATH\"" ;; +esac diff --git a/website/public/logo-ink.svg b/website/public/logo-ink.svg new file mode 100644 index 0000000..620acb1 --- /dev/null +++ b/website/public/logo-ink.svg @@ -0,0 +1,37 @@ + + + \ No newline at end of file diff --git a/website/public/logo-light.svg b/website/public/logo-light.svg new file mode 100644 index 0000000..ee37762 --- /dev/null +++ b/website/public/logo-light.svg @@ -0,0 +1,32 @@ + + + \ No newline at end of file diff --git a/website/public/wordmark-on-dark.svg b/website/public/wordmark-on-dark.svg new file mode 100644 index 0000000..69387f5 --- /dev/null +++ b/website/public/wordmark-on-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/public/wordmark-on-light.svg b/website/public/wordmark-on-light.svg new file mode 100644 index 0000000..70fba31 --- /dev/null +++ b/website/public/wordmark-on-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/scripts/prerender.ts b/website/scripts/prerender.ts new file mode 100644 index 0000000..a45ae65 --- /dev/null +++ b/website/scripts/prerender.ts @@ -0,0 +1,66 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { NOT_FOUND_META, ROUTES, type RouteMeta } from '../src/lib/routes.ts' + +const SSR_ENTRY = '../dist-ssr/entry-server.js' +const { render } = (await import(SSR_ENTRY)) as { + render: (location: string) => string +} + +const DIST = join(import.meta.dirname, '..', 'dist') + +const SITE_URL = 'https://re-invention.nl/soulseek-rs/' + +const escapeAttr = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"').replace(/ + html.replace( + new RegExp(`( { + const canonical = meta.path === '/' ? SITE_URL : `${SITE_URL}${meta.path.slice(1)}/` + let out = html.replace( + /[\s\S]*?<\/title>/, + `<title>${escapeAttr(meta.title)}`, + ) + out = setMeta(out, 'name', 'description', meta.description) + out = setMeta(out, 'property', 'og:title', meta.title) + out = setMeta(out, 'property', 'og:description', meta.description) + out = setMeta(out, 'property', 'og:url', canonical) + return out.replace( + /( => { + const html = applyMeta(template, meta).replace( + '
', + `
${render(location)}
`, + ) + const target = join(DIST, outPath) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, html, 'utf8') + console.log(` ${outPath}`) +} + +const template = await readFile(join(DIST, 'index.html'), 'utf8') + +console.log('prerendering:') +for (const route of ROUTES) { + const outPath = route.path === '/' ? 'index.html' : `${route.path.slice(1)}/index.html` + await writePage(template, route, outPath, route.path) +} + +await writePage(template, NOT_FOUND_META, '404.html', '/__not_found__') + +console.log(`done — ${String(ROUTES.length + 1)} pages`) diff --git a/website/scripts/test-install.sh b/website/scripts/test-install.sh new file mode 100644 index 0000000..4465f20 --- /dev/null +++ b/website/scripts/test-install.sh @@ -0,0 +1,399 @@ +#!/bin/sh +# Hermetic coverage for public/install.sh. Set INSTALL_TEST_NETWORK=stable to +# add a live stable-release install, or =all after a nightly release exists to +# exercise both public channels. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +script="$here/../public/install.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +failures=0 +report() { + if [ "$1" -eq 0 ]; then + printf 'ok %s\n' "$2" + else + printf 'FAIL %s\n' "$2" + failures=$((failures + 1)) + fi +} +contains() { case "$1" in *"$2"*) true ;; *) false ;; esac; } + +if command -v sha256sum >/dev/null 2>&1; then + fixture_sha256() { sha256sum "$1"; } +else + fixture_sha256() { shasum -a 256 "$1"; } +fi + +mkdir -p "$tmp/fixtures/good" "$tmp/fixtures/missing" "$tmp/fixtures/bad-version" \ + "$tmp/fixtures/cannot-run" +printf '#!/bin/sh\nprintf "soulseek-rs 99.0.0-test\\n"\n' >"$tmp/fixtures/good/soulseek-rs" +printf 'no binary here\n' >"$tmp/fixtures/missing/README.md" +printf '#!/bin/sh\nprintf "not-the-client 99.0.0-test\\n"\n' >"$tmp/fixtures/bad-version/soulseek-rs" +printf '#!/bin/sh\nexit 7\n' >"$tmp/fixtures/cannot-run/soulseek-rs" +chmod +x "$tmp/fixtures/good/soulseek-rs" "$tmp/fixtures/bad-version/soulseek-rs" \ + "$tmp/fixtures/cannot-run/soulseek-rs" +tar -czf "$tmp/fixtures/good.tar.gz" -C "$tmp/fixtures/good" soulseek-rs +tar -czf "$tmp/fixtures/missing.tar.gz" -C "$tmp/fixtures/missing" README.md +tar -czf "$tmp/fixtures/bad-version.tar.gz" -C "$tmp/fixtures/bad-version" soulseek-rs +tar -czf "$tmp/fixtures/cannot-run.tar.gz" -C "$tmp/fixtures/cannot-run" soulseek-rs +printf 'this is not a tar archive\n' >"$tmp/fixtures/corrupt.tar.gz" +fixture_sum=$(fixture_sha256 "$tmp/fixtures/good.tar.gz") +fixture_sum=${fixture_sum%% *} +printf '%s fixture.tar.gz\n' "$fixture_sum" >"$tmp/fixtures/good.sha256" +missing_sum=$(fixture_sha256 "$tmp/fixtures/missing.tar.gz") +missing_sum=${missing_sum%% *} +printf '%s fixture.tar.gz\n' "$missing_sum" >"$tmp/fixtures/missing.sha256" +bad_version_sum=$(fixture_sha256 "$tmp/fixtures/bad-version.tar.gz") +bad_version_sum=${bad_version_sum%% *} +printf '%s fixture.tar.gz\n' "$bad_version_sum" >"$tmp/fixtures/bad-version.sha256" +cannot_run_sum=$(fixture_sha256 "$tmp/fixtures/cannot-run.tar.gz") +cannot_run_sum=${cannot_run_sum%% *} +printf '%s fixture.tar.gz\n' "$cannot_run_sum" >"$tmp/fixtures/cannot-run.sha256" +corrupt_sum=$(fixture_sha256 "$tmp/fixtures/corrupt.tar.gz") +corrupt_sum=${corrupt_sum%% *} +printf '%s fixture.tar.gz\n' "$corrupt_sum" >"$tmp/fixtures/corrupt.sha256" + +# Every fetch is local. The same stub accepts curl's and wget's argument forms, +# records the final URL, and can inject each failure mode the installer handles. +mkdir -p "$tmp/stub" +cat >"$tmp/stub/uname" <<'EOF' +#!/bin/sh +[ -z "${UNAME_LOG:-}" ] || printf '%s\n' "${1:-system}" >>"$UNAME_LOG" +case "${1:-}" in + -m) printf '%s\n' "$STUB_ARCH" ;; + *) printf '%s\n' "$STUB_OS" ;; +esac +EOF +cat >"$tmp/stub/fetch" <<'EOF' +#!/bin/sh +url="" +for argument do url=$argument; done +[ -z "${FETCH_LOG:-}" ] || printf '%s\n' "$url" >>"$FETCH_LOG" +case "$url" in + *api.github.com*) + [ "${API_MODE:-ok}" = "ok" ] || exit 22 + printf '{"tag_name": "%s"}\n' "${API_TAG:-v12.0.0}" + ;; + *.sha256) + case "${CHECKSUM_MODE:-good}" in + good) cat "$FIXTURE_DIR/${ARCHIVE_MODE:-good}.sha256" ;; + bad) printf '%064d fixture.tar.gz\n' 0 ;; + empty) : ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; + *.tar.gz) + case "${ARCHIVE_MODE:-good}" in + good) cat "$FIXTURE_DIR/good.tar.gz" ;; + missing) cat "$FIXTURE_DIR/missing.tar.gz" ;; + bad-version) cat "$FIXTURE_DIR/bad-version.tar.gz" ;; + cannot-run) cat "$FIXTURE_DIR/cannot-run.tar.gz" ;; + corrupt) cat "$FIXTURE_DIR/corrupt.tar.gz" ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; + *) exit 22 ;; +esac +EOF +cat >"$tmp/brew-stub" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >"$BREW_LOG" +[ "${BREW_MODE:-ok}" = ok ] || exit 23 +EOF +chmod +x "$tmp/stub/uname" "$tmp/stub/fetch" "$tmp/brew-stub" +ln -s "$tmp/stub/fetch" "$tmp/stub/curl" + +base_path="$tmp/stub:/usr/bin:/bin" +fetch_log="$tmp/fetch.log" +uname_log="$tmp/uname.log" +brew_log="$tmp/brew.log" + +fixture_install() { + destination=$1 + shift + : >"$fetch_log" + STUB_OS="${STUB_OS:-Darwin}" STUB_ARCH="${STUB_ARCH:-arm64}" \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$destination" PATH="$base_path" \ + /bin/sh "$script" "$@" +} + +# Argument parsing happens before platform detection, including when piped via +# `sh -s --`. Help must therefore work even on an unsupported machine. +: >"$uname_log" +out=$(STUB_OS=Plan9 STUB_ARCH=mips UNAME_LOG="$uname_log" PATH="$base_path" \ + /bin/sh "$script" --help 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && contains "$out" "sh -s -- --nightly" && [ ! -s "$uname_log" ]; then res=0; fi +report "$res" "--help works without probing the machine" + +out=$(PATH="$base_path" /bin/sh "$script" --unknown 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unknown option '--unknown'"; then res=0; fi +report "$res" "unknown options fail with guidance" + +out=$(PATH="$base_path" /bin/sh "$script" -- nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected argument 'nightly'"; then res=0; fi +report "$res" "positional arguments are rejected" + +resolved_target() { + : >"$fetch_log" + STUB_OS="$1" STUB_ARCH="$2" ARCHIVE_MODE=fail FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$tmp/detection" \ + PATH="$base_path" /bin/sh "$script" >/dev/null 2>&1 || true + sed -n 's#^.*/\(soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz\)$#\1#p' "$fetch_log" | head -n1 +} + +for spec in \ + "Darwin arm64 aarch64-apple-darwin" \ + "Darwin x86_64 x86_64-apple-darwin" \ + "Linux aarch64 aarch64-unknown-linux-musl" \ + "Linux amd64 x86_64-unknown-linux-musl"; do + # The record deliberately relies on POSIX field splitting. + # shellcheck disable=SC2086 + set -- $spec + res=1 + if [ "$(resolved_target "$1" "$2")" = "soulseek-rs-v12.0.0-$3.tar.gz" ]; then res=0; fi + report "$res" "$1/$2 resolves to $3" +done + +out=$(STUB_OS=MINGW64_NT-10.0 STUB_ARCH=x86_64 PATH="$base_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "pc-windows-msvc" && contains "$out" "releases/tag/nightly"; then res=0; fi +report "$res" "Windows refuses and links to the selected channel" + +out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unsupported architecture 'armv7l'"; then res=0; fi +report "$res" "unsupported architectures fail before downloading" + +out=$(STUB_OS=Plan9 STUB_ARCH=x86_64 PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unsupported OS 'Plan9'"; then res=0; fi +report "$res" "unsupported operating systems fail before downloading" + +# Stable uses Homebrew when available. Nightly must bypass it because the tap +# intentionally tracks stable releases only. +mkdir -p "$tmp/with-brew" +ln -s "$tmp/stub/uname" "$tmp/with-brew/uname" +ln -s "$tmp/brew-stub" "$tmp/with-brew/brew" +ln -s "$tmp/stub/fetch" "$tmp/with-brew/curl" +: >"$fetch_log" +BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" >/dev/null 2>&1 +res=1 +if [ "$(cat "$brew_log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$fetch_log" ]; then res=0; fi +report "$res" "stable uses Homebrew without touching GitHub" + +out=$(BREW_MODE=fail BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 23 ]; then res=0; fi +report "$res" "Homebrew failures propagate to the caller" + +nightly_dir="$tmp/nightly install/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$nightly_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$nightly_dir/soulseek-rs" ] && + contains "$out" "(nightly)" && [ ! -s "$brew_log" ] && + ! grep -q 'api.github.com' "$fetch_log" && + grep -q '/nightly/soulseek-rs-nightly-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "--nightly bypasses Homebrew and the stable-release API" + +custom_stable_dir="$tmp/custom stable/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$custom_stable_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$custom_stable_dir/soulseek-rs" ] && + [ ! -s "$brew_log" ] && grep -q 'api.github.com' "$fetch_log"; then res=0; fi +report "$res" "custom install directory overrides Homebrew" + +stable_dir="$tmp/stable/bin" +out=$(fixture_install "$stable_dir" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$stable_dir/soulseek-rs" ] && + [ "$("$stable_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + contains "$out" "(stable)" && + grep -q '/v12.0.0/soulseek-rs-v12.0.0-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "verified stable archive installs and runs" + +res=1 +if contains "$out" "$stable_dir is not on your PATH"; then res=0; fi +report "$res" "a destination off PATH gets a usable PATH hint" + +# A minimal PATH with wget but no curl proves the fallback instead of merely +# exercising a wget-shaped stub while curl still wins command discovery. +mkdir -p "$tmp/wget-path" +for tool in cat chmod cp gzip head mkdir mktemp mv rm sed shasum sha256sum tar; do + tool_path=$(command -v "$tool" 2>/dev/null || true) + [ -z "$tool_path" ] || ln -s "$tool_path" "$tmp/wget-path/$tool" +done +ln -s "$tmp/stub/uname" "$tmp/wget-path/uname" +ln -s "$tmp/stub/fetch" "$tmp/wget-path/wget" +wget_dir="$tmp/wget/bin" +: >"$fetch_log" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$wget_dir" \ + PATH="$tmp/wget-path" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$wget_dir/soulseek-rs" ]; then res=0; fi +report "$res" "wget-only installation works" + +# Failures before replacement must preserve an existing install and leave no +# fixed-name staging file behind. +preserve_dir="$tmp/preserve/bin" +mkdir -p "$preserve_dir" +printf '#!/bin/sh\nprintf "soulseek-rs old\\n"\n' >"$preserve_dir/soulseek-rs" +chmod +x "$preserve_dir/soulseek-rs" +out=$(CHECKSUM_MODE=bad fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "sha256 mismatch" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum mismatch preserves the installed binary" + +out=$(CHECKSUM_MODE=empty fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "checksum" && contains "$out" "empty"; then res=0; fi +report "$res" "empty checksum is rejected" + +out=$(ARCHIVE_MODE=missing fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "did not contain a soulseek-rs binary" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive without the binary preserves the installed binary" + +out=$(ARCHIVE_MODE=bad-version fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected version output" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "wrong executable preserves the installed binary" + +out=$(ARCHIVE_MODE=cannot-run fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not run on this machine" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "non-running executable preserves the installed binary" + +out=$(ARCHIVE_MODE=corrupt fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not unpack" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "corrupt archive preserves the installed binary" + +out=$(ARCHIVE_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download soulseek-rs-nightly" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive download failure preserves the installed binary" + +out=$(CHECKSUM_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download the sha256 checksum" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum download failure preserves the installed binary" + +directory_target="$tmp/directory-target/bin" +mkdir -p "$directory_target/soulseek-rs" +out=$(fixture_install "$directory_target" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "is a directory" && + [ ! -e "$directory_target/soulseek-rs/.soulseek-rs.new" ]; then res=0; fi +report "$res" "a directory at the binary path is never written into" + +out=$(API_MODE=fail fixture_install "$tmp/api-failure" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not resolve the latest release"; then res=0; fi +report "$res" "stable API failure is explained" + +: >"$fetch_log" +out=$(API_TAG='../nightly' fixture_install "$tmp/unsafe-tag" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected tag" && + [ "$(wc -l <"$fetch_log" | tr -d ' ')" = 1 ]; then res=0; fi +report "$res" "unexpected stable tag is rejected before asset download" + +mkdir -p "$tmp/no-fetch" +ln -s "$tmp/stub/uname" "$tmp/no-fetch/uname" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-fetch" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need curl or wget"; then res=0; fi +report "$res" "missing downloader is reported" + +mkdir -p "$tmp/no-tar" +ln -s "$tmp/stub/uname" "$tmp/no-tar/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-tar/curl" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-tar" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need tar"; then res=0; fi +report "$res" "missing tar is reported before downloading" + +mkdir -p "$tmp/no-sha" +ln -s "$tmp/stub/uname" "$tmp/no-sha/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-sha/curl" +tar_path=$(command -v tar) +ln -s "$tar_path" "$tmp/no-sha/tar" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-sha" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need sha256sum or shasum"; then res=0; fi +report "$res" "missing checksum tool is reported before downloading" + +# Two callers can update the same path without sharing a predictable staging +# name. Either can win; the result must run and no staging file may remain. +concurrent_dir="$tmp/concurrent/bin" +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & first=$! +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & second=$! +first_rc=0 +second_rc=0 +wait "$first" || first_rc=$? +wait "$second" || second_rc=$? +set -- "$concurrent_dir"/.soulseek-rs.* +res=1 +if [ "$first_rc" -eq 0 ] && [ "$second_rc" -eq 0 ] && + [ "$("$concurrent_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + [ ! -e "$1" ]; then res=0; fi +report "$res" "concurrent installers leave one complete binary" + +case "${INSTALL_TEST_NETWORK:-}" in + "") ;; + stable | all) + network_path="/usr/bin:/bin" + live_stable="$tmp/live-stable" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_stable" PATH="$network_path" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_stable/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live stable release installs and runs" + + if [ "${INSTALL_TEST_NETWORK:-}" = all ]; then + live_nightly="$tmp/live-nightly" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_nightly" PATH="$network_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_nightly/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live nightly release installs and runs" + fi + ;; + *) + printf 'FAIL INSTALL_TEST_NETWORK must be empty, stable, or all\n' + failures=$((failures + 1)) + ;; +esac + +[ "$failures" -eq 0 ] || { + printf '%s test(s) failed\n' "$failures" + exit 1 +} +printf 'all tests passed\n' diff --git a/website/src/App.tsx b/website/src/App.tsx new file mode 100644 index 0000000..d8688a7 --- /dev/null +++ b/website/src/App.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from 'react' +import { Route, Routes } from 'react-router' + +import { SiteLayout } from '@/components/layout/site-layout' +import { ROUTES, type RoutePath } from '@/lib/routes' +import { Community } from '@/pages/community' +import { Docs } from '@/pages/docs' +import { Home } from '@/pages/home' +import { Install } from '@/pages/install' +import { NotFound } from '@/pages/not-found' + +const PAGES: Record = { + '/': , + '/install': , + '/docs': , + '/community': , +} + +export const App = () => ( + + }> + {ROUTES.map((route) => + route.path === '/' ? ( + + ) : ( + + ), + )} + } /> + + +) diff --git a/website/src/components/brand/logo.tsx b/website/src/components/brand/logo.tsx new file mode 100644 index 0000000..82fe510 --- /dev/null +++ b/website/src/components/brand/logo.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils' + +interface LogoProps { + size?: number + className?: string +} + +const ASPECT = 186.407 / 211 + +export const Logo = ({ size = 180, className }: LogoProps) => ( +
+) diff --git a/website/src/components/brand/wordmark.tsx b/website/src/components/brand/wordmark.tsx new file mode 100644 index 0000000..51259d6 --- /dev/null +++ b/website/src/components/brand/wordmark.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils' + +interface WordmarkProps { + size?: number + blink?: boolean + className?: string +} + +export const Wordmark = ({ size = 28, blink = false, className }: WordmarkProps) => ( + + soulseek-rs + +) diff --git a/website/src/components/layout/footer.tsx b/website/src/components/layout/footer.tsx new file mode 100644 index 0000000..a82e19f --- /dev/null +++ b/website/src/components/layout/footer.tsx @@ -0,0 +1,104 @@ +import { Link } from "react-router"; + +import { Wordmark } from "@/components/brand/wordmark"; +import { Wrap } from "@/components/ui/layout"; +import { LINKS, VERSION } from "@/lib/links"; + +interface FooterLink { + label: string; + href: string; +} + +interface FooterColumn { + heading: string; + links: readonly FooterLink[]; +} + +const COLUMNS: readonly FooterColumn[] = [ + { + heading: "Project", + links: [ + { label: "GitHub", href: LINKS.gh }, + { label: "Issues", href: LINKS.issues }, + { label: "Contributing", href: LINKS.contributing }, + { label: "Releases", href: LINKS.releases }, + { label: "Changelog", href: LINKS.changelog }, + ], + }, + { + heading: "Packages", + links: [ + { label: "crates.io · client", href: LINKS.cratesClient }, + { label: "crates.io · lib", href: LINKS.cratesLib }, + { label: "docs.rs", href: LINKS.docsrs }, + { label: "rustup", href: LINKS.rustup }, + ], + }, + { + heading: "Read", + links: [ + { label: "Docs", href: "/docs" }, + { label: "Install", href: "/install" }, + { label: "Community", href: "/community" }, + { label: "License · MIT", href: LINKS.license }, + ], + }, +]; + +const LINK_CLASS = + "text-[12.5px] text-secondary hover:text-primary sm:text-[13px]"; + +const FooterAnchor = ({ label, href }: FooterLink) => + href.startsWith("/") ? ( + + {label} + + ) : ( + + {label} + + ); + +export const Footer = () => ( +
+ +

Site footer

+
+
+ +

+ A Soulseek client and protocol library for Rust. +

+

+ Not affiliated with or endorsed by the Soulseek project. +

+
+ + {COLUMNS.map((column) => ( +
+

+ {column.heading} +

+
    + {column.links.map((link) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ +
+ MIT · © 2026 soulseek-rs contributors + {VERSION} +
+
+
+); diff --git a/website/src/components/layout/nav.tsx b/website/src/components/layout/nav.tsx new file mode 100644 index 0000000..69f2ae5 --- /dev/null +++ b/website/src/components/layout/nav.tsx @@ -0,0 +1,65 @@ +import { NavLink } from 'react-router' + +import { Wordmark } from '@/components/brand/wordmark' +import { ThemeToggle } from '@/components/layout/theme-toggle' +import { LINKS } from '@/lib/links' +import { cn } from '@/lib/utils' + +const ITEMS = [ + { label: 'home', to: '/' }, + { label: 'install', to: '/install' }, + { label: 'docs', to: '/docs' }, + { label: 'community', to: '/community' }, +] as const + +export const Nav = () => ( +
+
+ + + + + + + +
+
+) diff --git a/website/src/components/layout/site-layout.tsx b/website/src/components/layout/site-layout.tsx new file mode 100644 index 0000000..5a330e1 --- /dev/null +++ b/website/src/components/layout/site-layout.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { Outlet, useLocation } from 'react-router' + +import { Footer } from '@/components/layout/footer' +import { Nav } from '@/components/layout/nav' +import { usePageMeta } from '@/hooks/use-page-meta' + +const useNavigationEffects = () => { + const { pathname, hash } = useLocation() + + useEffect(() => { + if (hash !== '') { + document.querySelector(hash)?.scrollIntoView() + } else { + window.scrollTo(0, 0) + } + document.getElementById('main')?.focus({ preventScroll: true }) + }, [pathname, hash]) +} + +export const SiteLayout = () => { + useNavigationEffects() + usePageMeta() + + return ( + <> + + Skip to content + +