diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1213deba..3e218169 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,77 +1,23 @@ -# Dependabot version-update configuration. -# -# Security updates are enabled separately at the repo level and run regardless -# of this file. This config adds scheduled *version* updates to keep manifests -# and lockfiles fresh proactively. Updates are grouped to minimise PR noise. version: 2 updates: - # ---- Rust: main workspace (covers all crates/* and desktop/src-tauri) ---- - package-ecosystem: cargo directory: "/" schedule: - interval: weekly - open-pull-requests-limit: 10 + interval: monthly groups: - cargo-minor-patch: - update-types: - - minor - - patch - - # ---- Rust: benchv2 (excluded from the workspace) ---- - - package-ecosystem: cargo - directory: "/benchv2" - schedule: - interval: weekly - open-pull-requests-limit: 5 - groups: - benchv2-minor-patch: - update-types: - - minor - - patch - - # ---- Rust: fuzz (excluded from the workspace) ---- - - package-ecosystem: cargo - directory: "/fuzz" - schedule: - interval: weekly - open-pull-requests-limit: 5 - groups: - fuzz-minor-patch: - update-types: - - minor - - patch - - # ---- npm: web UI (crates/librtbit/webui) ---- - - package-ecosystem: npm - directory: "/crates/librtbit/webui" - schedule: - interval: weekly - open-pull-requests-limit: 10 - groups: - webui-minor-patch: - update-types: - - minor - - patch - - # ---- npm: root workspace (includes the desktop package) ---- + cargo: + patterns: ["*"] - package-ecosystem: npm directory: "/" schedule: - interval: weekly - open-pull-requests-limit: 5 + interval: monthly groups: - root-npm-minor-patch: - update-types: - - minor - - patch - - # ---- GitHub Actions workflows ---- + npm: + patterns: ["*"] - package-ecosystem: github-actions directory: "/" schedule: - interval: weekly - open-pull-requests-limit: 5 + interval: monthly groups: - actions-all: - patterns: - - "*" + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0681728..9a9b4b9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: rust: ${{ steps.classify.outputs.rust }} rust_full: ${{ steps.classify.outputs.rust_full }} rust_packages: ${{ steps.classify.outputs.rust_packages }} + rust_fluent: ${{ steps.classify.outputs.rust_fluent }} desktop: ${{ steps.classify.outputs.desktop }} webui: ${{ steps.classify.outputs.webui }} website: ${{ steps.classify.outputs.website }} @@ -46,11 +47,11 @@ jobs: run: | set -euo pipefail if [[ "$EVENT" != pull_request ]]; then - printf 'rust=true\nrust_full=true\nrust_packages=\ndesktop=true\nwebui=true\nwebsite=true\n' >>"$GITHUB_OUTPUT" + printf 'rust=true\nrust_full=true\nrust_packages=\nrust_fluent=true\ndesktop=true\nwebui=true\nwebsite=true\n' >>"$GITHUB_OUTPUT" exit 0 fi files="$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)" - rust=false full=false desktop=false webui=false website=false + rust=false full=false fluent=false desktop=false webui=false website=false packages=() add_package() { local pkg @@ -80,8 +81,15 @@ jobs: # The menu description has no window toolkit, so CI can test it. # The Tauri binary stays excluded: the runner has no webkit. desktop/menu/*) rust=true desktop=true; add_package rtbit-desktop-menu ;; + # The native app consumes the daemon's API types directly. + crates/librtbit/src/http_api_types.rs) + rust=true; fluent=true ;; crates/librtbit/*) rust=true desktop=true; add_package swarmforge ;; crates/rtbit/*) rust=true; add_package rtbit ;; + # The native client and UI crates are reverse dependencies of the + # fluent app, so changes to either must test the app as well. + crates/rtbit-client/*|crates/rtbit-ui-core/*) + rust=true; fluent=true ;; website/*) website=true ;; # An unrecognised crate path (a new member, or a path we have not # mapped yet) is not safe to narrow, so it takes everything. @@ -90,11 +98,11 @@ jobs: done <<<"$files" # A Rust change we could not attribute to a package still needs the # full workspace rather than no check at all. - if [[ "$rust" == true && "$full" == false && ${#packages[@]} -eq 0 ]]; then + if [[ "$rust" == true && "$full" == false && "$fluent" == false && ${#packages[@]} -eq 0 ]]; then full=true fi - printf 'rust=%s\nrust_full=%s\nrust_packages=%s\ndesktop=%s\nwebui=%s\nwebsite=%s\n' \ - "$rust" "$full" "${packages[*]-}" "$desktop" "$webui" "$website" | tee -a "$GITHUB_OUTPUT" + printf 'rust=%s\nrust_full=%s\nrust_packages=%s\nrust_fluent=%s\ndesktop=%s\nwebui=%s\nwebsite=%s\n' \ + "$rust" "$full" "${packages[*]-}" "$fluent" "$desktop" "$webui" "$website" | tee -a "$GITHUB_OUTPUT" # Shell checks for the release scripts and repository layout. They take # seconds and touch no network, so they run on every event. @@ -154,7 +162,7 @@ jobs: with: shared-key: ci save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2.87.32 with: tool: cargo-nextest - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable @@ -181,10 +189,14 @@ jobs: - name: Test env: SCOPE_ARGS: ${{ steps.scope.outputs.args }} + RUST_FLUENT: ${{ needs.changes.outputs.rust_fluent }} CARGO_PROFILE_TEST_DEBUG: 0 run: | read -r -a args <<<"$SCOPE_ARGS" cargo nextest run "${args[@]}" --all-targets --locked --no-default-features --features default-tls + if [[ "$RUST_FLUENT" == true ]]; then + cargo nextest run -p rtbit-fluent --all-targets --locked + fi # The shipped binary is worth a release-profile compile on a PR that # touches it. The queue builds it for every entry anyway. - name: Build the release binary diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5ac53c73..ac8177ab 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -83,10 +83,10 @@ jobs: esac fi printf 'ref=%s\n' "$ref" >> "$GITHUB_OUTPUT" - - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: /language:${{ matrix.language }} ref: ${{ steps.target.outputs.ref }} diff --git a/.github/workflows/compat-nightly.yml b/.github/workflows/compat-nightly.yml index c50b7c44..807cd6f4 100644 --- a/.github/workflows/compat-nightly.yml +++ b/.github/workflows/compat-nightly.yml @@ -39,7 +39,7 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -72,7 +72,7 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 3679a366..ce18ca7f 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -5,6 +5,8 @@ on: branches: [main, dev] tags: ['v*'] workflow_dispatch: + merge_group: + types: [checks_requested] permissions: contents: read @@ -16,6 +18,7 @@ concurrency: jobs: publish: + if: github.event_name != 'merge_group' # Privileged publication capacity is selected only for this repository and # this workflow deliberately has no pull_request trigger. runs-on: [self-hosted, linux, docker, publish] @@ -25,7 +28,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -80,3 +83,61 @@ jobs: -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "https://ghcr.io/v2/thedancingdeveloper-org/rusttorrent/manifests/$tag" > manifest.json jq -e '.schemaVersion == 2' manifest.json >/dev/null + + smoke: + if: github.event_name == 'merge_group' + runs-on: [self-hosted, linux, docker, publish] + permissions: + contents: read + env: + IMAGE: rusttorrent-container-smoke:${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Build the daemon image + run: docker build --tag "$IMAGE" --file Dockerfile . + - name: Start and probe the daemon + shell: bash + run: | + set -euo pipefail + volume="rusttorrent-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + container="rusttorrent-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + docker rm --force "$container" >/dev/null 2>&1 || true + docker volume rm "$volume" >/dev/null 2>&1 || true + docker image rm "$IMAGE" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker volume create "$volume" >/dev/null + docker run --detach --name "$container" --volume "$volume:/home/rtbit/db" \ + --publish 3030:3030 "$IMAGE" >/dev/null + + deadline=$((SECONDS + 30)) + setup_token='' + while (( SECONDS < deadline )); do + if ! setup_token=$(docker exec "$container" cat /home/rtbit/db/session/setup-token 2>/dev/null); then + setup_token='' + fi + if [[ -n "$setup_token" ]] && curl --fail --silent --show-error \ + --output /dev/null http://127.0.0.1:3030/healthz; then + break + fi + sleep 1 + done + [[ -n "$setup_token" ]] + (( SECONDS < deadline )) + + curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data "{\"username\":\"smoke\",\"password\":\"smoke-password\",\"setup_token\":\"$setup_token\"}" \ + http://127.0.0.1:3030/api/v2/auth/setup >/dev/null + version=$(curl --fail --silent --show-error \ + --user smoke:smoke-password http://127.0.0.1:3030/api/v2/app/webapiVersion) + [[ "$version" == '2.11.3' ]] + curl --fail --silent --show-error \ + http://127.0.0.1:3030/healthz >/dev/null + + docker stop --time 30 "$container" >/dev/null + [[ "$(docker inspect --format '{{.State.ExitCode}}' "$container")" == 0 ]] + docker run --rm --volume "$volume:/data" --entrypoint sh "$IMAGE" \ + -c 'test -s /data/session/session.json' diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index fb23b452..5b5638ef 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -32,3 +32,22 @@ jobs: - name: Verify migration policies shell: bash run: bash ci/migration-policy.sh + - name: Detect dependency policy changes + id: dependency-policy + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if git diff --quiet "$BASE_SHA" HEAD -- Cargo.lock ':(glob)**/Cargo.toml' deny.toml; then + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + if: github.event_name != 'pull_request' || steps.dependency-policy.outputs.run == 'true' + with: + tool: cargo-deny@0.20.2 + - name: Verify Rust dependency policy + if: github.event_name != 'pull_request' || steps.dependency-policy.outputs.run == 'true' + run: cargo deny check advisories licenses bans sources diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 23da7cf9..1e12d01b 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -82,6 +82,15 @@ jobs: cargo build -p rtbit-fluent --release --target x86_64-pc-windows-msvc rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh windows "$tag" target/desktop-stage + - name: Smoke-test Windows fluent artifact + shell: bash + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/rtbit/Cargo.toml | head -1)" + tag="v$version" + expected="rtbit-fluent $version" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-windows-x86_64.exe --version)" + test "$actual" = "$expected" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-dry-run-windows-fluent-${{ github.sha }} @@ -157,6 +166,14 @@ jobs: rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh linux "$tag" target/desktop-stage bash packaging/scripts/build-desktop.sh sbom "$tag" target/desktop-stage + - name: Smoke-test Linux fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ steps.tag.outputs.tag }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-linux-x86_64 --version)" + test "$actual" = "$expected" - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: @@ -278,3 +295,48 @@ jobs: done test "$count" -eq 2 || { echo "::error::expected 2 signed executables (daemon and fluent), verified $count"; exit 1; } (cd release-artifacts && sha256sum -c "SHA256SUMS-$tag.txt") + + report-failure: + name: Report release dry-run failure + if: >- + ${{ always() && github.event_name != 'pull_request' && + (needs.build-windows-fluent.result == 'failure' || needs.dry-run.result == 'failure') }} + needs: [build-windows-fluent, dry-run] + runs-on: [self-hosted, linux] + permissions: + contents: read + issues: write + steps: + - name: Open or update the tracking issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const title = '[CI] Release dry run is failing'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const report = [ + `The ${context.eventName} release dry run failed.`, + '', + `Run: ${runUrl}`, + `Commit: ${context.sha}`, + '', + 'This workflow does not publish release artifacts.', + ].join('\n'); + const {data: issues} = await github.rest.issues.listForRepo({ + ...context.repo, + state: 'open', + per_page: 100, + }); + const existing = issues.find(issue => !issue.pull_request && issue.title === title); + if (existing) { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: existing.number, + body: report, + }); + } else { + await github.rest.issues.create({ + ...context.repo, + title, + body: report, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fa3b94f..00d4db8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,14 @@ jobs: rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh linux "$tag" target/desktop-stage bash packaging/scripts/build-desktop.sh sbom "$tag" target/desktop-stage + - name: Smoke-test Linux fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-linux-x86_64 --version)" + test "$actual" = "$expected" - name: Stage and checksum artifacts shell: bash run: | @@ -178,6 +186,14 @@ jobs: cargo build -p rtbit-fluent --release --target x86_64-pc-windows-msvc rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh windows "$tag" target/desktop-stage + - name: Smoke-test Windows fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-windows-x86_64.exe --version)" + test "$actual" = "$expected" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-artifacts-windows-fluent diff --git a/Cargo.lock b/Cargo.lock index 0a358cec..dc4e289f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ dependencies = [ "atspi-common", "phf", "serde", - "zvariant 5.15.1", + "zvariant", ] [[package]] @@ -51,7 +51,7 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus 5.19.0", + "zbus", ] [[package]] @@ -90,11 +90,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", "zeroize", ] +[[package]] +name = "aes" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "ahash" version = "0.8.12" @@ -241,9 +252,6 @@ name = "arbitrary" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" -dependencies = [ - "derive_arbitrary", -] [[package]] name = "arc-swap" @@ -328,7 +336,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus 5.19.0", + "zbus", ] [[package]] @@ -403,9 +411,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.51" +version = "0.4.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459" dependencies = [ "compression-codecs", "compression-core", @@ -415,9 +423,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" dependencies = [ "async-task", "concurrent-queue", @@ -458,9 +466,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" dependencies = [ "event-listener", "event-listener-strategy", @@ -636,11 +644,11 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus 5.19.0", + "zbus", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names 4.3.5", - "zvariant 5.15.1", + "zbus_names", + "zvariant", ] [[package]] @@ -651,7 +659,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus 5.19.0", + "zbus", ] [[package]] @@ -719,9 +727,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.2" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -730,9 +738,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -1012,6 +1020,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1060,9 +1077,9 @@ dependencies = [ [[package]] name = "brotli" -version = "9.0.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1071,9 +1088,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "6.0.1" +version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1262,14 +1279,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] name = "cc" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", "jobserver", @@ -1386,10 +1412,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.5", +] + [[package]] name = "clap" version = "4.6.7" @@ -1517,9 +1553,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.46" +version = "0.4.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43" dependencies = [ "compression-core", "flate2", @@ -1768,6 +1804,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1803,9 +1845,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -1945,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1956,9 +1998,9 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", ] @@ -2005,9 +2047,9 @@ checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", "hashbrown 0.14.5", - "lock_api", + "lock_api 0.4.14", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] @@ -2019,9 +2061,9 @@ dependencies = [ "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", - "lock_api", + "lock_api 0.4.14", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", "serde", ] @@ -2106,17 +2148,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2402,9 +2433,9 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -2480,9 +2511,9 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased-serde" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" dependencies = [ "serde", "serde_core", @@ -2879,15 +2910,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "funty" version = "2.0.0" @@ -2956,8 +2978,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", - "lock_api", - "parking_lot", + "lock_api 0.4.14", + "parking_lot 0.12.5", ] [[package]] @@ -3392,7 +3414,7 @@ dependencies = [ "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "quanta", "rand 0.9.5", @@ -3451,7 +3473,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-video", "parking", - "parking_lot", + "parking_lot 0.12.5", "pin-project", "pollster 0.4.0", "postage", @@ -3510,7 +3532,7 @@ dependencies = [ "notify-rust", "oo7", "open", - "parking_lot", + "parking_lot 0.12.5", "raw-window-handle", "smallvec", "smol", @@ -3586,7 +3608,7 @@ dependencies = [ "gpui_util", "itertools 0.14.0", "log", - "parking_lot", + "parking_lot 0.12.5", "profiling", "raw-window-handle", "smallvec", @@ -3615,7 +3637,7 @@ dependencies = [ "image", "itertools 0.14.0", "log", - "parking_lot", + "parking_lot 0.12.5", "rand 0.9.5", "raw-window-handle", "scheduler", @@ -3931,7 +3953,7 @@ dependencies = [ "http", "http-body", "log", - "parking_lot", + "parking_lot 0.12.5", "serde", "serde_json", "serde_urlencoded", @@ -4083,19 +4105,19 @@ dependencies = [ [[package]] name = "ico" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" dependencies = [ "byteorder", - "png 0.17.16", + "png 0.18.1", ] [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -4107,9 +4129,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -4121,9 +4143,9 @@ dependencies = [ [[package]] name = "icu_locale_fallback" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" dependencies = [ "icu_locale_core", "icu_locale_fallback_data", @@ -4135,15 +4157,15 @@ dependencies = [ [[package]] name = "icu_locale_fallback_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4155,15 +4177,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -4176,15 +4198,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -4199,9 +4221,9 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" dependencies = [ "icu_collections", "icu_locale_fallback", @@ -4210,14 +4232,15 @@ dependencies = [ "potential_utf", "smallvec", "utf8_iter", + "zerotrie", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" [[package]] name = "ident_case" @@ -4349,10 +4372,20 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array 0.14.7", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -4471,9 +4504,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.38" +version = "0.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" dependencies = [ "defmt", "jiff-core", @@ -4488,9 +4521,9 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" dependencies = [ "defmt", "log", @@ -4498,9 +4531,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.38" +version = "0.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" dependencies = [ "jiff-core", "proc-macro2", @@ -4609,9 +4642,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -4711,7 +4744,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a396bb213c2d09ed6c5495fd082c991b6ab39c9daf4fff59e6727f85c73e4c5" dependencies = [ - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "tokio", ] @@ -4846,7 +4879,7 @@ dependencies = [ "libc", "librqbit-dualstack-sockets", "metrics", - "parking_lot", + "parking_lot 0.12.5", "rand 0.9.5", "ringbuf", "rustc-hash 2.1.3", @@ -4901,11 +4934,20 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lock_api" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" dependencies = [ "serde_core", ] @@ -5115,9 +5157,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -5382,19 +5424,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nix" version = "0.31.3" @@ -5439,20 +5468,21 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "notify" -version = "8.2.0" +version = "8.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" dependencies = [ "bitflags 2.13.2", - "fsevent-sys", "inotify", "kqueue", "libc", "log", "mio", "notify-types", + "objc2-core-foundation", + "objc2-core-services", "walkdir", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5466,14 +5496,14 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.19.0", + "zbus", ] [[package]] name = "notify-types" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108" dependencies = [ "bitflags 2.13.2", ] @@ -5759,6 +5789,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -5953,14 +5993,14 @@ name = "oo7" version = "0.6.0" source = "git+https://github.com/zed-industries/oo7?rev=731c1215edb6905876c6137ab300428c7b8d7c13#731c1215edb6905876c6137ab300428c7b8d7c13" dependencies = [ - "aes", + "aes 0.8.4", "ashpd", "async-fs", "async-io", "async-lock", "blocking", - "cbc", - "cipher", + "cbc 0.1.2", + "cipher 0.4.4", "digest 0.10.7", "endi", "futures-lite", @@ -5976,10 +6016,10 @@ dependencies = [ "serde_bytes", "sha2 0.10.9", "subtle", - "zbus 5.19.0", - "zbus_macros 5.19.0", + "zbus", + "zbus_macros", "zeroize", - "zvariant 5.15.1", + "zvariant", ] [[package]] @@ -5990,9 +6030,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "dunce", "is-wsl", @@ -6001,9 +6041,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.81" +version = "0.10.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" dependencies = [ "bitflags 2.13.2", "cfg-if", @@ -6026,9 +6066,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" [[package]] name = "openssl-src" @@ -6041,9 +6081,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.117" +version = "0.9.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" dependencies = [ "cc", "libc", @@ -6095,7 +6135,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix 0.31.3", + "nix", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -6173,8 +6213,18 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" +dependencies = [ + "lock_api 0.5.0", + "parking_lot_core 0.10.0", ] [[package]] @@ -6185,7 +6235,19 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parking_lot_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" +dependencies = [ + "libc", + "redox_syscall 0.9.5", "smallvec", "windows-link 0.2.1", ] @@ -6403,9 +6465,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", @@ -6532,7 +6594,7 @@ checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ "atomic", "crossbeam-queue", - "parking_lot", + "parking_lot 0.12.5", "pin-project", "pollster 0.2.5", "static_assertions", @@ -7150,6 +7212,15 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "redox_users" version = "0.5.3" @@ -7286,9 +7357,9 @@ dependencies = [ [[package]] name = "rfd" -version = "0.16.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ "block2", "dispatch2", @@ -7296,16 +7367,18 @@ dependencies = [ "gobject-sys", "gtk-sys", "js-sys", + "libc", "log", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "percent-encoding", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7392,7 +7465,7 @@ dependencies = [ "librqbit-dualstack-sockets", "metrics-exporter-prometheus", "openssl", - "parking_lot", + "parking_lot 0.13.0", "regex", "serde", "serde_derive", @@ -7434,7 +7507,7 @@ dependencies = [ "librqbit-dualstack-sockets", "metrics-exporter-prometheus", "open", - "parking_lot", + "parking_lot 0.13.0", "reqwest", "rtbit-desktop-menu", "serde", @@ -7502,13 +7575,12 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.21.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ "cfg-if", "ordered-multimap", - "trim-in-place", ] [[package]] @@ -7540,9 +7612,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -7697,7 +7769,7 @@ dependencies = [ "chrono", "flume", "futures", - "parking_lot", + "parking_lot 0.12.5", "rand 0.9.5", "web-time", ] @@ -7787,21 +7859,21 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "secret-service" -version = "4.0.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes", - "cbc", + "aes 0.9.4", + "cbc 0.2.1", "futures-util", - "generic-array 0.14.7", - "hkdf 0.12.4", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", "num 0.4.3", "once_cell", - "rand 0.8.8", "serde", - "sha2 0.10.9", - "zbus 4.4.0", + "sha2 0.11.1", + "zbus", ] [[package]] @@ -8080,17 +8152,6 @@ dependencies = [ "stable_deref_trait", ] -[[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 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -8157,9 +8218,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +checksum = "49f7447afd772ca79ad17ae0a3972a6b0df0cb4b80990fd82707593e9e012a74" dependencies = [ "libc", "signal-hook-registry", @@ -8233,9 +8294,9 @@ dependencies = [ [[package]] name = "sketches-ddsketch" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce" [[package]] name = "skrifa" @@ -8274,9 +8335,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -8344,7 +8405,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -8383,7 +8444,7 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "lock_api", + "lock_api 0.4.14", ] [[package]] @@ -8392,16 +8453,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" dependencies = [ - "lock_api", + "lock_api 0.4.14", ] [[package]] name = "spinning_top" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +checksum = "9cf02766a1eb909bc7653557c66b384e44edc3ad979e6257c6173c65ea51bc7d" dependencies = [ - "lock_api", + "lock_api 0.4.14", ] [[package]] @@ -8527,7 +8588,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1 0.11.0", + "sha1", "sha2 0.11.1", "sqlx-core", "thiserror 2.0.21", @@ -8627,7 +8688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", - "parking_lot", + "parking_lot 0.12.5", "phf_shared", "precomputed-hash", ] @@ -8752,9 +8813,10 @@ dependencies = [ "memmap2", "metrics-exporter-prometheus", "mime_guess", - "nix 0.31.3", + "network-interface", + "nix", "notify", - "parking_lot", + "parking_lot 0.13.0", "pollster 1.0.1", "rand 0.10.3", "regex", @@ -8848,7 +8910,7 @@ dependencies = [ "hex 0.4.3", "itertools 0.15.0", "memchr", - "parking_lot", + "parking_lot 0.13.0", "rand 0.10.3", "serde", "serde_derive", @@ -8878,7 +8940,7 @@ dependencies = [ "indexmap 2.14.2", "leaky-bucket", "librqbit-dualstack-sockets", - "parking_lot", + "parking_lot 0.13.0", "rand 0.10.3", "serde", "serde_derive", @@ -8906,7 +8968,7 @@ dependencies = [ "futures", "httparse", "librqbit-dualstack-sockets", - "parking_lot", + "parking_lot 0.13.0", "rand 0.10.3", "swarmforge-core", "swarmforge-sha1-wrapper", @@ -8957,7 +9019,7 @@ dependencies = [ "futures", "itertools 0.15.0", "librqbit-dualstack-sockets", - "parking_lot", + "parking_lot 0.13.0", "rand 0.10.3", "reqwest", "serde", @@ -9008,7 +9070,7 @@ dependencies = [ "librqbit-dualstack-sockets", "mime_guess", "network-interface", - "parking_lot", + "parking_lot 0.13.0", "quick-xml 0.42.0", "rand 0.10.3", "reqwest", @@ -9175,9 +9237,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.37.1" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" dependencies = [ "bitflags 2.13.2", "block2", @@ -9201,7 +9263,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "parking_lot", + "parking_lot 0.12.5", "percent-encoding", "raw-window-handle", "tao-macros", @@ -9249,9 +9311,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.12.4" +version = "2.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" +checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" dependencies = [ "anyhow", "bytes", @@ -9301,9 +9363,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" +checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" dependencies = [ "anyhow", "cargo_toml", @@ -9324,9 +9386,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" +checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" dependencies = [ "base64 0.23.1", "brotli", @@ -9351,9 +9413,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" +checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -9395,9 +9457,9 @@ dependencies = [ [[package]] name = "tauri-plugin-deep-link" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4" +checksum = "ab551d9bec27bb79d1c7fe5ed38c5cfd1cebb86efa745acc65f5853f345f6e56" dependencies = [ "dunce", "plist", @@ -9416,9 +9478,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" +checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5" dependencies = [ "log", "raw-window-handle", @@ -9458,13 +9520,13 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b" +checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf" dependencies = [ "log", "notify-rust", - "rand 0.9.5", + "rand 0.10.3", "serde", "serde_json", "serde_repr", @@ -9473,14 +9535,14 @@ dependencies = [ "thiserror 2.0.21", "time", "url", - "zbus 5.19.0", + "zbus", ] [[package]] name = "tauri-plugin-shell" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" +checksum = "7375b25f72f70f6168f27f1ba3a413e30eaa0a2098cff53a4046ebfe9e2f142d" dependencies = [ "encoding_rs", "libc", @@ -9501,9 +9563,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" dependencies = [ "serde", "serde_json", @@ -9512,16 +9574,16 @@ dependencies = [ "tokio", "tracing", "windows-sys 0.61.2", - "zbus 5.19.0", + "zbus", ] [[package]] name = "tauri-plugin-updater" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "dirs 7.0.0", "flate2", "futures-util", @@ -9546,7 +9608,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.61.2", - "zip 4.6.1", + "zip", ] [[package]] @@ -9576,9 +9638,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.12.2" +version = "2.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" +checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" dependencies = [ "gtk", "http", @@ -9586,7 +9648,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "once_cell", "percent-encoding", "raw-window-handle", "softbuffer", @@ -9821,9 +9882,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "serde_core", @@ -9855,7 +9916,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2", @@ -10042,9 +10103,9 @@ checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tonic" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b" dependencies = [ "async-trait", "axum", @@ -10063,6 +10124,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-stream", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -10071,9 +10133,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "eba464054e2226f182a34489b8b079bf4c6e3e7ab5fd7fc30e024f24aa92a484" dependencies = [ "bytes", "prost", @@ -10256,12 +10318,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "trim-in-place" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa60874b45b168f67f3f9ba1ee821ad77144d6fa19cef1cd1874a7450a948da" - [[package]] name = "try-lock" version = "0.2.5" @@ -10365,15 +10421,15 @@ dependencies = [ [[package]] name = "unicode-properties" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" [[package]] name = "unicode-script" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" +checksum = "aa04c955db05429d464c74d495c1ee677e1866e783a4c3747f9c0cef7756cabf" [[package]] name = "unicode-segmentation" @@ -10471,9 +10527,12 @@ dependencies = [ [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] [[package]] name = "utf8parse" @@ -10481,6 +10540,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "utoipa" version = "6.0.0" @@ -10531,14 +10596,14 @@ dependencies = [ "serde_json", "url", "utoipa", - "zip 9.1.3", + "zip", ] [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10659,12 +10724,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -10672,9 +10736,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.79" +version = "0.4.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" dependencies = [ "js-sys", "tokio", @@ -10683,9 +10747,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10693,9 +10757,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", @@ -10706,9 +10770,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] @@ -10827,9 +10891,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -10903,9 +10967,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -11120,9 +11184,9 @@ version = "30.0.0" source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" dependencies = [ "cfg-if", - "lock_api", + "lock_api 0.4.14", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "portable-atomic-util", ] @@ -11255,7 +11319,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ - "parking_lot", + "parking_lot 0.12.5", "rayon", "thiserror 2.0.21", "windows 0.61.3", @@ -11490,15 +11554,6 @@ 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" @@ -11861,11 +11916,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.57.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "block2", "cookie", "crossbeam-channel", @@ -11886,7 +11941,6 @@ dependencies = [ "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", - "once_cell", "percent-encoding", "raw-window-handle", "sha2 0.10.9", @@ -11955,9 +12009,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xattr" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" dependencies = [ "libc", "rustix", @@ -11981,16 +12035,6 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" -[[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 = "xim-ctext" version = "0.3.0" @@ -12045,9 +12089,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yazi" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +checksum = "ac366ea532fa8ee6a992cec9dea525b1dc284e69702f36c2ab5fbebadc81ec86" [[package]] name = "yeslogic-fontconfig-sys" @@ -12062,9 +12106,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -12083,44 +12127,6 @@ dependencies = [ "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 0.4.3", - "nix 0.29.0", - "ordered-stream", - "rand 0.8.8", - "serde", - "serde_repr", - "sha1 0.10.7", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.19.0" @@ -12151,9 +12157,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.4", - "zbus_macros 5.19.0", - "zbus_names 4.3.5", - "zvariant 5.15.1", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -12163,7 +12169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant 5.15.1", + "zvariant", ] [[package]] @@ -12177,20 +12183,7 @@ dependencies = [ "syn 2.0.119", "zbus-lockstep", "zbus_xml", - "zvariant 5.15.1", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zvariant", ] [[package]] @@ -12203,20 +12196,9 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "zbus_names 4.3.5", - "zvariant 5.15.1", - "zvariant_utils 4.2.0", -] - -[[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 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -12227,7 +12209,7 @@ checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow 1.0.4", - "zvariant 5.15.1", + "zvariant", ] [[package]] @@ -12238,15 +12220,15 @@ checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ "serde", "winnow 1.0.4", - "zbus_names 4.3.5", - "zvariant 5.15.1", + "zbus_names", + "zvariant", ] [[package]] name = "zcheapstr" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" dependencies = [ "serde", ] @@ -12384,9 +12366,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "serde", "yoke", @@ -12396,9 +12378,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -12407,28 +12389,17 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" +version = "9.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.2", - "memchr", -] - -[[package]] -name = "zip" -version = "9.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" +checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487" dependencies = [ "crc32fast", "flate2", "getrandom 0.4.3", "indexmap 2.14.2", + "libc", "memchr", - "nix 0.31.3", + "nix", "typed-path", "windows-sys 0.61.2", "zopfli", @@ -12496,19 +12467,6 @@ dependencies = [ "zune-core", ] -[[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 4.2.0", -] - [[package]] name = "zvariant" version = "5.15.1" @@ -12521,21 +12479,8 @@ dependencies = [ "serde_bytes", "winnow 1.0.4", "zcheapstr", - "zvariant_derive 5.15.1", - "zvariant_utils 4.2.0", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -12548,18 +12493,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "zvariant_utils 4.2.0", -] - -[[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", + "zvariant_utils", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a60f5c8c..fe411769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,7 @@ network-interface = "2" nix = "0.31" notify = "8" openssl = "0.10" -parking_lot = "0.12" +parking_lot = "0.13" pollster = "1.0" quick-xml = "0.42" rand = "0.10" diff --git a/benchv2/Cargo.lock b/benchv2/Cargo.lock index f858fda3..f90f32a9 100644 --- a/benchv2/Cargo.lock +++ b/benchv2/Cargo.lock @@ -885,9 +885,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -1529,9 +1529,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", diff --git a/crates/librtbit-core/Cargo.toml b/crates/librtbit-core/Cargo.toml index 3057f782..5901de9a 100644 --- a/crates/librtbit-core/Cargo.toml +++ b/crates/librtbit-core/Cargo.toml @@ -24,7 +24,7 @@ hex = "0.4" anyhow = "1" url = { version = "2", default-features = false } rand = "0.10" -parking_lot = "0.12" +parking_lot = "0.13" serde = "1" serde_derive = "1" buffers.workspace = true diff --git a/crates/librtbit-dht/Cargo.toml b/crates/librtbit-dht/Cargo.toml index ecb89d10..abedc011 100644 --- a/crates/librtbit-dht/Cargo.toml +++ b/crates/librtbit-dht/Cargo.toml @@ -30,7 +30,7 @@ clone_to_owned.workspace = true librtbit-core.workspace = true sha1w.workspace = true anyhow = "1" -parking_lot = "0.12" +parking_lot = "0.13" tracing = "0.1" backon = { version = "1.6", features = ["tokio-sleep"] } futures = "0.3" diff --git a/crates/librtbit-dht/src/dht.rs b/crates/librtbit-dht/src/dht.rs index 17f1c90c..9bf886b2 100644 --- a/crates/librtbit-dht/src/dht.rs +++ b/crates/librtbit-dht/src/dht.rs @@ -1114,38 +1114,32 @@ impl DhtWorker { v4.or(v6) } - async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> crate::Result<()> { + async fn bootstrap(&self, bootstrap_addrs: &[String]) -> crate::Result<()> { let backoff = ExponentialBuilder::new() - .with_max_delay(Duration::from_secs(60)) + .with_max_delay(Duration::from_secs(60 * 60)) .with_jitter() - .with_total_delay(Some(Duration::from_secs(86400))) .without_max_times(); - (|| self.bootstrap_hostname(addr)) - .retry(backoff) - .notify(|error, retry_in| { - warn!(?retry_in, ?addr, "error in bootstrap: {error:#}"); - }) - .instrument(debug_span!("bootstrap", hostname = addr)) - .await - } - - async fn bootstrap(&self, bootstrap_addrs: &[String]) -> crate::Result<()> { - let mut futs = FuturesUnordered::new(); - - for addr in bootstrap_addrs.iter() { - futs.push(self.bootstrap_hostname_with_backoff(addr)); - } - let mut successes = 0; - while let Some(resp) = futs.next().await { - if resp.is_ok() { - successes += 1 + (|| async { + let mut futs = FuturesUnordered::new(); + for addr in bootstrap_addrs { + futs.push( + self.bootstrap_hostname(addr) + .instrument(debug_span!("bootstrap", hostname = addr.as_str())), + ); } - } - if successes == 0 { - return Err(Error::BootstrapFailed); - } - Ok(()) + while let Some(resp) = futs.next().await { + if resp.is_ok() { + return Ok(()); + } + } + Err(Error::BootstrapFailed) + }) + .retry(backoff) + .notify(|error, retry_in| { + warn!(?retry_in, "all DHT bootstrap nodes failed: {error:#}"); + }) + .await } async fn bucket_refresher(&self, is_v4: bool) -> crate::Result<()> { diff --git a/crates/librtbit-lsd/Cargo.toml b/crates/librtbit-lsd/Cargo.toml index 972739ea..9a0c50e9 100644 --- a/crates/librtbit-lsd/Cargo.toml +++ b/crates/librtbit-lsd/Cargo.toml @@ -27,7 +27,7 @@ librtbit-core.workspace = true rand = "0.10" futures = "0.3" bstr = "1" -parking_lot = "0.12" +parking_lot = "0.13" httparse = "1" atoi = "3" tracing = "0.1" diff --git a/crates/librtbit-tracker-comms/Cargo.toml b/crates/librtbit-tracker-comms/Cargo.toml index c897dffe..f0508bc0 100644 --- a/crates/librtbit-tracker-comms/Cargo.toml +++ b/crates/librtbit-tracker-comms/Cargo.toml @@ -28,7 +28,7 @@ rand = "0.10" tracing = "0.1" reqwest = { version = "0.13", default-features = false, features = ["json"] } url = { version = "2", default-features = false } -parking_lot = "0.12" +parking_lot = "0.13" tokio-util = "0.7" librqbit-dualstack-sockets = "0.7" backon = "1.6" diff --git a/crates/librtbit-tracker-comms/src/tracker_comms.rs b/crates/librtbit-tracker-comms/src/tracker_comms.rs index 367664c5..5f3ee6a2 100644 --- a/crates/librtbit-tracker-comms/src/tracker_comms.rs +++ b/crates/librtbit-tracker-comms/src/tracker_comms.rs @@ -86,6 +86,18 @@ impl TorrentStatsProvider for () { type Sender = tokio::sync::mpsc::Sender; +const TRACKER_RETRY_BASE: Duration = Duration::from_secs(10); +const TRACKER_RETRY_CAP: Duration = Duration::from_secs(60 * 60); + +fn tracker_retry_delay(failures: u32) -> Duration { + let exponent = failures.saturating_sub(1).min(31); + let seconds = TRACKER_RETRY_BASE + .as_secs() + .saturating_mul(1u64 << exponent) + .min(TRACKER_RETRY_CAP.as_secs()); + Duration::from_secs(seconds) +} + enum SupportedTracker { Udp(Url), Http(Url), @@ -371,7 +383,7 @@ impl TrackerComms { .with_jitter() .with_factor(2.) .with_min_delay(Duration::from_secs(10)) - .with_max_delay(Duration::from_secs(600)), + .with_max_delay(Duration::from_secs(60 * 60)), ) .when(|_| self.is_active(&tracker_url, generation)) .notify(|err, retry_in| { @@ -483,6 +495,7 @@ impl TrackerComms { ); let mut sleep_interval: Option = None; + let mut announce_failures = 0u32; let mut prev_addrs: Option = None; let mut kick = self.status.as_ref().map(|s| s.subscribe()); loop { @@ -525,9 +538,13 @@ impl TrackerComms { .instrument(trace_span!("udp request", ?addr)) .await { - Ok(sleep) => sleep_interval = Some(sleep), + Ok(sleep) => { + announce_failures = 0; + sleep_interval = Some(sleep) + } Err(_) => { - sleep_interval = Some(sleep_interval.unwrap_or(Duration::from_secs(60))) + announce_failures = announce_failures.saturating_add(1); + sleep_interval = Some(tracker_retry_delay(announce_failures)); } } } @@ -538,12 +555,16 @@ impl TrackerComms { self.tracker_one_request_udp(&url, v6.into(), &client) .instrument(trace_span!("udp request", addr=?v6)) ); - sleep_interval = Some( - r4.or(r6) - .ok() - .or(sleep_interval) - .unwrap_or(Duration::from_secs(60)), - ) + sleep_interval = match r4.or(r6) { + Ok(sleep) => { + announce_failures = 0; + Some(sleep) + } + Err(_) => { + announce_failures = announce_failures.saturating_add(1); + Some(tracker_retry_delay(announce_failures)) + } + }; } } } @@ -613,3 +634,17 @@ impl TrackerComms { } } } + +#[cfg(test)] +mod retry_tests { + use super::{TRACKER_RETRY_CAP, tracker_retry_delay}; + use std::time::Duration; + + #[test] + fn udp_tracker_retry_backoff_is_exponential_and_capped() { + assert_eq!(tracker_retry_delay(1), Duration::from_secs(10)); + assert_eq!(tracker_retry_delay(2), Duration::from_secs(20)); + assert_eq!(tracker_retry_delay(3), Duration::from_secs(40)); + assert_eq!(tracker_retry_delay(20), TRACKER_RETRY_CAP); + } +} diff --git a/crates/librtbit-upnp-serve/Cargo.toml b/crates/librtbit-upnp-serve/Cargo.toml index d6bd7056..ddfb8616 100644 --- a/crates/librtbit-upnp-serve/Cargo.toml +++ b/crates/librtbit-upnp-serve/Cargo.toml @@ -28,7 +28,7 @@ gethostname = "1" librtbit-core.workspace = true mime_guess = { version = "2", default-features = false } url = { version = "2", default-features = false } -parking_lot = "0.12" +parking_lot = "0.13" tokio-util = "0.7" reqwest = { version = "0.13", default-features = false } quick-xml = { version = "0.42", features = ["serialize"] } diff --git a/crates/librtbit/Cargo.toml b/crates/librtbit/Cargo.toml index ab597552..e75c4648 100644 --- a/crates/librtbit/Cargo.toml +++ b/crates/librtbit/Cargo.toml @@ -65,6 +65,7 @@ peer_binary_protocol.workspace = true sha1w.workspace = true dht.workspace = true librtbit-upnp.workspace = true +network-interface.workspace = true upnp-serve = { workspace = true, optional = true } # External deps @@ -96,7 +97,7 @@ byteorder = "1" bincode = "2" bitvec = "1" crc32fast = "1" -parking_lot = "0.12" +parking_lot = "0.13" tracing = "0.1" size_format = "1" rand = "0.10" diff --git a/crates/librtbit/examples/simulate_traffic.rs b/crates/librtbit/examples/simulate_traffic.rs index ef1028cf..33ae20bf 100644 --- a/crates/librtbit/examples/simulate_traffic.rs +++ b/crates/librtbit/examples/simulate_traffic.rs @@ -179,6 +179,7 @@ impl TestHarness { download_bps: Default::default(), peer_limit: None, concurrent_init_limit: None, + recheck_read_mibps: None, }, disable_local_service_discovery: false, connect: Some(ConnectionOptions { diff --git a/crates/librtbit/src/api.rs b/crates/librtbit/src/api.rs index 3020c3ad..fc4a52fd 100644 --- a/crates/librtbit/src/api.rs +++ b/crates/librtbit/src/api.rs @@ -44,6 +44,36 @@ pub use crate::torrent_state::stats::{LiveStats, TorrentStats}; pub type Result = std::result::Result; +fn download_location_allowed(path: &std::path::Path, roots: &[PathBuf]) -> bool { + !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + && roots.iter().any(|root| path.starts_with(root)) +} + +#[cfg(test)] +mod download_location_tests { + use super::download_location_allowed; + use std::path::Path; + use std::path::PathBuf; + + #[test] + fn only_configured_roots_and_descendants_are_allowed() { + let roots = vec![PathBuf::from("/downloads"), PathBuf::from("/category")]; + assert!(download_location_allowed( + Path::new("/downloads/new"), + &roots + )); + assert!(download_location_allowed(Path::new("/category"), &roots)); + assert!(!download_location_allowed(Path::new("/downloaded"), &roots)); + assert!(!download_location_allowed( + Path::new("/downloads/../etc"), + &roots + )); + assert!(!download_location_allowed(Path::new("/etc"), &roots)); + } +} + /// Library API for use in different web frameworks. /// Contains all methods you might want to expose with (de)serializable inputs/outputs. #[derive(Clone)] @@ -282,7 +312,7 @@ impl Api { let only_files = handle.only_files(); let category = handle.shared().category.read().clone(); let output_folder = handle.output_folder().to_string_lossy().into_owned(); - make_torrent_details( + let mut details = make_torrent_details( Some(handle.id()), &info_hash, handle.metadata.load().as_ref().map(|r| &r.info), @@ -290,7 +320,18 @@ impl Api { only_files.as_deref(), output_folder, category, - ) + )?; + if let Some(files) = details.files.as_mut() { + let priorities = handle.file_priority_levels(); + for (index, file) in files.iter_mut().enumerate() { + file.priority = + priorities + .get(index) + .copied() + .unwrap_or(if file.included { 1 } else { 0 }); + } + } + Ok(details) } pub fn api_session_stats(&self) -> SessionStatsSnapshot { @@ -463,6 +504,20 @@ impl Api { .await } + pub async fn api_torrent_file_set_priority( + &self, + idx: TorrentIdOrHash, + file_index: usize, + priority: u8, + ) -> Result { + if !matches!(priority, 0 | 1 | 6 | 7) { + return Err(anyhow::anyhow!("invalid file priority").into()); + } + let handle = self.mgr_handle(idx)?; + handle.update_file_priority(file_index, priority)?; + Ok(Default::default()) + } + pub fn api_set_rust_log(&self, new_value: String) -> Result { let tx = self .rust_log_reload_tx @@ -594,6 +649,24 @@ impl Api { self.session.output_folder().to_string_lossy().into_owned() } + /// Validate that a requested payload location is within a configured + /// download root (the default folder or a category save path). + pub fn validate_download_location(&self, path: &std::path::Path) -> Result<()> { + let roots = std::iter::once(PathBuf::from(self.api_output_folder())) + .chain( + self.api_list_categories() + .into_values() + .filter_map(|category| category.save_path), + ) + .collect::>(); + if !download_location_allowed(path, &roots) { + return Err( + anyhow::anyhow!("location must be under a configured download folder").into(), + ); + } + Ok(()) + } + pub fn api_listen_port(&self) -> u16 { self.session.listen_addr().map_or(0, |addr| addr.port()) } @@ -683,6 +756,53 @@ impl Api { })?) } + /// Return the compatibility piece state values: 0 for missing pieces, + /// 1 for pieces currently being downloaded, and 2 for completed pieces. + pub fn api_dump_piece_states(&self, idx: TorrentIdOrHash) -> Result> { + let mgr = self.mgr_handle(idx)?; + let Some(live) = mgr.live() else { + return self.api_dump_haves(idx).map(|(bf, len)| { + bf.iter() + .take(len as usize) + .map(|have| u8::from(*have) * 2) + .collect() + }); + }; + let locked = live.lock_read("qbit_piece_states"); + let pieces = locked.get_pieces()?; + let lengths = pieces.chunks().get_lengths(); + let total = lengths.total_pieces(); + Ok((0..total) + .map(|index| { + let have = pieces.chunks().get_have_pieces().as_slice()[index as usize]; + if have { + 2 + } else if lengths + .validate_piece_index(index) + .is_some_and(|piece| pieces.get_inflight(piece).is_some()) + { + 1 + } else { + 0 + } + }) + .collect()) + } + + /// Per-piece count of connected peers that have the piece. Empty while the + /// metadata is unknown (magnet still resolving). + pub fn api_piece_availability(&self, idx: TorrentIdOrHash) -> Result> { + let mgr = self.mgr_handle(idx)?; + let Ok(total) = mgr.with_metadata(|m| m.info.lengths().total_pieces()) else { + return Ok(Vec::new()); + }; + let total = total as usize; + Ok(match mgr.live() { + Some(live) => live.peers.piece_availability(total), + None => vec![0; total], + }) + } + pub async fn api_stream(&self, idx: TorrentIdOrHash, file_id: usize) -> Result { let mgr = self.mgr_handle(idx)?; Ok(mgr.stream(file_id).await?) @@ -696,19 +816,21 @@ impl Api { &self, name: String, save_path: Option, + limits: crate::session::ShareLimits, ) -> Result { let mgr = self.session.categories(); if mgr.exists(&name) { - mgr.edit(&name, save_path) + mgr.edit(&name, save_path, limits) .context("error editing category")?; } else { - mgr.create(name, save_path) + mgr.create(name, save_path, limits) .context("error creating category")?; } self.session .persist_categories() .await .context("error persisting categories")?; + self.session.policy_pass().await; Ok(Default::default()) } @@ -933,10 +1055,16 @@ pub struct TorrentDetailsResponseFile { pub components: Vec, pub length: u64, pub included: bool, + #[serde(default = "default_file_priority")] + pub priority: u8, #[cfg_attr(feature = "swagger", schema(value_type = Object))] pub attributes: FileDetailsAttrs, } +const fn default_file_priority() -> u8 { + 1 +} + #[derive(Default, Serialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct EmptyJsonResponse {} @@ -1003,6 +1131,7 @@ fn make_torrent_details( components, length: d.len, included, + priority: if included { 1 } else { 0 }, attributes: d.attrs(), } }) diff --git a/crates/librtbit/src/category.rs b/crates/librtbit/src/category.rs index 600f68ed..0387f332 100644 --- a/crates/librtbit/src/category.rs +++ b/crates/librtbit/src/category.rs @@ -5,11 +5,29 @@ use anyhow::bail; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use crate::session::ShareLimits; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TorrentCategory { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub save_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratio_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seeding_time_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inactive_seeding_time_limit: Option, +} + +impl TorrentCategory { + pub fn share_limits(&self) -> ShareLimits { + ShareLimits { + max_ratio: self.ratio_limit, + max_seeding_time: self.seeding_time_limit, + max_inactive_seeding_time: self.inactive_seeding_time_limit, + } + } } #[derive(Debug)] @@ -40,21 +58,43 @@ impl CategoryManager { self.categories.read().clone() } - pub fn create(&self, name: String, save_path: Option) -> anyhow::Result<()> { + pub fn create( + &self, + name: String, + save_path: Option, + limits: ShareLimits, + ) -> anyhow::Result<()> { let mut cats = self.categories.write(); if cats.contains_key(&name) { bail!("category '{name}' already exists"); } - cats.insert(name.clone(), TorrentCategory { name, save_path }); + cats.insert( + name.clone(), + TorrentCategory { + name, + save_path, + ratio_limit: limits.max_ratio, + seeding_time_limit: limits.max_seeding_time, + inactive_seeding_time_limit: limits.max_inactive_seeding_time, + }, + ); Ok(()) } - pub fn edit(&self, name: &str, save_path: Option) -> anyhow::Result<()> { + pub fn edit( + &self, + name: &str, + save_path: Option, + limits: ShareLimits, + ) -> anyhow::Result<()> { let mut cats = self.categories.write(); let cat = cats.get_mut(name); match cat { Some(cat) => { cat.save_path = save_path; + cat.ratio_limit = limits.max_ratio; + cat.seeding_time_limit = limits.max_seeding_time; + cat.inactive_seeding_time_limit = limits.max_inactive_seeding_time; Ok(()) } None => bail!("category '{name}' does not exist"), diff --git a/crates/librtbit/src/file_ops.rs b/crates/librtbit/src/file_ops.rs index 71c5a017..f177c3df 100644 --- a/crates/librtbit/src/file_ops.rs +++ b/crates/librtbit/src/file_ops.rs @@ -75,6 +75,7 @@ impl<'a> FileOps<'a> { &self, progress: &AtomicU64, cancellation_token: Option<&CancellationToken>, + throttle: Option<&ReadThrottle>, ) -> anyhow::Result { let mut have_pieces = BF::from_boxed_slice(vec![0u8; self.torrent.lengths().piece_bitfield_bytes()].into()); @@ -123,6 +124,9 @@ impl<'a> FileOps<'a> { let mut piece_remaining = piece_info.len as usize; let mut some_files_broken = false; progress.fetch_add(piece_info.len as u64, Ordering::Relaxed); + if let Some(t) = throttle { + t.wait(piece_info.len as u64); + } while piece_remaining > 0 { let mut to_read_in_file: usize = @@ -374,3 +378,65 @@ impl<'a> FileOps<'a> { Ok(()) } } + +/// Session-wide cap on the combined disk read rate of all checks. Each caller +/// reserves a slot on a shared timeline, so concurrent checks share the cap. +#[derive(Default)] +pub struct ReadThrottle { + /// MiB/s; 0 is unlimited. + mibps: std::sync::atomic::AtomicU64, + next_free: parking_lot::Mutex>, +} + +impl ReadThrottle { + pub fn set_mibps(&self, mibps: Option) { + self.mibps.store(mibps.unwrap_or(0), Ordering::Relaxed); + } + + pub fn mibps(&self) -> Option { + Some(self.mibps.load(Ordering::Relaxed)).filter(|v| *v > 0) + } + + /// How long to wait before reading `bytes`, reserving that slot. + pub(crate) fn reserve(&self, bytes: u64) -> std::time::Duration { + let Some(mibps) = self.mibps() else { + return std::time::Duration::ZERO; + }; + let cost = std::time::Duration::from_secs_f64(bytes as f64 / (mibps as f64 * 1048576.0)); + let now = std::time::Instant::now(); + let mut next = self.next_free.lock(); + let start = next.filter(|n| *n > now).unwrap_or(now); + *next = Some(start + cost); + start - now + } + + /// Block the calling (blocking) thread until `bytes` may be read. + pub fn wait(&self, bytes: u64) { + let d = self.reserve(bytes); + if !d.is_zero() { + std::thread::sleep(d); + } + } +} + +#[cfg(test)] +mod throttle_tests { + use super::ReadThrottle; + use std::time::Duration; + + #[test] + fn unlimited_never_waits() { + let t = ReadThrottle::default(); + assert_eq!(t.reserve(1 << 30), Duration::ZERO); + } + + #[test] + fn limited_spaces_reads_on_a_shared_timeline() { + let t = ReadThrottle::default(); + t.set_mibps(Some(1)); + assert!(t.reserve(1 << 20) < Duration::from_millis(50)); + let second = t.reserve(1 << 20); + assert!(second > Duration::from_millis(900), "{second:?}"); + assert!(second <= Duration::from_secs(1)); + } +} diff --git a/crates/librtbit/src/http_api/handlers/configure.rs b/crates/librtbit/src/http_api/handlers/configure.rs index bcc3b0c6..295b1f89 100644 --- a/crates/librtbit/src/http_api/handlers/configure.rs +++ b/crates/librtbit/src/http_api/handlers/configure.rs @@ -143,6 +143,12 @@ pub const MIN_KEEP_ALIVE_SECS: u64 = crate::session_persistence::MIN_KEEP_ALIVE_ pub const MAX_KEEP_ALIVE_SECS: u64 = crate::session_persistence::MAX_KEEP_ALIVE_SECS; pub const MIN_UPLOAD_SLOTS: u64 = crate::session_persistence::MIN_UPLOAD_SLOTS; pub const MAX_UPLOAD_SLOTS: u64 = crate::session_persistence::MAX_UPLOAD_SLOTS; +pub const MIN_CONCURRENT_RECHECK_LIMIT: u64 = + crate::session_persistence::MIN_CONCURRENT_RECHECK_LIMIT; +pub const MAX_CONCURRENT_RECHECK_LIMIT: u64 = + crate::session_persistence::MAX_CONCURRENT_RECHECK_LIMIT; +pub const MIN_RECHECK_READ_MIBPS: u64 = crate::session_persistence::MIN_RECHECK_READ_MIBPS; +pub const MAX_RECHECK_READ_MIBPS: u64 = crate::session_persistence::MAX_RECHECK_READ_MIBPS; pub const MIN_MAGNET_RESOLUTION_TIMEOUT_SECS: u64 = crate::session_persistence::MIN_MAGNET_RESOLUTION_TIMEOUT_SECS; pub const MAX_MAGNET_RESOLUTION_TIMEOUT_SECS: u64 = @@ -187,6 +193,10 @@ pub struct AdvancedConfig { pub max_concurrent_handshakes: Option, #[serde(default)] pub fastresume_validation_denom: Option, + #[serde(default)] + pub concurrent_recheck_limit: Option, + #[serde(default)] + pub recheck_read_mibps: Option, } /// The restart knobs as saved for the next start. @@ -272,6 +282,18 @@ fn validate_advanced(config: &AdvancedConfig) -> Result { MIN_FASTRESUME_VALIDATION_DENOM, MAX_FASTRESUME_VALIDATION_DENOM, )?, + concurrent_recheck_limit: bounded( + "concurrent_recheck_limit", + config.concurrent_recheck_limit, + MIN_CONCURRENT_RECHECK_LIMIT, + MAX_CONCURRENT_RECHECK_LIMIT, + )?, + recheck_read_mibps: bounded( + "recheck_read_mibps", + config.recheck_read_mibps, + MIN_RECHECK_READ_MIBPS, + MAX_RECHECK_READ_MIBPS, + )?, }) } @@ -297,6 +319,8 @@ pub async fn h_get_advanced(State(state): State) -> Result)); - fn knobs() -> [Knob; 7] { + fn knobs() -> [Knob; 9] { [ ( "connect_timeout_secs", @@ -974,6 +998,18 @@ mod tests { MAX_FASTRESUME_VALIDATION_DENOM, |c, v| c.fastresume_validation_denom = v, ), + ( + "concurrent_recheck_limit", + MIN_CONCURRENT_RECHECK_LIMIT, + MAX_CONCURRENT_RECHECK_LIMIT, + |c, v| c.concurrent_recheck_limit = v, + ), + ( + "recheck_read_mibps", + MIN_RECHECK_READ_MIBPS, + MAX_RECHECK_READ_MIBPS, + |c, v| c.recheck_read_mibps = v, + ), ] } @@ -990,6 +1026,8 @@ mod tests { ("magnet_resolution_timeout_secs", 10, 900), ("max_concurrent_handshakes", 8, 512), ("fastresume_validation_denom", 0, 1000), + ("concurrent_recheck_limit", 1, 32), + ("recheck_read_mibps", 1, 100_000), ] ); assert_eq!((MIN_PEER_LIMIT, MAX_PEER_LIMIT), (1, 5000)); @@ -1181,6 +1219,8 @@ mod tests { magnet_resolution_timeout_secs: Some(240), max_concurrent_handshakes: Some(200), fastresume_validation_denom: Some(0), + concurrent_recheck_limit: Some(1), + recheck_read_mibps: Some(25), }), ) .await; @@ -1230,6 +1270,9 @@ mod tests { // The restart knobs are now the running ones. assert_eq!(session.max_concurrent_handshakes(), 200); assert_eq!(session.fastresume_validation_denom, Some(0)); + // The recheck knobs survived the restart. + assert_eq!(session.get_concurrent_init_limit(), 1); + assert_eq!(session.recheck_throttle.mibps(), Some(25)); assert_eq!( session.saved_advanced(), session.startup_advanced(), diff --git a/crates/librtbit/src/http_api/handlers/file_included_tests.rs b/crates/librtbit/src/http_api/handlers/file_included_tests.rs new file mode 100644 index 00000000..24924bfd --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/file_included_tests.rs @@ -0,0 +1,136 @@ +//! Router-level tests for `POST /torrents/{id}/files/{index}/included`. + +use std::{net::Ipv4Addr, sync::Arc}; + +use http::StatusCode; +use tower::ServiceExt; + +use crate::{ + AddTorrent, AddTorrentOptions, Api, CreateTorrentOptions, ListenerMode, ManagedTorrent, + Session, SessionOptions, create_torrent, + http_api::{HttpApi, HttpApiOptions}, + listen::ListenerOptions, + spawn_utils::BlockingSpawner, +}; + +/// The API router over a fresh session holding one paused two-file torrent. +async fn router_with_two_file_torrent() -> (axum::Router, Arc, tempfile::TempDir) { + let output = tempfile::TempDir::with_prefix("file_included").unwrap(); + let payload = output.path().join("payload"); + std::fs::create_dir(&payload).unwrap(); + std::fs::write(payload.join("a.bin"), vec![0x41; 16 * 1024]).unwrap(); + std::fs::write(payload.join("b.bin"), vec![0x42; 16 * 1024]).unwrap(); + let torrent = create_torrent( + &payload, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + + let session = Session::new_with_opts( + output.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_local_service_discovery: true, + listen: Some(ListenerOptions { + mode: ListenerMode::TcpOnly, + listen_addr: (Ipv4Addr::LOCALHOST, 0).into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + .unwrap(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + let api = Api::new( + session, + None, + #[cfg(feature = "tracing-subscriber-utils")] + None, + ); + let state = Arc::new(HttpApi::new(api, Some(HttpApiOptions::default()))); + (super::make_api_router(state), handle, output) +} + +fn post_included(id: usize, index: usize, body: &str) -> http::Request { + http::Request::builder() + .method("POST") + .uri(format!("/torrents/{id}/files/{index}/included")) + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_owned())) + .unwrap() +} + +fn selection(handle: &ManagedTorrent) -> Vec { + let mut files = handle.only_files().unwrap_or_else(|| vec![0, 1]); + files.sort_unstable(); + files +} + +#[tokio::test] +async fn excluding_then_including_a_file_updates_the_selection() { + let (router, handle, _output) = router_with_two_file_torrent().await; + let id = handle.id(); + + let response = router + .clone() + .oneshot(post_included(id, 1, r#"{"included":false}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(selection(&handle), vec![0]); + + let response = router + .oneshot(post_included(id, 1, r#"{"included":true}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(selection(&handle), vec![0, 1]); +} + +#[tokio::test] +async fn out_of_range_file_index_is_a_bad_request() { + let (router, handle, _output) = router_with_two_file_torrent().await; + + let response = router + .oneshot(post_included(handle.id(), 2, r#"{"included":false}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(selection(&handle), vec![0, 1]); +} + +#[tokio::test] +async fn malformed_body_is_rejected() { + let (router, handle, _output) = router_with_two_file_torrent().await; + + let response = router + .oneshot(post_included(handle.id(), 0, r#"{"included":"yes"}"#)) + .await + .unwrap(); + assert!(response.status().is_client_error(), "{}", response.status()); + assert_eq!(selection(&handle), vec![0, 1]); +} diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 54689b24..44a6e22d 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -1,5 +1,7 @@ pub(crate) mod configure; pub(crate) mod dht; +#[cfg(test)] +mod file_included_tests; pub(crate) mod healthz; pub(crate) mod indexarr; pub(crate) mod logging; @@ -155,6 +157,18 @@ pub fn make_api_router(state: ApiState) -> Router { if !state.opts.read_only { api_router = api_router .route("/torrents", post(torrents::h_torrents_post)) + .route( + "/torrents/bulk/category", + post(torrents::h_bulk_set_torrent_category), + ) + .route( + "/torrents/bulk/tags/add", + post(torrents::h_bulk_add_torrent_tags), + ) + .route( + "/torrents/bulk/tags/remove", + post(torrents::h_bulk_remove_torrent_tags), + ) .route("/torrents/seed_limits", post(torrents::h_set_seed_limits)) .route( "/torrents/limits", @@ -204,6 +218,10 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/files/{index}/included", post(torrents::h_torrent_file_set_included), ) + .route( + "/torrents/{id}/files/{index}/priority", + post(torrents::h_torrent_file_set_priority), + ) .route("/torrents/{id}/add_peers", post(torrents::h_add_peers)) .route("/torrents/create", post(torrents::h_create_torrent)) .route( @@ -218,6 +236,11 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/set_category", post(torrents::h_set_torrent_category), ) + .route( + "/torrents/{id}/set_location", + post(torrents::h_set_torrent_location), + ) + .route("/torrents/{id}/rename", post(torrents::h_rename_torrent)) .route( "/torrents/{id}/seed_limits", post(torrents::h_set_torrent_seed_limits), diff --git a/crates/librtbit/src/http_api/handlers/qbit/app.rs b/crates/librtbit/src/http_api/handlers/qbit/app.rs index 7cacc4bf..3d296d68 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/app.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/app.rs @@ -2,6 +2,69 @@ use super::*; +#[derive(Serialize)] +pub(super) struct NetworkInterfaceEntry { + name: String, + value: String, +} + +pub(super) async fn h_app_network_interface_list() -> axum::Json> { + axum::Json( + NetworkInterface::show() + .unwrap_or_default() + .into_iter() + .map(|interface| NetworkInterfaceEntry { + name: interface.name.clone(), + value: interface.name, + }) + .collect(), + ) +} + +#[derive(Deserialize, Default)] +pub(super) struct NetworkInterfaceQuery { + #[serde(default)] + iface: String, +} + +pub(super) async fn h_app_network_interface_address_list( + Query(query): Query, +) -> axum::response::Response { + let interfaces = match NetworkInterface::show() { + Ok(interfaces) => interfaces, + Err(error) => return qbit_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + if query.iface.is_empty() { + return axum::Json(Vec::::new()).into_response(); + } + let Some(interface) = interfaces + .into_iter() + .find(|interface| interface.name == query.iface) + else { + return qbit_error(StatusCode::NOT_FOUND, "network interface not found"); + }; + axum::Json( + interface + .addr + .into_iter() + .map(|address| address.ip().to_string()) + .collect::>(), + ) + .into_response() +} + +pub(super) async fn h_app_shutdown(State(state): State>) -> &'static str { + let session = state.api_state.api.session().clone(); + let shutdown_token = state.api_state.opts.shutdown_token.clone(); + tokio::spawn(async move { + session.stop().await; + if let Some(token) = shutdown_token { + token.cancel(); + } + }); + "" +} + // --------------------------------------------------------------------------- // App info endpoints // --------------------------------------------------------------------------- @@ -22,13 +85,82 @@ pub(super) async fn h_app_default_save_path(State(state): State>) state.api_state.api.api_output_folder() } -pub(super) async fn h_app_build_info() -> impl IntoResponse { +/// Configured download roots: the session output folder plus every category +/// save path. +fn save_roots(api: &crate::Api) -> Vec { + let mut roots = vec![std::path::PathBuf::from(api.api_output_folder())]; + roots.extend( + api.api_list_categories() + .into_values() + .filter_map(|category| category.save_path), + ); + roots +} + +/// Whether `path` is a root or lies beneath one. Compared by path component, +/// and a `..` component never qualifies, so it cannot climb out of a root. +fn path_under_roots(path: &std::path::Path, roots: &[std::path::PathBuf]) -> bool { + !path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + && roots.iter().any(|root| path.starts_with(root)) +} + +/// Free bytes on the volume holding the default save path; 0 when the +/// platform cannot report it. +pub(super) fn free_space_on_save_volume(api: &crate::Api) -> u64 { + crate::session::scheduler::free_disk_space(std::path::Path::new(&api.api_output_folder())) + .unwrap_or(0) +} + +#[derive(Deserialize, Default)] +pub(super) struct FreeSpaceQuery { + #[serde(default)] + pub(super) path: String, +} + +/// `app/getFreeSpaceAtPath?path=` — bytes free on the volume holding `path`, +/// as plain text. Only paths at or under a configured download root (the +/// default save path or a category save path) are answered; anything else is +/// 400, so the endpoint cannot probe the rest of the filesystem. +pub(super) async fn h_app_get_free_space_at_path( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + let api = &state.api_state.api; + let path = std::path::Path::new(&query.path); + if query.path.is_empty() || !path_under_roots(path, &save_roots(api)) { + return qbit_error( + StatusCode::BAD_REQUEST, + "path must be under a configured download folder", + ); + } + match crate::session::scheduler::free_disk_space(path) { + Some(free) => (StatusCode::OK, free.to_string()).into_response(), + None => qbit_error( + StatusCode::BAD_REQUEST, + "free space is not available for this path", + ), + } +} + +const fn tls_flavour() -> &'static str { + if cfg!(feature = "rust-tls") { + "rustls" + } else if cfg!(feature = "default-tls") { + "OpenSSL (native-tls)" + } else { + "none" + } +} + +pub(super) async fn h_app_build_info() -> axum::Json { axum::Json(QbitBuildInfo { qt: "N/A", libtorrent: "N/A", - boost: "N/A", - openssl: "N/A", - bitness: 64, + boost: concat!("rtbit ", env!("CARGO_PKG_VERSION")), + openssl: tls_flavour(), + bitness: usize::BITS, }) } @@ -39,6 +171,8 @@ pub(super) async fn h_app_preferences( let session = state.api_state.api.session(); let (download, upload) = session.normal_rate_limits(); let alt = session.alt_speed_status().config; + let seed = session.seed_limits(); + let net = session.pending_network().unwrap_or_default(); axum::Json(QbitPreferences { save_path, @@ -50,9 +184,14 @@ pub(super) async fn h_app_preferences( max_uploads_per_torrent: -1, dl_limit: pref_limit_to_qbit(download.map(|v| u64::from(v.get()))), up_limit: pref_limit_to_qbit(upload.map(|v| u64::from(v.get()))), - dht: true, + // A saved value applies at the next start; report it so a client that + // just set it reads it back. + dht: net + .dht_enabled + .unwrap_or_else(|| session.get_dht().is_some()), + // Peer exchange is part of the peer protocol and has no switch. pex: true, - lsd: true, + lsd: net.lsd_enabled.unwrap_or_else(|| session.lsd.is_some()), encryption: 0, queueing_enabled: session.queueing_enabled(), max_active_downloads: limit_to_qbit(session.max_active_downloads()), @@ -66,17 +205,65 @@ pub(super) async fn h_app_preferences( alt_dl_limit: pref_limit_to_qbit(alt.download_rate), alt_up_limit: pref_limit_to_qbit(alt.upload_rate), scheduler_enabled: false, - max_ratio_enabled: false, - max_ratio: 2.0, + max_ratio_enabled: seed.max_ratio.is_some(), + max_ratio: seed.max_ratio.unwrap_or(2.0), + // Reaching a limit always pauses the torrent. max_ratio_act: 0, - max_seeding_time_enabled: false, - max_seeding_time: 1440, - max_inactive_seeding_time_enabled: false, - max_inactive_seeding_time: 1440, + max_seeding_time_enabled: seed.max_seeding_time.is_some(), + max_seeding_time: minutes_or(seed.max_seeding_time, 1440), + max_inactive_seeding_time_enabled: seed.max_inactive_seeding_time.is_some(), + max_inactive_seeding_time: minutes_or(seed.max_inactive_seeding_time, 1440), }) } -/// `app/setPreferences`. `announce_port`, the queue settings +/// Minutes on the wire for a global limit, or `default` when it is off. +fn minutes_or(limit: Option, default: i64) -> i64 { + limit.map_or(default, |d| i64::from(minutes_to_qbit(Some(d)))) +} + +/// Apply an enabled flag and value pair to one global share limit. A flag +/// alone keeps the current value, or `default` when there is none. +fn merge_limit( + current: Option, + enabled: Option, + value: Option, + default: T, +) -> Option { + match enabled { + Some(false) => None, + Some(true) => value.or(current).or(Some(default)), + None => current.map(|c| value.unwrap_or(c)), + } +} + +/// The global share limits after a preferences update. +fn share_limits_update(preferences: &SetPreferences, current: ShareLimits) -> ShareLimits { + let minutes = |m: Option| m.and_then(minutes_from_qbit); + ShareLimits { + max_ratio: merge_limit( + current.max_ratio, + preferences.max_ratio_enabled, + preferences.max_ratio.filter(|r| *r >= 0.0), + 2.0, + ), + max_seeding_time: merge_limit( + current.max_seeding_time, + preferences.max_seeding_time_enabled, + minutes(preferences.max_seeding_time), + std::time::Duration::from_secs(1440 * 60), + ), + max_inactive_seeding_time: merge_limit( + current.max_inactive_seeding_time, + preferences.max_inactive_seeding_time_enabled, + minutes(preferences.max_inactive_seeding_time), + std::time::Duration::from_secs(1440 * 60), + ), + } +} + +/// `app/setPreferences`. `announce_port`, the global share limits +/// (`max_ratio*`, `max_seeding_time*`, `max_inactive_seeding_time*`), `dht` and +/// `lsd` (saved for the next start; `pex` is always on), the queue settings /// (`queueing_enabled`, `max_active_downloads`, `max_active_uploads`) and the /// normal and alternative speed limits (`dl_limit`, `up_limit`, /// `alt_dl_limit`, `alt_up_limit`, bytes/s, 0 or -1 unlimited) are applied; @@ -115,6 +302,26 @@ pub(super) async fn h_app_set_preferences( { warn!(%error, "qbit compat: error saving queue limits"); } + let current = session.seed_limits(); + let limits = share_limits_update(&preferences, current); + if limits != current + && let Err(error) = session.set_seed_limits(limits).await + { + warn!(%error, "qbit compat: error saving global share limits"); + } + if preferences.dht.is_some() || preferences.lsd.is_some() { + // Discovery is chosen at startup, so these are saved for the next start. + if session.has_persistence() { + session.set_pending_network(crate::session_persistence::NetworkSettings { + dht_enabled: preferences.dht, + lsd_enabled: preferences.lsd, + ..Default::default() + }); + if let Err(error) = session.persist_session_settings().await { + warn!(%error, "qbit compat: error saving network settings"); + } + } + } if preferences.dl_limit.is_some() || preferences.up_limit.is_some() { let (download, upload) = session.normal_rate_limits(); let bps = |limit: i64| pref_limit(limit).and_then(limit_to_bps); diff --git a/crates/librtbit/src/http_api/handlers/qbit/categories.rs b/crates/librtbit/src/http_api/handlers/qbit/categories.rs index fd0e9019..68c6ad19 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/categories.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/categories.rs @@ -17,7 +17,20 @@ pub(super) async fn h_categories(State(state): State>) -> impl In .save_path .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - (name.clone(), QbitCategory { name, save_path }) + ( + name.clone(), + QbitCategory { + name, + save_path, + ratio_limit: category.ratio_limit.unwrap_or(-1.0), + seeding_time_limit: category + .seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + inactive_seeding_time_limit: category + .inactive_seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + }, + ) }) .collect(); axum::Json(serde_json::to_value(&map).unwrap_or_default()) @@ -29,6 +42,12 @@ pub(super) struct CreateCategoryForm { pub(super) category: String, #[serde(default, alias = "savePath")] pub(super) save_path: String, + #[serde(default, alias = "ratioLimit")] + pub(super) ratio_limit: Option, + #[serde(default, alias = "seedingTimeLimit")] + pub(super) seeding_time_limit: Option, + #[serde(default, alias = "inactiveSeedingTimeLimit")] + pub(super) inactive_seeding_time_limit: Option, } /// Upstream rejects names with a leading or trailing `/`, or an empty path @@ -89,7 +108,19 @@ async fn save_category(state: &QbitState, form: CreateCategoryForm) -> axum::res match state .api_state .api - .api_create_or_edit_category(form.category, save_path) + .api_create_or_edit_category( + form.category, + save_path, + crate::session::ShareLimits { + max_ratio: form.ratio_limit.filter(|v| *v >= 0.0), + max_seeding_time: form + .seeding_time_limit + .and_then(crate::http_api::handlers::qbit::torrentcreator::minutes_from_qbit), + max_inactive_seeding_time: form + .inactive_seeding_time_limit + .and_then(crate::http_api::handlers::qbit::torrentcreator::minutes_from_qbit), + }, + ) .await { Ok(_) => (StatusCode::OK, "Ok.").into_response(), diff --git a/crates/librtbit/src/http_api/handlers/qbit/mod.rs b/crates/librtbit/src/http_api/handlers/qbit/mod.rs index df85c1d7..39e1cf5c 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/mod.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/mod.rs @@ -5,7 +5,7 @@ //! pretending to be qBittorrent. use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeSet, HashMap}, num::{NonZeroU16, NonZeroU32}, path::Path, path::PathBuf, @@ -22,6 +22,7 @@ use axum::{ }; use bytes::Bytes; use http::{HeaderMap, Method, StatusCode, header::RETRY_AFTER, header::SET_COOKIE}; +use network_interface::{NetworkInterface, NetworkInterfaceConfig}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; @@ -458,6 +459,22 @@ struct SetPreferences { alt_dl_limit: Option, #[serde(default)] alt_up_limit: Option, + #[serde(default)] + max_ratio_enabled: Option, + #[serde(default)] + max_ratio: Option, + #[serde(default)] + max_seeding_time_enabled: Option, + #[serde(default)] + max_seeding_time: Option, + #[serde(default)] + max_inactive_seeding_time_enabled: Option, + #[serde(default)] + max_inactive_seeding_time: Option, + #[serde(default)] + dht: Option, + #[serde(default)] + lsd: Option, } /// A preferences byte-rate limit as rtbit stores it: 0 or negative means @@ -561,8 +578,15 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/webapiVersion", get(h_app_webapi_version)) .route("/buildInfo", get(h_app_build_info)) .route("/defaultSavePath", get(h_app_default_save_path)) + .route("/getFreeSpaceAtPath", get(h_app_get_free_space_at_path)) + .route("/networkInterfaceList", get(h_app_network_interface_list)) + .route( + "/networkInterfaceAddressList", + get(h_app_network_interface_address_list), + ) .route("/preferences", get(h_app_preferences)) - .route("/setPreferences", post(h_app_set_preferences)); + .route("/setPreferences", post(h_app_set_preferences)) + .route("/shutdown", post(h_app_shutdown)); // Torrent endpoints let torrents_router = Router::new() @@ -573,6 +597,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/trackers", get(h_torrents_trackers)) .route("/webseeds", get(h_torrents_webseeds)) .route("/export", get(h_torrents_export)) + .route("/fetchMetadata", post(h_torrents_fetch_metadata)) + .route("/parseMetadata", post(h_torrents_parse_metadata)) + .route("/pieceAvailability", get(h_torrents_piece_availability)) .route("/pieceStates", get(h_torrents_piece_states)) .route("/pieceHashes", get(h_torrents_piece_hashes)) .route("/add", post(h_torrents_add)) @@ -612,6 +639,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/setLocation", post(h_torrents_set_location)) .route("/setSavePath", post(h_torrents_set_location)) .route("/setCategory", post(h_torrents_set_category)) + .route("/setComment", post(h_torrents_set_comment)) + .route("/setAutoManagement", post(h_torrents_set_auto_management)) + .route("/setSuperSeeding", post(h_torrents_set_super_seeding)) .route("/tags", get(h_torrents_tags)) .route("/createTags", post(h_torrents_create_tags)) .route("/deleteTags", post(h_torrents_delete_tags)) diff --git a/crates/librtbit/src/http_api/handlers/qbit/sync.rs b/crates/librtbit/src/http_api/handlers/qbit/sync.rs index 4fdb532b..04df481e 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/sync.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/sync.rs @@ -67,7 +67,20 @@ pub(super) async fn h_sync_maindata( .save_path .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - (name.clone(), QbitCategory { name, save_path }) + ( + name.clone(), + QbitCategory { + name, + save_path, + ratio_limit: category.ratio_limit.unwrap_or(-1.0), + seeding_time_limit: category + .seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + inactive_seeding_time_limit: category + .inactive_seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + }, + ) }) .collect(); @@ -93,7 +106,7 @@ pub(super) async fn h_sync_maindata( queueing: api.session().queueing_enabled(), use_alt_speed_limits: api.session().alt_speed_enabled(), refresh_interval: 1500, - free_space_on_disk: 0, + free_space_on_disk: free_space_on_save_volume(api), global_ratio, }; diff --git a/crates/librtbit/src/http_api/handlers/qbit/tags.rs b/crates/librtbit/src/http_api/handlers/qbit/tags.rs index 297b34c8..9387738f 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/tags.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/tags.rs @@ -157,6 +157,9 @@ pub(super) async fn h_torrents_set_location( return qbit_error(StatusCode::BAD_REQUEST, "location is required"); } let new_root = PathBuf::from(location); + if let Err(error) = api.validate_download_location(&new_root) { + return qbit_error(StatusCode::BAD_REQUEST, error.to_string()); + } let mut last_error = None; for idx in resolve_hashes(api, &form.hashes) { let Ok(handle) = api.mgr_handle(idx) else { diff --git a/crates/librtbit/src/http_api/handlers/qbit/tests.rs b/crates/librtbit/src/http_api/handlers/qbit/tests.rs index d6f75cc2..d677fedc 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/tests.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/tests.rs @@ -1,5 +1,6 @@ use std::{net::Ipv4Addr, path::Path, sync::Arc, time::Duration}; +use axum::extract::FromRequest; use axum::response::IntoResponse; use bytes::Bytes; use http::StatusCode; @@ -17,11 +18,11 @@ use crate::{ use super::{ AddTaskForm, CreatorParams, CreatorStatus, CreatorTask, QbitSessions, QbitState, QbitTorrentCreator, RequiredTaskIdQuery, SyncSnapshots, creator_params, creator_task_status, - h_app_preferences, h_app_set_preferences, h_app_version, h_torrentcreator_add_task, - h_torrentcreator_delete_task, h_torrentcreator_torrent_file, h_torrents_add_tags, - h_torrents_create_tags, h_torrents_delete_tags, h_torrents_info, h_torrents_recheck, - h_torrents_remove_tags, h_torrents_rename_tag, h_torrents_set_tags, matches_category, - origin_allowed, qbit_file_name, + h_app_build_info, h_app_preferences, h_app_set_preferences, h_app_version, + h_torrentcreator_add_task, h_torrentcreator_delete_task, h_torrentcreator_torrent_file, + h_torrents_add_tags, h_torrents_create_tags, h_torrents_delete_tags, h_torrents_fetch_metadata, + h_torrents_info, h_torrents_parse_metadata, h_torrents_recheck, h_torrents_remove_tags, + h_torrents_rename_tag, h_torrents_set_tags, matches_category, origin_allowed, qbit_file_name, }; async fn qbit_state() -> (Arc, Arc, tempfile::TempDir) { @@ -83,6 +84,71 @@ async fn preferences_reports_fixed_listener_and_runtime_announce_port() { assert_eq!(preferences.web_ui_port, 3031); } +#[tokio::test] +async fn parse_metadata_returns_details_without_adding_torrent() { + let (state, session, output) = qbit_state().await; + let payload = output.path().join("payload.bin"); + std::fs::write(&payload, vec![7u8; 4096]).unwrap(); + let torrent = create_torrent( + &payload, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let boundary = "metadataBoundary"; + let body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"payload.torrent\"\r\nContent-Type: application/x-bittorrent\r\n\r\n" + ) + .into_bytes() + .into_iter() + .chain(torrent) + .chain(format!("\r\n--{boundary}--\r\n").into_bytes()) + .collect::>(); + let request = axum::extract::Request::builder() + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(axum::body::Body::from(body)) + .unwrap(); + let response = h_torrents_parse_metadata( + axum::extract::State(state), + axum::extract::Multipart::from_request(request, &()) + .await + .unwrap(), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let metadata: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(metadata["name"], "payload.bin"); + assert_eq!(metadata["size"], 4096); + assert_eq!(metadata["files"][0]["size"], 4096); + assert_eq!(session.with_torrents(|torrents| torrents.count()), 0); +} + +#[tokio::test] +async fn fetch_metadata_rejects_private_url_before_fetching() { + let (state, _session, _output) = qbit_state().await; + let response = h_torrents_fetch_metadata( + axum::extract::State(state), + Bytes::from_static(b"url=http%3A%2F%2F127.0.0.1%2Ftorrent"), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn set_preferences_updates_port_idempotently_without_rebinding_listener() { let (state, session, _output) = qbit_state().await; @@ -141,6 +207,22 @@ async fn app_version_is_on_the_5x_line() { assert!(h_app_version().await.starts_with("v5.")); } +#[tokio::test] +async fn build_info_reports_compatible_schema_and_real_bitness() { + let build_info = serde_json::to_value(h_app_build_info().await.0).unwrap(); + let object = build_info.as_object().unwrap(); + + assert_eq!(object.len(), 5); + for key in ["qt", "libtorrent", "boost", "openssl"] { + assert!(build_info[key].is_string(), "{key} must be a string"); + } + assert_eq!(build_info["bitness"], usize::BITS); + assert_eq!( + build_info["boost"], + concat!("rtbit ", env!("CARGO_PKG_VERSION")) + ); +} + #[tokio::test] async fn preferences_report_seeding_limits_as_disabled() { let (state, _session, _output) = qbit_state().await; @@ -1609,3 +1691,43 @@ fn incomplete_read_only_payload_reports_missing_files() { &stats(None) )); } + +#[cfg(unix)] +#[tokio::test] +async fn free_space_at_path_answers_only_under_download_roots() { + use super::{FreeSpaceQuery, h_app_get_free_space_at_path}; + let (state, _session, output) = qbit_state().await; + let ask = |path: String| { + let state = state.clone(); + async move { + h_app_get_free_space_at_path( + axum::extract::State(state), + axum::extract::Query(FreeSpaceQuery { path }), + ) + .await + } + }; + let root = output.path().to_string_lossy().into_owned(); + let child = output + .path() + .join("not/yet/created") + .to_string_lossy() + .into_owned(); + for allowed in [root.clone(), child] { + let response = ask(allowed).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert!(std::str::from_utf8(&body).unwrap().parse::().is_ok()); + } + for refused in [ + String::new(), + "/".to_owned(), + "/tmp".to_owned(), + format!("{root}/../.."), + format!("{root}-sibling"), + ] { + assert_eq!(ask(refused).await.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs b/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs index dc34ee78..9d9cea41 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs @@ -2,6 +2,113 @@ use super::*; +const METADATA_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Serialize)] +struct QbitMetadataFile { + name: String, + size: u64, +} + +#[derive(Serialize)] +struct QbitMetadataResponse { + name: String, + size: u64, + files: Vec, + hash: String, +} + +#[derive(Deserialize, Default)] +struct FetchMetadataForm { + #[serde(default)] + magnet: String, + #[serde(default)] + url: String, +} + +fn metadata_response(details: crate::api::TorrentDetailsResponse) -> QbitMetadataResponse { + let files = details + .files + .unwrap_or_default() + .into_iter() + .map(|file| QbitMetadataFile { + name: file.name, + size: file.length, + }) + .collect::>(); + let size = files.iter().map(|file| file.size).sum(); + QbitMetadataResponse { + name: details.name.unwrap_or_default(), + size, + files, + hash: details.info_hash, + } +} + +async fn list_only_metadata( + state: &QbitState, + source: AddTorrent<'static>, +) -> Result { + let mut opts = AddTorrentOptions { + list_only: true, + ..Default::default() + }; + let response = tokio::time::timeout( + METADATA_RESOLUTION_TIMEOUT, + state + .api_state + .api + .api_add_torrent(source, Some(std::mem::take(&mut opts))), + ) + .await + .map_err(|_| (StatusCode::ACCEPTED, "metadata is still resolving"))? + .map_err(|_| (StatusCode::BAD_REQUEST, "unable to resolve metadata"))?; + Ok(metadata_response(response.details)) +} + +pub(super) async fn h_torrents_parse_metadata( + State(state): State>, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut torrent = None; + while let Ok(Some(field)) = multipart.next_field().await { + if matches!(field.name(), Some("file" | "torrent" | "torrents")) { + torrent = field.bytes().await.ok(); + break; + } + } + let Some(torrent) = torrent.filter(|bytes| !bytes.is_empty()) else { + return qbit_error(StatusCode::BAD_REQUEST, "torrent file is required"); + }; + match list_only_metadata(&state, AddTorrent::TorrentFileBytes(torrent)).await { + Ok(metadata) => axum::Json(metadata).into_response(), + Err((status, message)) => qbit_error(status, message), + } +} + +pub(super) async fn h_torrents_fetch_metadata( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: FetchMetadataForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let source = if !form.magnet.trim().is_empty() { + form.magnet.trim().to_owned() + } else { + let url = form.url.trim().to_owned(); + if let Err(error) = crate::rss::fetch::check_feed_url(&url) { + return qbit_error(StatusCode::BAD_REQUEST, error.to_string()); + } + url + }; + if source.is_empty() { + return qbit_error(StatusCode::BAD_REQUEST, "magnet or url is required"); + } + match list_only_metadata(&state, AddTorrent::Url(source.into())).await { + Ok(metadata) => axum::Json(metadata).into_response(), + Err((status, message)) => qbit_error(status, message), + } +} + // --------------------------------------------------------------------------- // Torrent actions (add, pause, resume, delete) // --------------------------------------------------------------------------- @@ -632,6 +739,79 @@ pub(super) async fn h_torrents_set_category( (StatusCode::OK, "Ok.").into_response() } +#[derive(Deserialize, Default)] +pub(super) struct SetCommentForm { + #[serde(default)] + pub(super) hashes: String, + #[serde(default)] + pub(super) comment: String, +} + +pub(super) async fn h_torrents_set_comment( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: SetCommentForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + *handle.shared().comment_override.write() = Some(form.comment.clone()); + api.session().try_update_persistence_metadata(&handle).await; + } + } + (StatusCode::OK, "Ok.") +} + +#[derive(Deserialize, Default)] +pub(super) struct SetBooleanForm { + #[serde(default)] + pub(super) hashes: String, + #[serde(default)] + pub(super) value: String, +} + +async fn set_torrent_boolean( + state: &QbitState, + body: &[u8], + set: impl Fn(&crate::torrent_state::ManagedTorrentShared, bool), +) { + let form: SetBooleanForm = serde_urlencoded::from_bytes(body).unwrap_or_default(); + let enabled = form.value.eq_ignore_ascii_case("true"); + let api = &state.api_state.api; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + set(handle.shared(), enabled); + api.session().try_update_persistence_metadata(&handle).await; + } + } +} + +pub(super) async fn h_torrents_set_auto_management( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + set_torrent_boolean(&state, &body, |shared, enabled| { + shared + .auto_management + .store(enabled, std::sync::atomic::Ordering::Relaxed); + }) + .await; + (StatusCode::OK, "Ok.") +} + +pub(super) async fn h_torrents_set_super_seeding( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + set_torrent_boolean(&state, &body, |shared, enabled| { + shared + .super_seeding + .store(enabled, std::sync::atomic::Ordering::Relaxed); + }) + .await; + (StatusCode::OK, "Ok.") +} + #[derive(Deserialize, Default)] pub(super) struct AddTrackersForm { #[serde(default)] @@ -828,22 +1008,18 @@ pub(super) async fn h_torrents_file_prio( Ok(idx) => idx, Err(_) => return (StatusCode::NOT_FOUND, "Not found"), }; - let handle = match api.mgr_handle(idx) { - Ok(h) => h, + match api.mgr_handle(idx) { + Ok(_) => {} Err(_) => return (StatusCode::NOT_FOUND, "Not found"), - }; + } let num_files = match api.api_torrent_details(idx) { Ok(details) => details.files.map(|f| f.len()).unwrap_or(0), Err(_) => return (StatusCode::NOT_FOUND, "Not found"), }; - // qBittorrent priority 0 means "do not download"; anything else downloads. - // We only model an include/exclude selection, so map onto only_files. - let mut included: HashSet = match handle.only_files() { - Some(files) => files.into_iter().collect(), - None => (0..num_files).collect(), - }; - let download = form.priority != 0; + if !matches!(form.priority, 0 | 1 | 6 | 7) { + return (StatusCode::CONFLICT, "Invalid priority"); + } for id in form .id .split('|') @@ -852,20 +1028,19 @@ pub(super) async fn h_torrents_file_prio( if id >= num_files { return (StatusCode::CONFLICT, "Invalid file id"); } - if download { - included.insert(id); - } else { - included.remove(&id); + match api + .api_torrent_file_set_priority(idx, id, form.priority) + .await + { + Ok(_) => {} + Err(error) => { + warn!(%error, "qbit compat: error setting file priority"); + return (StatusCode::CONFLICT, "Failed"); + } } } - match api - .api_torrent_action_update_only_files(idx, &included) - .await - { - Ok(_) => (StatusCode::OK, "Ok."), - Err(error) => { - warn!(%error, "qbit compat: error setting file priority"); - (StatusCode::CONFLICT, "Failed") - } + if form.id.trim().is_empty() { + return (StatusCode::CONFLICT, "Invalid file id"); } + (StatusCode::OK, "Ok.") } diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs b/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs index e01a39b6..af08466f 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs @@ -140,6 +140,12 @@ pub(super) struct QbitCategory { pub(super) name: String, #[serde(rename = "savePath")] pub(super) save_path: String, + #[serde(rename = "ratioLimit")] + pub(super) ratio_limit: f64, + #[serde(rename = "seedingTimeLimit")] + pub(super) seeding_time_limit: i64, + #[serde(rename = "inactiveSeedingTimeLimit")] + pub(super) inactive_seeding_time_limit: i64, } /// Combined qBit compat state. diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs index 43906899..9fcc9b7b 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs @@ -49,7 +49,10 @@ pub(super) fn qbit_state_for( forced: bool, ) -> &'static str { if matches!(stats.state, TorrentStatsState::Paused) - && stats.error.as_deref() == Some(crate::torrent_state::READ_ONLY_INCOMPLETE_PAYLOAD) + && stats + .error + .as_deref() + .is_some_and(crate::torrent_state::is_read_only_incomplete_payload) { return "missingFiles"; } @@ -206,7 +209,10 @@ pub(super) fn build_torrent_info( QbitTorrentInfo { added_on, amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), - auto_tmm: false, + auto_tmm: handle + .shared() + .auto_management + .load(std::sync::atomic::Ordering::Relaxed), availability: -1, category, completed: stats.progress_bytes, @@ -257,7 +263,10 @@ pub(super) fn build_torrent_info( seq_dl: handle.is_sequential_download(), size: stats.total_bytes, state: qbit_state.to_string(), - super_seeding: false, + super_seeding: handle + .shared() + .super_seeding + .load(std::sync::atomic::Ordering::Relaxed), tags: handle .shared() .tags @@ -504,7 +513,12 @@ pub(super) async fn h_torrents_properties( save_path: output_folder, creation_date: meta.creation_date, piece_size, - comment: meta.comment, + comment: handle + .shared() + .comment_override + .read() + .clone() + .unwrap_or(meta.comment), total_wasted: 0, total_uploaded: stats.uploaded_bytes, total_uploaded_session: stats.uploaded_bytes, @@ -630,6 +644,7 @@ pub(super) async fn h_torrents_files( }) .unwrap_or_default(); let use_piece_ranges = piece_ranges.len() == details_files.len(); + let priorities = handle.file_priority_levels(); let files: Vec = details_files .iter() .enumerate() @@ -651,7 +666,10 @@ pub(super) async fn h_torrents_files( name, size: f.length, progress, - priority: if f.included { 1 } else { 0 }, + priority: priorities + .get(i) + .copied() + .unwrap_or(if f.included { 1 } else { 0 }), is_seed, piece_range: if use_piece_ranges { piece_ranges[i] @@ -732,20 +750,27 @@ pub(super) async fn h_torrents_piece_states( Ok(idx) => idx, Err(_) => return qbit_error(StatusCode::NOT_FOUND, "Not found"), }; - let (bf, len) = match api.api_dump_haves(idx) { + let states = match api.api_dump_piece_states(idx) { Ok(v) => v, Err(_) => return qbit_error(StatusCode::NOT_FOUND, "Not found"), }; - // qBittorrent: 0 not downloaded, 1 downloading (requested), 2 downloaded. - // We only distinguish have/not-have, so emit 0 or 2. - let states: Vec = bf - .iter() - .take(len as usize) - .map(|b| if *b { 2 } else { 0 }) - .collect(); axum::Json(states).into_response() } +pub(super) async fn h_torrents_piece_availability( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return qbit_error(StatusCode::NOT_FOUND, "Not found"), + }; + match state.api_state.api.api_piece_availability(idx) { + Ok(counts) => axum::Json(counts).into_response(), + Err(_) => qbit_error(StatusCode::NOT_FOUND, "Not found"), + } +} + pub(super) async fn h_torrents_piece_hashes( State(state): State>, Query(query): Query, diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity.rs b/crates/librtbit/src/http_api/handlers/qbit_parity.rs index dc1d11d9..cd66bd76 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_parity.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_parity.rs @@ -654,7 +654,7 @@ async fn routed_responses_match_declared_shapes() { continue; }; let uri = format!( - "/{}?hash={hash}&hashes={hash}&taskID={task_id}", + "/{}?hash={hash}&hashes={hash}&taskID={task_id}&path={source}", ep.endpoint ); let response = get(&router, &uri).await; @@ -1672,7 +1672,7 @@ async fn bridge_endpoints_through_compat_router() { .json(); assert_eq!(states, Value::from(vec![2; pieces])); - // filePrio: 0 skips a file, any other level includes it again. + // filePrio preserves qBittorrent's skip/normal/high/max levels. let file_priorities = || async { get(&router, &format!("/torrents/files?hash={hash}")) .await @@ -1699,8 +1699,12 @@ async fn bridge_endpoints_through_compat_router() { }; assert_eq!(set_prio("1", "0").await, StatusCode::OK); assert_eq!(file_priorities().await, vec![1, 0]); + assert_eq!(set_prio("0", "6").await, StatusCode::OK); + assert_eq!(file_priorities().await, vec![6, 0]); assert_eq!(set_prio("1", "7").await, StatusCode::OK); - assert_eq!(file_priorities().await, vec![1, 1]); + assert_eq!(file_priorities().await, vec![6, 7]); + assert_eq!(set_prio("0", "1").await, StatusCode::OK); + assert_eq!(file_priorities().await, vec![1, 7]); assert_eq!(set_prio("5", "0").await, StatusCode::CONFLICT); let added = post_form( @@ -1870,6 +1874,28 @@ async fn post_add(router: &axum::Router, torrent: &[u8], fields: &[(&str, &str)] send(router, request).await } +/// Global share limits set through `app/setPreferences` are read back, and a +/// disabled flag turns the limit off again. +#[tokio::test] +async fn set_preferences_applies_global_share_limits() { + let (router, session, _output) = make_router().await; + let json = r#"{"max_ratio_enabled":true,"max_ratio":1.5,"max_seeding_time_enabled":true,"max_seeding_time":90}"#; + let set = post_form(&router, "/app/setPreferences", &[("json", json)]).await; + assert_eq!(set.status, StatusCode::OK); + let prefs = get(&router, "/app/preferences").await.json(); + assert_eq!(prefs["max_ratio_enabled"], true); + assert_eq!(prefs["max_ratio"], 1.5); + assert_eq!(prefs["max_seeding_time_enabled"], true); + assert_eq!(prefs["max_seeding_time"], 90); + assert_eq!(session.seed_limits().max_ratio, Some(1.5)); + + let json = r#"{"max_ratio_enabled":false}"#; + post_form(&router, "/app/setPreferences", &[("json", json)]).await; + let prefs = get(&router, "/app/preferences").await.json(); + assert_eq!(prefs["max_ratio_enabled"], false); + assert_eq!(prefs["max_seeding_time_enabled"], true); +} + /// The calls Sonarr and Radarr make: "Test Connection" (version, preferences, /// categories, creating their category) and then a grab (add stopped into the /// category with a content layout, read it back by category, properties and @@ -1902,7 +1928,8 @@ async fn arr_test_connection_and_grab_flow() { assert!(prefs.get(key).is_some(), "preferences lacks {key}: {prefs}"); } assert_eq!(prefs["max_ratio_enabled"], false); - assert_eq!(prefs["dht"], true); + assert!(prefs["dht"].is_boolean()); + assert_eq!(prefs["pex"], true); assert_eq!( get(&router, "/torrents/categories").await.json(), serde_json::json!({}) diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json index 240b9e47..3386ff52 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json +++ b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json @@ -9,7 +9,7 @@ "endpoint": "app/buildInfo", "method": "GET", "status": "partial", - "notes": "Placeholder values for qt/libtorrent/boost/openssl.", + "notes": "Keeps qt/libtorrent keys for schema compatibility, reports the rtbit crate version in boost, the configured OpenSSL/rustls flavour in openssl, and the target pointer width in bitness.", "response": { "content": "json", "schema": { @@ -50,23 +50,28 @@ { "endpoint": "app/getFreeSpaceAtPath", "method": "GET", - "status": "missing" + "status": "full", + "response": { + "content": "text" + } }, { "endpoint": "app/networkInterfaceAddressList", "method": "GET", - "status": "missing" + "status": "full", + "response": { "content": "json", "schema": ["string"] } }, { "endpoint": "app/networkInterfaceList", "method": "GET", - "status": "missing" + "status": "full", + "response": { "content": "json", "schema": [{ "name": "string", "value": "string" }] } }, { "endpoint": "app/preferences", "method": "GET", "status": "partial", - "notes": "Subset of preference fields. dl_limit/up_limit and alt_dl_limit/alt_up_limit report the live normal and alternative session limits (bytes/s, 0 unlimited); scheduler_enabled is false. queueing_enabled, max_active_downloads and max_active_uploads reflect the session queue limits; max_active_torrents is -1 (no combined limit). Global seeding-limit fields (max_ratio_enabled, max_seeding_time_enabled, max_inactive_seeding_time_enabled and their values) are reported disabled with upstream default values; rtbit enforces seeding limits per torrent only (torrents/setShareLimits). dht/pex/lsd are reported true.", + "notes": "Subset of preference fields. dl_limit/up_limit and alt_dl_limit/alt_up_limit report the live normal and alternative session limits (bytes/s, 0 unlimited); scheduler_enabled is false. queueing_enabled, max_active_downloads and max_active_uploads reflect the session queue limits; max_active_torrents is -1 (no combined limit). Global seeding-limit fields (max_ratio*, max_seeding_time*, max_inactive_seeding_time*) report the session-wide seed limits (disabled with upstream default values when unset); max_ratio_act is always 0 (pause). dht and lsd report the running state, or the value saved for the next start; pex is always true.", "response": { "content": "json", "schema": { @@ -135,13 +140,15 @@ { "endpoint": "app/shutdown", "method": "POST", - "status": "missing" + "status": "full", + "response": { "content": "text" }, + "notes": "Requires authentication and initiates graceful session shutdown." }, { "endpoint": "app/version", "method": "GET", "status": "partial", - "notes": "Returns a fixed 5.x-line version string (v5.0.0) matching the advertised WebAPI version; not a real qBittorrent build.", + "notes": "Returns a fixed 5.x-line version string (v5.0.0) matching the advertised WebAPI version because *arr clients gate compatibility features on this value; it intentionally does not report the rtbit crate version.", "response": { "content": "text" } @@ -766,13 +773,28 @@ { "endpoint": "torrents/fetchMetadata", "method": "POST", - "status": "missing" + "status": "full", + "notes": "Resolves a magnet or validated public HTTP(S) URL without adding it; returns 202 while metadata resolution exceeds the bounded timeout.", + "response": { + "content": "json", + "schema": { + "name": "string", + "size": "integer", + "files": [ + { + "name": "string", + "size": "integer" + } + ], + "hash": "string" + } + } }, { "endpoint": "torrents/filePrio", "method": "POST", "status": "partial", - "notes": "Maps priority 0 to file exclusion and any non-zero priority to inclusion (no per-file priority tiers).", + "notes": "Accepts and persists the full per-file priority levels 0 (skipped), 1 (normal), 6 (high), and 7 (maximum); values round-trip through torrents/files.", "response": { "content": "text" } @@ -889,7 +911,21 @@ { "endpoint": "torrents/parseMetadata", "method": "POST", - "status": "missing" + "status": "full", + "response": { + "content": "json", + "schema": { + "name": "string", + "size": "integer", + "files": [ + { + "name": "string", + "size": "integer" + } + ], + "hash": "string" + } + } }, { "endpoint": "torrents/pause", @@ -904,7 +940,14 @@ { "endpoint": "torrents/pieceAvailability", "method": "GET", - "status": "missing" + "status": "full", + "notes": "Per-piece count of connected peers that advertise the piece; empty array while a magnet is still resolving.", + "response": { + "content": "json", + "schema": [ + "integer" + ] + } }, { "endpoint": "torrents/pieceHashes", @@ -922,7 +965,7 @@ "endpoint": "torrents/pieceStates", "method": "GET", "status": "partial", - "notes": "Emits 0 (not downloaded) / 2 (downloaded) from the have bitfield; the in-flight state 1 is not distinguished.", + "notes": "Emits 0 (not downloaded), 1 (currently in-flight), or 2 (downloaded) for each piece.", "response": { "content": "json", "schema": [ @@ -1077,7 +1120,11 @@ { "endpoint": "torrents/setAutoManagement", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists the per-torrent auto-management flag and reports it in torrents/info auto_tmm.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setCategory", @@ -1090,7 +1137,11 @@ { "endpoint": "torrents/setComment", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists a per-torrent comment and reports it in torrents/properties.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setDownloadLimit", @@ -1151,7 +1202,11 @@ { "endpoint": "torrents/setSuperSeeding", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists the per-torrent super-seeding flag and reports it in torrents/info super_seeding; peer behavior is not changed.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setTags", @@ -1232,7 +1287,7 @@ "endpoint": "torrents/trackers", "method": "GET", "status": "partial", - "notes": "Real per-tracker announce state; tier always 0 and num_downloaded always -1 (not tracked).", + "notes": "Real per-tracker announce state and peer counts. Tracker tier and num_downloaded remain unavailable because configured trackers are currently stored without tier or scrape-download metadata.", "response": { "content": "json", "schema": [ diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay.rs b/crates/librtbit/src/http_api/handlers/qbit_replay.rs index c5fadf22..d66d9326 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_replay.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_replay.rs @@ -38,6 +38,8 @@ const FIXTURE_TORRENT: &[u8] = include_bytes!("qbit_replay/fixture.torrent"); const FIXTURES: &[(&str, &str)] = &[ ("sonarr", include_str!("qbit_replay/sonarr.json")), + ("radarr", include_str!("qbit_replay/radarr.json")), + ("lidarr", include_str!("qbit_replay/lidarr.json")), ( "qbittorrent_api", include_str!("qbit_replay/qbittorrent_api.json"), @@ -380,11 +382,23 @@ async fn replay_sonarr() { } #[tokio::test] -async fn replay_qbittorrent_api() { +async fn replay_radarr() { let (name, raw) = FIXTURES[1]; replay(&parse(name, raw)).await; } +#[tokio::test] +async fn replay_lidarr() { + let (name, raw) = FIXTURES[2]; + replay(&parse(name, raw)).await; +} + +#[tokio::test] +async fn replay_qbittorrent_api() { + let (name, raw) = FIXTURES[3]; + replay(&parse(name, raw)).await; +} + #[test] fn fixture_status_codes_are_valid() { for (name, raw) in FIXTURES { diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json b/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json new file mode 100644 index 00000000..4e25f779 --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json @@ -0,0 +1,33 @@ +{ + "name": "lidarr", + "client": "Lidarr download-client proxy", + "notes": "Request sequence built from the current V2 proxy source: connection test, music add, post-import category, and delete.", + "steps": [ + { "name": "login", "method": "POST", "path": "/auth/login", + "form": { "username": "{{username}}", "password": "{{password}}" }, + "live_only": true, "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "detect v2 API", "method": "GET", "path": "/app/version", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "read API version", "method": "GET", "path": "/app/webapiVersion", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "create imported category", "method": "POST", "path": "/torrents/createCategory", + "form": { "category": "lidarr-imported", "savePath": "" }, + "expect": { "status": 200 } }, + { "name": "add album", "method": "POST", "path": "/torrents/add", + "multipart": { "torrent": true, "fields": { + "category": "lidarr", "stopped": "true" } }, + "expect": { "status": 200 } }, + { "name": "wait for album", "method": "GET", "path": "/torrents/info", + "query": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a" }, + "expect": { "status": 200, "eventually": true, "json": [ + { "path": "", "len": 1 }, + { "path": "0.category", "eq": "lidarr" } ] }, + "diff": { "values": ["0.hash", "0.name", "0.category"] } }, + { "name": "set imported category", "method": "POST", "path": "/torrents/setCategory", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "category": "lidarr-imported" }, + "expect": { "status": 200 } }, + { "name": "delete album", "method": "POST", "path": "/torrents/delete", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "deleteFiles": "true" }, + "expect": { "status": 200 } } + ] +} diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json b/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json new file mode 100644 index 00000000..4633b019 --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json @@ -0,0 +1,33 @@ +{ + "name": "radarr", + "client": "Radarr download-client proxy", + "notes": "Request sequence built from the current V2 proxy source: connection test, movie add, post-import category, and delete.", + "steps": [ + { "name": "login", "method": "POST", "path": "/auth/login", + "form": { "username": "{{username}}", "password": "{{password}}" }, + "live_only": true, "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "detect v2 API", "method": "GET", "path": "/app/version", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "read API version", "method": "GET", "path": "/app/webapiVersion", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "create imported category", "method": "POST", "path": "/torrents/createCategory", + "form": { "category": "radarr-imported", "savePath": "" }, + "expect": { "status": 200 } }, + { "name": "add movie", "method": "POST", "path": "/torrents/add", + "multipart": { "torrent": true, "fields": { + "category": "radarr", "stopped": "true" } }, + "expect": { "status": 200 } }, + { "name": "wait for movie", "method": "GET", "path": "/torrents/info", + "query": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a" }, + "expect": { "status": 200, "eventually": true, "json": [ + { "path": "", "len": 1 }, + { "path": "0.category", "eq": "radarr" } ] }, + "diff": { "values": ["0.hash", "0.name", "0.category"] } }, + { "name": "set imported category", "method": "POST", "path": "/torrents/setCategory", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "category": "radarr-imported" }, + "expect": { "status": 200 } }, + { "name": "delete movie", "method": "POST", "path": "/torrents/delete", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "deleteFiles": "true" }, + "expect": { "status": 200 } } + ] +} diff --git a/crates/librtbit/src/http_api/handlers/torrents.rs b/crates/librtbit/src/http_api/handlers/torrents.rs index 151366e0..1169978b 100644 --- a/crates/librtbit/src/http_api/handlers/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/torrents.rs @@ -22,7 +22,7 @@ use crate::api::{ use crate::{ AddTorrent, ApiError, CreateTorrentOptions, SUPPORTED_SCHEMES, api::{ApiTorrentListOpts, Result, TorrentIdOrHash}, - api_error::WithStatusError, + api_error::{WithStatus, WithStatusError}, http_api::timeout::Timeout, http_api_types::TorrentAddQueryParams, torrent_state::peer::stats::snapshot::{PeerStatsFilter, PeerStatsFilterState}, @@ -587,6 +587,37 @@ pub async fn h_torrent_file_set_included( .map(axum::Json) } +#[derive(Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct SetFilePriorityRequest { + priority: u8, +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + path = "/torrents/{id}/files/{index}/priority", + params( + ("id" = String, Path, description = "Torrent ID or info hash"), + ("index" = usize, Path, description = "File index"), + ), + request_body(content = SetFilePriorityRequest, description = "File priority: 0, 1, 6, or 7"), + responses( + (status = 200, description = "File priority updated", body = EmptyJsonResponse), + (status = 400, description = "File index or priority is invalid") + ) +))] +pub async fn h_torrent_file_set_priority( + State(state): State, + Path((idx, file_index)): Path<(TorrentIdOrHash, usize)>, + axum::Json(req): axum::Json, +) -> Result { + state + .api + .api_torrent_file_set_priority(idx, file_index, req.priority) + .await + .map(axum::Json) +} + #[cfg_attr(feature = "swagger", utoipa::path( get, path = "/stats", @@ -814,6 +845,12 @@ pub async fn h_create_torrent( pub struct CreateOrEditCategoryRequest { pub name: String, pub save_path: Option, + #[serde(default)] + pub ratio_limit: Option, + #[serde(default)] + pub seeding_time_limit: Option, + #[serde(default)] + pub inactive_seeding_time_limit: Option, } #[derive(Deserialize)] @@ -821,6 +858,107 @@ pub struct SetTorrentCategoryRequest { pub category: Option, } +#[derive(Deserialize)] +pub struct BulkCategoryRequest { + pub ids: Vec, + pub category: Option, +} + +#[derive(Deserialize)] +pub struct BulkTagsRequest { + pub ids: Vec, + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct BulkEditFailure { + pub id: u64, + pub error: String, +} + +#[derive(Serialize)] +pub struct BulkEditResponse { + pub failures: Vec, +} + +pub async fn h_bulk_set_torrent_category( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_set_torrent_category(TorrentIdOrHash::Id(id), req.category.clone()) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + +pub async fn h_bulk_add_torrent_tags( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_add_torrent_tags(TorrentIdOrHash::Id(id), &req.tags) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + +pub async fn h_bulk_remove_torrent_tags( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_remove_torrent_tags(TorrentIdOrHash::Id(id), &req.tags) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + pub async fn h_list_categories(State(state): State) -> impl IntoResponse { axum::Json(state.api.api_list_categories()) } @@ -831,7 +969,17 @@ pub async fn h_create_or_edit_category( ) -> Result { state .api - .api_create_or_edit_category(req.name, req.save_path) + .api_create_or_edit_category( + req.name, + req.save_path, + crate::session::ShareLimits { + max_ratio: req.ratio_limit, + max_seeding_time: req.seeding_time_limit.map(std::time::Duration::from_secs), + max_inactive_seeding_time: req + .inactive_seeding_time_limit + .map(std::time::Duration::from_secs), + }, + ) .await .map(axum::Json) } @@ -855,6 +1003,50 @@ pub async fn h_set_torrent_category( .map(axum::Json) } +#[derive(Deserialize)] +pub struct SetTorrentLocationRequest { + pub location: String, +} + +pub async fn h_set_torrent_location( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + let location = req.location.trim(); + if location.is_empty() { + return Err(anyhow::anyhow!("location is required").into()); + } + let location = PathBuf::from(location); + state.api.validate_download_location(&location)?; + let handle = state.api.mgr_handle(idx)?; + handle + .set_location(location) + .with_status(StatusCode::CONFLICT)?; + Ok(axum::Json(crate::api::EmptyJsonResponse::default())) +} + +#[derive(Deserialize)] +pub struct RenameTorrentRequest { + pub name: String, +} + +pub async fn h_rename_torrent( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + let name = req.name.trim(); + if name.is_empty() { + return Err(anyhow::anyhow!("name is required").into()); + } + state + .api + .mgr_handle(idx)? + .set_display_name(Some(name.to_owned())); + Ok(axum::Json(crate::api::EmptyJsonResponse::default())) +} + pub async fn h_get_torrent_rate_limits( State(state): State, Path(idx): Path, diff --git a/crates/librtbit/src/http_api/mod.rs b/crates/librtbit/src/http_api/mod.rs index bd9d6c8d..8fb4cb81 100644 --- a/crates/librtbit/src/http_api/mod.rs +++ b/crates/librtbit/src/http_api/mod.rs @@ -89,7 +89,7 @@ struct ApiDoc; /// An HTTP server for the API. pub struct HttpApi { api: Api, - opts: HttpApiOptions, + pub(crate) opts: HttpApiOptions, login_limiter: auth::LoginLimiter, } @@ -115,6 +115,7 @@ pub struct HttpApiOptions { pub rss_db: Option>>, /// RSS feed history limit (max items to keep). None = unlimited, default 500. pub rss_history_limit: Option, + pub shutdown_token: Option, } /// Constant-time byte comparison to prevent timing attacks on auth credentials. diff --git a/crates/librtbit/src/ip_ranges.rs b/crates/librtbit/src/ip_ranges.rs index d0752189..91bfe07d 100644 --- a/crates/librtbit/src/ip_ranges.rs +++ b/crates/librtbit/src/ip_ranges.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result}; use async_compression::tokio::bufread::GzipDecoder; -use futures::TryStreamExt; use intervaltree::IntervalTree; use std::iter::empty; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -9,7 +8,6 @@ use std::path::Path; use std::str::FromStr; use tokio::io::{AsyncBufRead, AsyncRead}; use tokio::{io::AsyncBufReadExt, io::BufReader}; -use tokio_util::io::StreamReader; use tracing::trace; use url::Url; @@ -69,14 +67,14 @@ impl IpRanges { return Self::load_from_file(path).await; } - let response = reqwest::get(parsed_url) + let limits = crate::safe_fetch::FetchLimits { + max_bytes: 256 * 1024 * 1024, + ..Default::default() + }; + let body = crate::safe_fetch::fetch_bytes(parsed_url.as_str(), &limits) .await .context("error fetching list")?; - if !response.status().is_success() { - anyhow::bail!("error fetching list: HTTP {}", response.status()); - } - - let mut reader = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); + let mut reader = std::io::Cursor::new(body); let bl = Self::create_from_stream(&mut reader).await?; Ok(bl) } diff --git a/crates/librtbit/src/lib.rs b/crates/librtbit/src/lib.rs index 628a5296..00e6fe84 100644 --- a/crates/librtbit/src/lib.rs +++ b/crates/librtbit/src/lib.rs @@ -70,9 +70,14 @@ mod piece_tracker; mod read_buf; #[cfg(feature = "rss")] pub mod rss; +mod safe_fetch; mod session; mod session_persistence; pub use session_persistence::TorrentRateLimits; +pub use session_persistence::missing_report::{ + MissingFileReport, MissingFileStatus, MissingTorrentDiagnosis, MissingTorrentReport, + report_missing_files, +}; pub mod session_stats; pub mod spawn_utils; pub mod tag; diff --git a/crates/librtbit/src/piece_tracker.rs b/crates/librtbit/src/piece_tracker.rs index 65c2a9b9..6f3fd493 100644 --- a/crates/librtbit/src/piece_tracker.rs +++ b/crates/librtbit/src/piece_tracker.rs @@ -427,6 +427,39 @@ mod tests { assert_eq!(picked, vec![0, 3, 1, 2, 4, 6, 5]); } + #[test] + fn test_file_priority_order_picks_max_before_high_and_normal() { + let (mut tracker, file_infos) = make_two_file_tracker(2, 2); + let file_priorities = vec![1, 7]; + let mut ordered_files = (0..file_infos.len()).collect::>(); + ordered_files.sort_unstable_by_key(|id| std::cmp::Reverse(file_priorities[*id])); + let picked = { + let mut picked = Vec::new(); + loop { + match tracker.acquire_piece(AcquireRequest { + peer: peer(1), + peer_avg_time: None, + priority_pieces: std::iter::empty(), + file_priorities: &ordered_files, + file_infos: &file_infos, + order: PieceOrder { + sequential: true, + first_last_piece_priority: false, + }, + peer_has_piece: |_| true, + can_steal: |_| false, + }) { + AcquireResult::Reserved(piece) => picked.push(piece.get()), + AcquireResult::NoneAvailable => break picked, + other => panic!("unexpected {other:?}"), + } + } + }; + assert_eq!(file_priorities, vec![1, 7]); + assert_eq!(ordered_files, vec![1, 0]); + assert_eq!(picked, vec![2, 3, 0, 1]); + } + #[test] fn test_sequential_order() { let (mut tracker, file_infos) = make_two_file_tracker(4, 3); diff --git a/crates/librtbit/src/rss/fetch.rs b/crates/librtbit/src/rss/fetch.rs index 26bf8074..35ae10bc 100644 --- a/crates/librtbit/src/rss/fetch.rs +++ b/crates/librtbit/src/rss/fetch.rs @@ -6,6 +6,8 @@ use std::net::IpAddr; +pub use crate::safe_fetch::is_forbidden_address; + /// Largest feed body that is kept. Anything longer is truncated, so a feed /// cannot force an unbounded allocation. pub const MAX_FEED_BYTES: usize = 2 * 1024 * 1024; @@ -71,65 +73,6 @@ pub fn assert_public_addresses(addresses: &[IpAddr]) -> Result<(), FeedUrlError> Ok(()) } -/// True for loopback, private, link-local, documentation, broadcast and other -/// addresses a feed must never be fetched from. -pub fn is_forbidden_address(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let [a, b, ..] = v4.octets(); - v4.is_private() - || v4.is_loopback() - || v4.is_link_local() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_unspecified() - || v4.is_multicast() - // "This network" 0.0.0.0/8 (is_unspecified covers only 0.0.0.0). - || a == 0 - // Carrier-grade NAT, 100.64.0.0/10. - || (a == 100 && (64..128).contains(&b)) - // Benchmarking 198.18.0.0/15. - || (a == 198 && (b == 18 || b == 19)) - // Reserved for future use, 240.0.0.0/4. - || a >= 240 - } - IpAddr::V6(v6) => { - let seg0 = v6.segments()[0]; - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - // Unique local fc00::/7 and link local fe80::/10. - || (seg0 & 0xfe00) == 0xfc00 - || (seg0 & 0xffc0) == 0xfe80 - // NAT64 well-known prefix 64:ff9b::/96. - || is_nat64(v6) - // IPv4-compatible ::a.b.c.d and 6to4 2002::/16. - || is_ipv4_compatible(v6) - || seg0 == 0x2002 - || v6 - .to_ipv4_mapped() - .is_some_and(|mapped| is_forbidden_address(IpAddr::V4(mapped))) - } - } -} - -fn is_nat64(v6: std::net::Ipv6Addr) -> bool { - let s = v6.segments(); - s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0 -} - -/// Deprecated IPv4-compatible form `::a.b.c.d` (not `::` and not `::1`). -fn is_ipv4_compatible(v6: std::net::Ipv6Addr) -> bool { - let s = v6.segments(); - s[0] == 0 - && s[1] == 0 - && s[2] == 0 - && s[3] == 0 - && s[4] == 0 - && s[5] == 0 - && !(s[6] == 0 && (s[7] == 0 || s[7] == 1)) -} - /// Keep at most [`MAX_FEED_BYTES`] of a feed body. pub fn cap_feed_body(body: &[u8]) -> Vec { body.iter().take(MAX_FEED_BYTES).copied().collect() diff --git a/crates/librtbit/src/rss/monitor.rs b/crates/librtbit/src/rss/monitor.rs index d0a6bb70..13ec21dc 100644 --- a/crates/librtbit/src/rss/monitor.rs +++ b/crates/librtbit/src/rss/monitor.rs @@ -97,9 +97,12 @@ impl RssMonitor { return Ok(fetch::cap_feed_body(&bytes)); } - let parsed = reqwest::Url::parse(url)?; - let response = send_following_checked_redirects(client, parsed).await?; - read_capped_body(response).await + let _ = client; + let limits = crate::safe_fetch::FetchLimits { + max_bytes: fetch::MAX_FEED_BYTES, + ..Default::default() + }; + crate::safe_fetch::fetch_bytes(url, &limits).await } async fn check_feed( @@ -354,113 +357,9 @@ impl RssMonitor { } } -const MAX_REDIRECTS: usize = 3; - -/// Shared client. Redirects are not followed here: each hop is checked and -/// pinned by [`client_pinned_to_checked_address`] before the next request. +/// Unused handle kept for the call shape; fetches go through [`crate::safe_fetch`]. fn pinned_client() -> Result { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) - .build() -} - -/// Resolve `url`, refuse any non-public address, and return a client that -/// connects to one of the addresses that passed the check. A later lookup -/// cannot substitute a private address (DNS rebinding). -async fn client_pinned_to_checked_address( - base: &reqwest::Client, - url: &reqwest::Url, -) -> anyhow::Result { - let _ = base; - let host = url - .host_str() - .ok_or_else(|| anyhow::anyhow!("feed URL has no host"))?; - let port = url.port_or_known_default().unwrap_or(80); - let vetted = vetted_addresses(host, port).await?; - let mut builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - for addr in vetted { - builder = builder.resolve(host, std::net::SocketAddr::new(addr, port)); - } - Ok(builder.build()?) -} - -async fn vetted_addresses(host: &str, port: u16) -> anyhow::Result> { - if let Ok(ip) = host.parse::() { - fetch::assert_public_addresses(&[ip]).map_err(|e| anyhow::anyhow!("{e}"))?; - return Ok(vec![ip]); - } - let addresses = tokio::net::lookup_host((host, port)) - .await - .map_err(|e| anyhow::anyhow!("could not resolve feed host {host}: {e}"))? - .map(|addr| addr.ip()) - .collect::>(); - fetch::assert_public_addresses(&addresses).map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(addresses) -} - -/// Refuse a declared Content-Length over the cap, then stream the body and -/// stop once the running total exceeds it. The whole response is never buffered -/// first. -async fn read_capped_body(response: reqwest::Response) -> anyhow::Result> { - // content_length() is the body size hint, which a chunked or hostile - // response can omit or understate. The declared header is checked on its own. - let declared = response - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - if let Some(len) = declared - && len > fetch::MAX_FEED_BYTES as u64 - { - return Err(anyhow::anyhow!( - "feed Content-Length {len} exceeds the {max} byte cap", - max = fetch::MAX_FEED_BYTES - )); - } - let mut body = Vec::new(); - let mut response = response; - while let Some(chunk) = response.chunk().await? { - if body.len().saturating_add(chunk.len()) > fetch::MAX_FEED_BYTES { - return Err(anyhow::anyhow!( - "feed body exceeds the {max} byte cap", - max = fetch::MAX_FEED_BYTES - )); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -/// Follow at most [`MAX_REDIRECTS`] hops. Every hop is address-checked and -/// pinned the same way as the first request. -async fn send_following_checked_redirects( - client: &reqwest::Client, - url: reqwest::Url, -) -> anyhow::Result { - let mut current = url; - for _ in 0..=MAX_REDIRECTS { - let pinned = client_pinned_to_checked_address(client, ¤t).await?; - let response = pinned.get(current.clone()).send().await?; - if response.status().is_redirection() { - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| anyhow::anyhow!("redirect with no Location header"))?; - current = current - .join(location) - .map_err(|_| anyhow::anyhow!("redirect Location is not a URL"))?; - fetch::check_feed_url(current.as_str()).map_err(|e| anyhow::anyhow!("{e}"))?; - continue; - } - return Ok(response); - } - Err(anyhow::anyhow!( - "feed redirected more than {MAX_REDIRECTS} times" - )) + reqwest::Client::builder().build() } fn file_url_to_path(url: &str) -> anyhow::Result { @@ -513,44 +412,4 @@ mod tests { ); } } - - #[tokio::test] - async fn an_oversized_content_length_is_refused_without_reading_the_body() { - let mut response = reqwest::Response::from( - http::Response::builder() - .status(200) - .body(reqwest::Body::from("not-read")) - .unwrap(), - ); - response.headers_mut().insert( - http::header::CONTENT_LENGTH, - http::HeaderValue::from_str(&(fetch::MAX_FEED_BYTES as u64 + 1).to_string()).unwrap(), - ); - let err = read_capped_body(response).await.unwrap_err(); - assert!(err.to_string().contains("Content-Length"), "{err}"); - } - - #[tokio::test] - async fn a_body_that_grows_past_the_cap_is_refused() { - let chunk = vec![b'a'; 64 * 1024]; - let parts = std::iter::repeat_n(chunk, 40).map(Ok::<_, std::io::Error>); - let response = http::Response::builder() - .status(200) - .body(reqwest::Body::wrap_stream(futures::stream::iter(parts))) - .unwrap(); - let response = reqwest::Response::from(response); - let err = read_capped_body(response).await.unwrap_err(); - assert!(err.to_string().contains("exceeds"), "{err}"); - } - - #[tokio::test] - async fn a_small_body_is_returned_whole() { - let response = http::Response::builder() - .status(200) - .body(reqwest::Body::from("")) - .unwrap(); - let response = reqwest::Response::from(response); - let body = read_capped_body(response).await.unwrap(); - assert_eq!(body, b""); - } } diff --git a/crates/librtbit/src/safe_fetch.rs b/crates/librtbit/src/safe_fetch.rs new file mode 100644 index 00000000..654b949e --- /dev/null +++ b/crates/librtbit/src/safe_fetch.rs @@ -0,0 +1,349 @@ +//! The one fetcher for server-side requests to URLs that came from outside +//! (torrent URL adds, RSS feeds, blocklists). +//! +//! DNS is resolved once per hop, every resolved address must be public unless +//! the host (or literal IP) is on the allowlist, and the connection is pinned +//! to the vetted addresses so a second lookup cannot swap in a private one. +//! Redirects are followed manually (at most [`MAX_REDIRECTS`]), each hop +//! re-checked. Body size and total time are capped. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::RwLock; +use std::time::Duration; + +use anyhow::{Context, bail}; +use reqwest::{Client, Response, Url, header}; + +pub const MAX_REDIRECTS: usize = 5; +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024; + +/// Hosts and literal IPs that may be fetched even when they resolve to a +/// non-public address. Empty by default. +static ALLOWLIST: RwLock> = RwLock::new(Vec::new()); + +/// Replace the allowlist of hosts/IPs exempt from the non-public address check. +pub fn set_allowlist(entries: impl IntoIterator) { + let list = entries + .into_iter() + .map(|e| e.to_ascii_lowercase()) + .collect(); + *ALLOWLIST.write().unwrap_or_else(|e| e.into_inner()) = list; +} + +fn is_allowlisted(host: &str) -> bool { + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + ALLOWLIST + .read() + .unwrap_or_else(|e| e.into_inner()) + .contains(&host) +} + +#[derive(Debug, Clone)] +pub struct FetchLimits { + pub timeout: Duration, + pub max_bytes: usize, + pub max_redirects: usize, +} + +impl Default for FetchLimits { + fn default() -> Self { + Self { + timeout: DEFAULT_TIMEOUT, + max_bytes: DEFAULT_MAX_BYTES, + max_redirects: MAX_REDIRECTS, + } + } +} + +/// True for loopback, private, link-local, documentation, broadcast and other +/// addresses a feed must never be fetched from. +pub fn is_forbidden_address(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let [a, b, ..] = v4.octets(); + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() + || v4.is_multicast() + // "This network" 0.0.0.0/8 (is_unspecified covers only 0.0.0.0). + || a == 0 + // Carrier-grade NAT, 100.64.0.0/10. + || (a == 100 && (64..128).contains(&b)) + // Benchmarking 198.18.0.0/15. + || (a == 198 && (b == 18 || b == 19)) + // Reserved for future use, 240.0.0.0/4. + || a >= 240 + } + IpAddr::V6(v6) => { + let seg0 = v6.segments()[0]; + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // Unique local fc00::/7 and link local fe80::/10. + || (seg0 & 0xfe00) == 0xfc00 + || (seg0 & 0xffc0) == 0xfe80 + // NAT64 well-known prefix 64:ff9b::/96. + || is_nat64(v6) + // IPv4-compatible ::a.b.c.d and 6to4 2002::/16. + || is_ipv4_compatible(v6) + || seg0 == 0x2002 + || v6 + .to_ipv4_mapped() + .is_some_and(|mapped| is_forbidden_address(IpAddr::V4(mapped))) + } + } +} + +fn is_nat64(v6: std::net::Ipv6Addr) -> bool { + let s = v6.segments(); + s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0 +} + +/// Deprecated IPv4-compatible form `::a.b.c.d` (not `::` and not `::1`). +fn is_ipv4_compatible(v6: std::net::Ipv6Addr) -> bool { + let s = v6.segments(); + s[0] == 0 + && s[1] == 0 + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + && !(s[6] == 0 && (s[7] == 0 || s[7] == 1)) +} + +/// Resolve the host once and return the addresses it may be connected to. +async fn vet_host(url: &Url) -> anyhow::Result<(String, u16, Vec)> { + if !matches!(url.scheme(), "http" | "https") { + bail!("URL must use http or https"); + } + let host = url.host_str().context("URL has no host")?.to_string(); + let port = url.port_or_known_default().unwrap_or(80); + let bare = host.trim_matches(['[', ']']); + let addrs: Vec = if let Ok(ip) = bare.parse::() { + vec![ip] + } else { + tokio::net::lookup_host((bare, port)) + .await + .with_context(|| format!("could not resolve {host}"))? + .map(|a| a.ip()) + .collect() + }; + if addrs.is_empty() { + bail!("{host} resolved to no addresses"); + } + if !is_allowlisted(&host) && addrs.iter().any(|ip| is_forbidden_address(*ip)) { + bail!("{host} points at a private, loopback or link-local address"); + } + Ok((host, port, addrs)) +} + +/// Send a GET following at most `limits.max_redirects` checked redirects. +pub async fn get(url: &str, limits: &FetchLimits) -> anyhow::Result { + let mut current = Url::parse(url).context("invalid URL")?; + for _ in 0..=limits.max_redirects { + let (host, port, addrs) = vet_host(¤t).await?; + let mut builder = Client::builder() + .timeout(limits.timeout) + .redirect(reqwest::redirect::Policy::none()); + for ip in addrs { + builder = builder.resolve(&host, SocketAddr::new(ip, port)); + } + let response = builder.build()?.get(current.clone()).send().await?; + if response.status().is_redirection() { + let location = response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()) + .context("redirect with no Location header")?; + current = current + .join(location) + .context("redirect Location is not a URL")?; + continue; + } + return Ok(response); + } + bail!("redirected more than {} times", limits.max_redirects) +} + +/// Stream a response body, refusing it once it passes `max_bytes`. +pub async fn read_capped(mut response: Response, max_bytes: usize) -> anyhow::Result> { + if let Some(len) = response.content_length() + && len > max_bytes as u64 + { + bail!("Content-Length {len} exceeds the {max_bytes} byte cap"); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > max_bytes { + bail!("body exceeds the {max_bytes} byte cap"); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// GET `url` and return its body, enforcing every limit. Non-2xx is an error. +pub async fn fetch_bytes(url: &str, limits: &FetchLimits) -> anyhow::Result> { + let response = get(url, limits).await?; + if !response.status().is_success() { + bail!("GET {url} returned {}", response.status()); + } + read_capped(response, limits.max_bytes).await +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // The allowlist is process-wide, so tests that touch it run one at a time. + static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + async fn serve(response: String) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut s, _)) = listener.accept().await else { + return; + }; + let response = response.clone(); + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf).await; + let _ = s.write_all(response.as_bytes()).await; + }); + } + }); + port + } + + fn ok(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn redirect(to: &str) -> String { + format!( + "HTTP/1.1 302 Found\r\nLocation: {to}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + } + + async fn refused(url: &str) -> String { + let _g = SERIAL.lock().await; + set_allowlist([]); + format!( + "{:#}", + fetch_bytes(url, &FetchLimits::default()).await.unwrap_err() + ) + } + + #[tokio::test] + async fn refuses_loopback() { + assert!(refused("http://127.0.0.1:1/").await.contains("private")); + assert!(refused("http://localhost:1/").await.contains("private")); + assert!(refused("http://[::1]:1/").await.contains("private")); + } + + #[tokio::test] + async fn refuses_metadata_and_link_local() { + assert!( + refused("http://169.254.169.254/latest/meta-data") + .await + .contains("private") + ); + assert!(refused("http://[fe80::1]/").await.contains("private")); + } + + #[tokio::test] + async fn refuses_private_ranges() { + for u in [ + "http://10.0.0.1/", + "http://172.16.5.5/", + "http://192.168.1.1/", + "http://100.64.0.1/", + ] { + assert!(refused(u).await.contains("private"), "{u}"); + } + } + + #[tokio::test] + async fn refuses_non_http_scheme() { + assert!( + refused("file:///etc/passwd") + .await + .contains("http or https") + ); + } + + #[tokio::test] + async fn allowlisted_host_is_fetched() { + let _g = SERIAL.lock().await; + let port = serve(ok("hello")).await; + set_allowlist(["127.0.0.1".to_string()]); + let body = fetch_bytes( + &format!("http://127.0.0.1:{port}/"), + &FetchLimits::default(), + ) + .await + .unwrap(); + set_allowlist([]); + assert_eq!(body, b"hello"); + } + + #[tokio::test] + async fn redirect_to_metadata_is_refused() { + let _g = SERIAL.lock().await; + let port = serve(redirect("http://169.254.169.254/latest")).await; + set_allowlist(["127.0.0.1".to_string()]); + let err = fetch_bytes( + &format!("http://127.0.0.1:{port}/"), + &FetchLimits::default(), + ) + .await + .unwrap_err(); + set_allowlist([]); + assert!(format!("{err:#}").contains("private"), "{err:#}"); + } + + #[tokio::test] + async fn redirect_chain_is_capped() { + let _g = SERIAL.lock().await; + let port = serve(redirect("/again")).await; + set_allowlist(["127.0.0.1".to_string()]); + let err = fetch_bytes( + &format!("http://127.0.0.1:{port}/"), + &FetchLimits::default(), + ) + .await + .unwrap_err(); + set_allowlist([]); + assert!( + format!("{err:#}").contains("redirected more than 5"), + "{err:#}" + ); + } + + #[tokio::test] + async fn oversized_body_is_refused() { + let _g = SERIAL.lock().await; + let port = serve(ok("0123456789")).await; + set_allowlist(["127.0.0.1".to_string()]); + let limits = FetchLimits { + max_bytes: 4, + ..Default::default() + }; + let err = fetch_bytes(&format!("http://127.0.0.1:{port}/"), &limits) + .await + .unwrap_err(); + set_allowlist([]); + assert!(format!("{err:#}").contains("byte cap"), "{err:#}"); + } +} diff --git a/crates/librtbit/src/session/helpers.rs b/crates/librtbit/src/session/helpers.rs index be66ed65..df9d7a1b 100644 --- a/crates/librtbit/src/session/helpers.rs +++ b/crates/librtbit/src/session/helpers.rs @@ -27,19 +27,11 @@ pub(super) async fn torrent_from_url( return torrent_from_bytes(bytes.into()).context("error decoding torrent"); } - let response = reqwest_client - .get(url) - .send() + let _ = reqwest_client; + let b = crate::safe_fetch::fetch_bytes(url, &crate::safe_fetch::FetchLimits::default()) .await - .context("error downloading torrent metadata")?; - if !response.status().is_success() { - bail!("GET {} returned {}", url, response.status()) - } - let b = response - .bytes() - .await - .with_context(|| format!("error reading response body from {url}"))?; - torrent_from_bytes(b).context("error decoding torrent") + .with_context(|| format!("error downloading torrent metadata from {url}"))?; + torrent_from_bytes(b.into()).context("error decoding torrent") } pub(super) fn compute_only_files_regex>( diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index e66c4de7..2979c98f 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -176,6 +176,7 @@ pub struct Session { pub ratelimits: Limits, pub(crate) peer_limit_atomic: AtomicUsize, pub(crate) concurrent_init_limit_atomic: AtomicUsize, + pub(crate) recheck_throttle: Arc, pub blocklist: parking_lot::RwLock, /// Last text submitted to the IP filter. Not a secret. @@ -326,9 +327,24 @@ impl Session { .load(std::sync::atomic::Ordering::Relaxed) } + /// Resize the concurrent init/recheck gate. Growing adds permits now; + /// shrinking takes permits as running checks release them. pub fn set_concurrent_init_limit(&self, limit: usize) { - self.concurrent_init_limit_atomic - .store(limit, std::sync::atomic::Ordering::Relaxed); + let old = self + .concurrent_init_limit_atomic + .swap(limit, std::sync::atomic::Ordering::Relaxed); + if limit > old { + self.concurrent_initialize_semaphore + .add_permits(limit - old); + } else if limit < old { + let sem = self.concurrent_initialize_semaphore.clone(); + let shrink = u32::try_from(old - limit).unwrap_or(u32::MAX); + tokio::spawn(async move { + if let Ok(p) = sem.acquire_many(shrink).await { + p.forget(); + } + }); + } } /// Create a new session with options. @@ -596,6 +612,7 @@ impl Session { .context("error creating stream connector")?, ); + crate::safe_fetch::set_allowlist(opts.fetch_allowlist.clone()); let blocklist = if let Some(blocklist_url) = opts.blocklist_url { info!(url = blocklist_url, "loading p2p blocklist"); let bl = IpRanges::load_from_url(&blocklist_url) @@ -672,7 +689,13 @@ impl Session { }; let resolved_peer_limit = opts.peer_limit.unwrap_or(200); - let resolved_concurrent_init_limit = opts.concurrent_init_limit.unwrap_or(3); + let resolved_concurrent_init_limit = saved_advanced + .concurrent_recheck_limit + .and_then(|v| usize::try_from(v).ok()) + .or(opts.concurrent_init_limit) + .unwrap_or(crate::session_persistence::DEFAULT_CONCURRENT_RECHECK_LIMIT); + let recheck_throttle = Arc::new(crate::file_ops::ReadThrottle::default()); + recheck_throttle.set_mibps(saved_advanced.recheck_read_mibps.or(opts.recheck_read_mibps)); let session = Arc::new(Self { persistence, @@ -733,6 +756,7 @@ impl Session { ratelimits: Limits::new(opts.ratelimits), peer_limit_atomic: AtomicUsize::new(resolved_peer_limit), concurrent_init_limit_atomic: AtomicUsize::new(resolved_concurrent_init_limit), + recheck_throttle, ipv4_only: opts.ipv4_only, trackers: opts.trackers, disable_trackers: opts.disable_trackers, @@ -755,7 +779,7 @@ impl Session { on_complete: RwLock::new(OnCompleteHook::default()), max_active_downloads: AtomicUsize::new(max_active_downloads), max_active_seeds: AtomicUsize::new(max_active_seeds), - min_free_disk_space: std::sync::atomic::AtomicU64::new(0), + min_free_disk_space: std::sync::atomic::AtomicU64::new(scheduler::DEFAULT_MIN_FREE_DISK_SPACE), free_space_provider: RwLock::new(None), use_part_files: std::sync::atomic::AtomicBool::new(use_part_files), seed_limits: RwLock::new(seed_limits), @@ -941,6 +965,13 @@ impl Session { ), Ordering::Relaxed, ); + if let Some(limit) = settings + .concurrent_recheck_limit + .and_then(|v| usize::try_from(v).ok()) + { + self.set_concurrent_init_limit(limit); + } + self.recheck_throttle.set_mibps(settings.recheck_read_mibps); self.magnet_resolution_timeout_secs.store( settings .magnet_resolution_timeout_secs @@ -1293,6 +1324,21 @@ impl Session { opts.only_files_regex, opts.list_only, )?; + let file_count = metadata.info.iter_file_lengths().count(); + let mut file_priority_levels = opts + .file_priority_levels + .take() + .unwrap_or_else(|| vec![1; file_count]); + if file_priority_levels.len() != file_count { + file_priority_levels.resize(file_count, 1); + } + if let Some(only_files) = &only_files { + for (idx, priority) in file_priority_levels.iter_mut().enumerate() { + if !only_files.contains(&idx) { + *priority = 0; + } + } + } let default_output_folder = expand_folder_template(&self.output_folder.read().clone(), opts.category.as_deref()); @@ -1399,6 +1445,9 @@ impl Session { tags: RwLock::new(crate::tag::normalize_tags(&opts.tags)), ratelimit_override: RwLock::new(opts.ratelimits), name_override: RwLock::new(None), + comment_override: RwLock::new(None), + auto_management: std::sync::atomic::AtomicBool::new(false), + super_seeding: std::sync::atomic::AtomicBool::new(false), output_folder_override: RwLock::new(output_folder), tracker_status, added_on: opts.added_on.unwrap_or_else(|| { @@ -1427,6 +1476,7 @@ impl Session { minfo.clone(), metadata.clone(), only_files.clone(), + file_priority_levels.clone(), self.spawner .block_in_place(|| minfo.storage_factory.create_and_init(&minfo, &metadata))?, false, @@ -1436,6 +1486,7 @@ impl Session { paused: opts.paused, state: ManagedTorrentState::Initializing(initializing), only_files, + file_priority_levels, }), state_change_notify: Notify::new(), shared: minfo, @@ -2378,7 +2429,7 @@ mod tests { "semaphore should have 5 permits" ); - // Default (no explicit limit) should give 3 permits. + // Default (no explicit limit) should give 2 permits. let session_default = super::Session::new_with_opts( tempdir.path().to_owned(), SessionOptions { @@ -2395,8 +2446,8 @@ mod tests { session_default .concurrent_initialize_semaphore .available_permits(), - 3, - "default semaphore should have 3 permits" + 2, + "default semaphore should have 2 permits" ); } @@ -2444,4 +2495,55 @@ mod tests { "second acquire should have succeeded after permit was dropped" ); } + + /// Five checkers contending for the gate with a cap of 2 never run more + /// than 2 at once; the limit can be resized live. + #[tokio::test] + async fn test_recheck_gate_caps_concurrency_and_resizes() { + use crate::SessionOptions; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let tempdir = tempfile::TempDir::new().unwrap(); + let session = super::Session::new_with_opts( + tempdir.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_trackers: true, + disable_local_service_discovery: true, + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(session.get_concurrent_init_limit(), 2); + + async fn run_five(session: &std::sync::Arc) -> usize { + let running = std::sync::Arc::new(AtomicUsize::new(0)); + let peak = std::sync::Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); + for _ in 0..5 { + let sem = session.concurrent_initialize_semaphore.clone(); + let (running, peak) = (running.clone(), peak.clone()); + tasks.push(tokio::spawn(async move { + let _p = sem.acquire().await.unwrap(); + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(30)).await; + running.fetch_sub(1, Ordering::SeqCst); + })); + } + for t in tasks { + t.await.unwrap(); + } + peak.load(Ordering::SeqCst) + } + + assert_eq!(run_five(&session).await, 2); + session.set_concurrent_init_limit(1); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(run_five(&session).await, 1); + session.set_concurrent_init_limit(4); + assert_eq!(run_five(&session).await, 4); + } } diff --git a/crates/librtbit/src/session/scheduler.rs b/crates/librtbit/src/session/scheduler.rs index 140db4e7..4f2373d2 100644 --- a/crates/librtbit/src/session/scheduler.rs +++ b/crates/librtbit/src/session/scheduler.rs @@ -115,6 +115,32 @@ pub(crate) enum ShareLimitReason { /// `seeding_secs` is zero when the completion time is unknown, which disables /// the seeding-time check rather than tripping it. `inactive_secs` is the time /// since the upload counter last moved, and zero likewise disables its check. +fn effective_share_limits_for_torrent( + session: &Session, + handle: &ManagedTorrentHandle, + own: ShareLimits, +) -> ShareLimits { + if !own.is_unset() { + return own; + } + let category = handle.shared().category.read().clone(); + category + .as_deref() + .and_then(|name| session.categories().get(name)) + .map(|category| category.share_limits()) + .map(|category| { + let global = *session.seed_limits.read(); + ShareLimits { + max_ratio: category.max_ratio.or(global.max_ratio), + max_seeding_time: category.max_seeding_time.or(global.max_seeding_time), + max_inactive_seeding_time: category + .max_inactive_seeding_time + .or(global.max_inactive_seeding_time), + } + }) + .unwrap_or_else(|| *session.seed_limits.read()) +} +#[cfg(test)] pub(crate) fn effective_share_limits(own: ShareLimits, global: ShareLimits) -> ShareLimits { if own.is_unset() { global } else { own } } @@ -298,6 +324,9 @@ fn os_free_disk_space(path: &Path) -> Option { Some(stat.blocks_available() as u64 * stat.fragment_size() as u64) } +/// Default free-space floor for the low-disk guard: 1 GiB. +pub(crate) const DEFAULT_MIN_FREE_DISK_SPACE: u64 = 1 << 30; + /// Stand-in free-space reading for tests. `None` means "use the OS". pub(crate) type FreeSpaceFn = fn(&Path) -> Option; @@ -513,7 +542,7 @@ impl Session { && !handle.is_paused() { let own = *handle.shared().share_limits.read(); - let limits = effective_share_limits(own, *self.seed_limits.read()); + let limits = effective_share_limits_for_torrent(self, handle, own); let completed_at = handle.shared().completion_on.load(Ordering::Relaxed); let seeding_secs = if completed_at > 0 { now.saturating_sub(completed_at) @@ -647,6 +676,17 @@ mod tests { assert_eq!(effective_share_limits(own, global), own); } + #[test] + fn category_limits_fill_unset_global_fields() { + let category = limits(Some(1.5), Some(30)); + let global = limits(Some(2.0), Some(60)); + assert_eq!(category.max_ratio.or(global.max_ratio), Some(1.5)); + assert_eq!( + category.max_seeding_time.or(global.max_seeding_time), + Some(Duration::from_secs(30)) + ); + } + #[test] fn no_limits_never_triggers() { assert_eq!( diff --git a/crates/librtbit/src/session/types.rs b/crates/librtbit/src/session/types.rs index 9ad8e023..7af6d5ed 100644 --- a/crates/librtbit/src/session/types.rs +++ b/crates/librtbit/src/session/types.rs @@ -115,6 +115,8 @@ pub struct AddTorrentOptions { /// An explicit list of file IDs to download. /// To see the file indices, run with "list_only". pub only_files: Option>, + #[serde(skip)] + pub file_priority_levels: Option>, /// Allow writing on top of existing files, including when resuming a torrent. /// You probably want to set it, however for safety it's not default. /// @@ -340,6 +342,10 @@ pub struct SessionOptions { /// how many concurrent torrent initializations can happen pub concurrent_init_limit: Option, + /// Cap on the combined disk read rate of checks, in MiB/s. None is unlimited. + /// A saved Advanced setting wins over this. + pub recheck_read_mibps: Option, + /// How many blocking threads does the tokio runtime have. /// Will limit blocking work to that number to avoid starving the runtime. pub runtime_worker_threads: Option, @@ -352,6 +358,11 @@ pub struct SessionOptions { pub blocklist_url: Option, pub allowlist_url: Option, + /// Hosts or IPs that server-side URL fetches (torrent URL adds, RSS, + /// blocklists) may reach even when they resolve to a private, loopback or + /// link-local address. Empty by default. + pub fetch_allowlist: Vec, + // The list of tracker URLs to always use for each torrent. pub trackers: std::collections::HashSet, diff --git a/crates/librtbit/src/session_persistence/json.rs b/crates/librtbit/src/session_persistence/json.rs index bfaa81c8..be3f4e6d 100644 --- a/crates/librtbit/src/session_persistence/json.rs +++ b/crates/librtbit/src/session_persistence/json.rs @@ -62,7 +62,13 @@ impl JsonSessionPersistenceStore { let mut rdr = tokio::io::BufReader::new(f); rdr.read_to_end(&mut buf).await?; - serde_json::from_reader(&buf[..]).context("error deserializing session database")? + match serde_json::from_slice(&buf) { + Ok(db) => db, + Err(e) => { + quarantine_corrupt(&db_filename, &e)?; + Default::default() + } + } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(), Err(e) => { @@ -106,19 +112,14 @@ impl JsonSessionPersistenceStore { trace!(?tmp_filename, "serialized DB as JSON"); // The database now holds the proxy URL, including its password, so the // file is owner-only the same way the other private config files are. - write_owner_only(&tmp_filename, &buf) - .with_context(|| format!("error writing {tmp_filename:?}"))?; - trace!(?tmp_filename, "wrote to temp file"); - - tokio::fs::rename(&tmp_filename, &self.db_filename) - .await - .context("error renaming persistence file")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&self.db_filename, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("error restricting {:?}", self.db_filename))?; - } + atomic_write( + std::path::Path::new(&tmp_filename), + &self.db_filename, + &buf, + true, + ) + .await + .with_context(|| format!("error writing {:?}", self.db_filename))?; trace!(filename=?self.db_filename, "wrote persistence"); Ok(()) } @@ -170,6 +171,7 @@ impl JsonSessionPersistenceStore { // we don't serialize this here, but to a file instead. torrent_bytes: Default::default(), only_files: torrent.only_files().clone(), + file_priority_levels: Some(torrent.file_priority_levels()), is_paused: torrent.is_paused(), queued: policy.queued, held_for_disk: policy.held_for_disk, @@ -187,6 +189,15 @@ impl JsonSessionPersistenceStore { file_renames: torrent.renamed_files().into_iter().collect(), category: torrent.shared().category.read().clone(), tags: torrent.shared().tags.read().clone(), + comment: torrent.shared().comment_override.read().clone(), + auto_management: torrent + .shared() + .auto_management + .load(std::sync::atomic::Ordering::Relaxed), + super_seeding: torrent + .shared() + .super_seeding + .load(std::sync::atomic::Ordering::Relaxed), added_on: Some(torrent.shared().added_on), completion_on: match torrent .shared() @@ -276,20 +287,10 @@ impl BitVFactory for JsonSessionPersistenceStore { // Use a distinct .init_tmp suffix from the DiskBackedBitV flusher (which uses .bitv.tmp) // to avoid a race where an old flusher's rename steals our temp file before we rename it. let tmp_filename = format!("{}.init_tmp", filename.to_str().context("bug")?); - let mut dst = tokio::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; let data = crate::bitv::bitv_with_header(b.as_raw_slice()); - dst.write_all(&data) - .await - .with_context(|| format!("error writing bitslice to {tmp_filename:?}"))?; - tokio::fs::rename(&tmp_filename, &filename) + atomic_write(std::path::Path::new(&tmp_filename), &filename, &data, false) .await - .with_context(|| format!("error renaming {tmp_filename:?} to {filename:?}"))?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(?filename, "stored initial check bitfield"); Ok(DiskBackedBitV::new(filename.clone(), self.spawner.clone()) .await @@ -408,9 +409,13 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { let mut buf = Vec::new(); let mut rdr = tokio::io::BufReader::new(f); rdr.read_to_end(&mut buf).await?; - let categories: HashMap = - serde_json::from_reader(&buf[..]).context("error deserializing categories")?; - Ok(categories) + match serde_json::from_slice(&buf) { + Ok(categories) => Ok(categories), + Err(e) => { + quarantine_corrupt(&filename, &e)?; + Ok(HashMap::new()) + } + } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), Err(e) => Err(e).context(format!("error opening categories file {filename:?}")), @@ -422,26 +427,11 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { categories: &HashMap, ) -> anyhow::Result<()> { let filename = self.categories_filename(); - let tmp_filename = format!("{}.tmp", filename.to_str().unwrap()); - let mut tmp = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; - - let mut buf = Vec::new(); - serde_json::to_writer_pretty(&mut buf, categories) - .context("error serializing categories")?; - - tmp.write_all(&buf) - .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - - tokio::fs::rename(&tmp_filename, &filename) + let tmp_filename = filename.with_extension("json.tmp"); + let buf = serde_json::to_vec_pretty(categories).context("error serializing categories")?; + atomic_write(&tmp_filename, &filename, &buf, false) .await - .context("error renaming categories file")?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(filename=?filename, "wrote categories"); Ok(()) } @@ -449,7 +439,13 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { async fn load_tags(&self) -> anyhow::Result> { let filename = self.tags_filename(); match tokio::fs::read(&filename).await { - Ok(buf) => serde_json::from_slice(&buf).context("error deserializing tags"), + Ok(buf) => match serde_json::from_slice(&buf) { + Ok(tags) => Ok(tags), + Err(e) => { + quarantine_corrupt(&filename, &e)?; + Ok(BTreeSet::new()) + } + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()), Err(e) => Err(e).context(format!("error reading tags file {filename:?}")), } @@ -459,35 +455,85 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { let filename = self.tags_filename(); let tmp_filename = filename.with_extension("json.tmp"); let buf = serde_json::to_vec_pretty(tags).context("error serializing tags")?; - tokio::fs::write(&tmp_filename, &buf) - .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - tokio::fs::rename(&tmp_filename, &filename) + atomic_write(&tmp_filename, &filename, &buf, false) .await - .context("error renaming tags file")?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(filename=?filename, "wrote tags"); Ok(()) } } -/// Write bytes to a new file that only the owner can read. Used for the session -/// database because it stores the proxy URL with its password. -fn write_owner_only(path: &str, bytes: &[u8]) -> anyhow::Result<()> { - if let Some(parent) = std::path::Path::new(path).parent() { - std::fs::create_dir_all(parent)?; - } - let _ = std::fs::remove_file(path); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(path)?; - use std::io::Write; - file.write_all(bytes)?; - file.sync_all()?; - Ok(()) +/// Crash-safe replace of `dest`: write `tmp`, fsync it, rename over `dest`, +/// then fsync the parent directory so the rename itself is durable. +async fn atomic_write( + tmp: &std::path::Path, + dest: &std::path::Path, + bytes: &[u8], + owner_only: bool, +) -> anyhow::Result<()> { + let (tmp, dest, bytes) = (tmp.to_owned(), dest.to_owned(), bytes.to_vec()); + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + use std::io::Write; + let _ = std::fs::remove_file(&tmp); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if owner_only { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let _ = owner_only; + let mut file = options + .open(&tmp) + .with_context(|| format!("error opening {tmp:?}"))?; + file.write_all(&bytes) + .with_context(|| format!("error writing {tmp:?}"))?; + file.sync_all() + .with_context(|| format!("error syncing {tmp:?}"))?; + drop(file); + std::fs::rename(&tmp, &dest) + .with_context(|| format!("error renaming {tmp:?} to {dest:?}"))?; + #[cfg(unix)] + if let Some(parent) = dest.parent() { + let parent = if parent.as_os_str().is_empty() { + std::path::Path::new(".") + } else { + parent + }; + std::fs::File::open(parent) + .and_then(|d| d.sync_all()) + .with_context(|| format!("error syncing directory {parent:?}"))?; + } + Ok(()) + }) + .await + .context("atomic write task failed")? +} + +/// Move an unparseable state file aside under a unique name. Refusing to +/// continue when that fails prevents the next save from destroying the only +/// copy of the previous state. +fn quarantine_corrupt( + path: &std::path::Path, + err: &dyn std::fmt::Display, +) -> anyhow::Result { + static QUARANTINE_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let mut name = path.as_os_str().to_owned(); + let id = QUARANTINE_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + name.push(format!(".corrupt.{timestamp}.{id}")); + let corrupt = PathBuf::from(name); + std::fs::rename(path, &corrupt).with_context(|| { + format!("corrupt state file ({err}); could not quarantine {path:?} as {corrupt:?}") + })?; + warn!( + ?path, + ?corrupt, + "corrupt state file ({err}); quarantined, starting with empty state" + ); + Ok(corrupt) } #[cfg(test)] @@ -520,6 +566,8 @@ mod tests { connect_timeout_secs: Some(15), upload_slots: Some(8), fastresume_validation_denom: Some(0), + concurrent_recheck_limit: Some(3), + recheck_read_mibps: Some(50), ..Default::default() }), }; @@ -575,6 +623,55 @@ mod tests { assert_eq!(again.load_session_settings().await.unwrap().proxy_url, None); } + #[tokio::test] + async fn truncated_state_files_are_quarantined() { + let dir = tempfile::tempdir().unwrap(); + let spawner = BlockingSpawner::new(1); + for name in ["tags.json", "categories.json", "session.json"] { + std::fs::write(dir.path().join(name), b"{\"trunc").unwrap(); + } + let store = JsonSessionPersistenceStore::new(dir.path().to_path_buf(), spawner) + .await + .unwrap(); + assert!(store.load_tags().await.unwrap().is_empty()); + assert!(store.load_categories().await.unwrap().is_empty()); + assert!(store.load_session_settings().await.is_ok()); + for name in ["tags.json", "categories.json", "session.json"] { + assert!(!dir.path().join(name).exists(), "{name} should be moved"); + assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&format!("{name}.corrupt.")) + })); + } + let tags: BTreeSet = ["a".to_owned()].into(); + store.store_tags(&tags).await.unwrap(); + assert_eq!(store.load_tags().await.unwrap(), tags); + } + + #[test] + fn corrupt_quarantine_failure_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.json"); + let error = quarantine_corrupt(&missing, &"invalid JSON").unwrap_err(); + assert!(error.to_string().contains("could not quarantine")); + } + + #[test] + fn corrupt_quarantines_do_not_replace_earlier_copies() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.json"); + std::fs::write(&path, "first").unwrap(); + let first = quarantine_corrupt(&path, &"invalid JSON").unwrap(); + std::fs::write(&path, "second").unwrap(); + let second = quarantine_corrupt(&path, &"invalid JSON").unwrap(); + assert_ne!(first, second); + assert_eq!(std::fs::read_to_string(first).unwrap(), "first"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "second"); + } + #[tokio::test] async fn json_store_persists_tag_registry() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/librtbit/src/session_persistence/missing_report.rs b/crates/librtbit/src/session_persistence/missing_report.rs new file mode 100644 index 00000000..ed6fc89e --- /dev/null +++ b/crates/librtbit/src/session_persistence/missing_report.rs @@ -0,0 +1,221 @@ +use std::{collections::HashMap, path::Path}; + +use anyhow::{Context, bail}; +use librtbit_core::torrent_metainfo::torrent_from_bytes; +use serde::{Deserialize, Serialize}; + +use super::SerializedTorrent; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MissingFileStatus { + Exists, + WrongSize { actual_size: u64 }, + Absent, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MissingTorrentDiagnosis { + PathProblem, + MissingPayload, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MissingFileReport { + pub path: std::path::PathBuf, + pub expected_size: u64, + pub parent_dir_exists: bool, + pub status: MissingFileStatus, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MissingTorrentReport { + pub id: usize, + pub info_hash: String, + pub name: String, + pub save_path: std::path::PathBuf, + pub diagnosis: MissingTorrentDiagnosis, + pub files: Vec, +} + +#[derive(Deserialize)] +struct SessionDatabase { + torrents: HashMap, +} + +pub async fn report_missing_files( + persistence_folder: &Path, +) -> anyhow::Result> { + let session_file = persistence_folder.join("session.json"); + let bytes = tokio::fs::read(&session_file) + .await + .with_context(|| format!("error reading session state {}", session_file.display()))?; + let database: SessionDatabase = serde_json::from_slice(&bytes) + .with_context(|| format!("error parsing session state {}", session_file.display()))?; + + let mut torrents = database.torrents.into_iter().collect::>(); + torrents.sort_by_key(|(id, _)| *id); + let mut reports = Vec::new(); + for (id, torrent) in torrents { + let info_hash = torrent.info_hash.as_string(); + let metainfo_file = persistence_folder.join(format!("{info_hash}.torrent")); + let metainfo_bytes = tokio::fs::read(&metainfo_file) + .await + .with_context(|| format!("error reading metainfo {}", metainfo_file.display()))?; + let metainfo = torrent_from_bytes(&metainfo_bytes) + .with_context(|| format!("error parsing metainfo {}", metainfo_file.display()))?; + let metainfo = metainfo + .info + .data + .validate() + .context("invalid torrent metainfo")?; + let name = metainfo + .name() + .unwrap_or_else(|| "".into()) + .into_owned(); + let mut files = Vec::new(); + let mut has_missing = false; + let mut has_missing_parent = false; + + for (file_id, details) in metainfo.iter_file_details().enumerate() { + if torrent + .only_files + .as_ref() + .is_some_and(|selected| !selected.contains(&file_id)) + { + continue; + } + let relative_path = torrent + .file_renames + .get(&file_id) + .cloned() + .unwrap_or_else(|| details.filename.to_pathbuf()); + if relative_path.is_absolute() { + bail!("saved file path is absolute: {}", relative_path.display()); + } + let path = torrent.output_folder.join(relative_path); + let parent_dir_exists = path.parent().is_some_and(Path::is_dir); + let status = match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() && metadata.len() == details.len => { + MissingFileStatus::Exists + } + Ok(metadata) if metadata.is_file() => MissingFileStatus::WrongSize { + actual_size: metadata.len(), + }, + Ok(_) => MissingFileStatus::WrongSize { actual_size: 0 }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + MissingFileStatus::Absent + } + Err(error) => { + return Err(error) + .with_context(|| format!("error inspecting {}", path.display())); + } + }; + if status != MissingFileStatus::Exists { + has_missing = true; + has_missing_parent |= !parent_dir_exists; + } + files.push(MissingFileReport { + path, + expected_size: details.len, + parent_dir_exists, + status, + }); + } + + if has_missing { + reports.push(MissingTorrentReport { + id, + info_hash, + name, + save_path: torrent.output_folder, + diagnosis: if has_missing_parent { + MissingTorrentDiagnosis::PathProblem + } else { + MissingTorrentDiagnosis::MissingPayload + }, + files, + }); + } + } + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CreateTorrentOptions, create_torrent, spawn_utils::BlockingSpawner}; + + #[tokio::test] + async fn reports_file_statuses_and_path_problem() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + for (name, size) in [ + ("exists", 4), + ("wrong", 5), + ("absent", 6), + ("nested/missing", 7), + ] { + let path = source.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, vec![b'x'; size]).unwrap(); + } + let torrent = create_torrent( + &source, + CreateTorrentOptions::default(), + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let metadata = torrent_from_bytes(&torrent).unwrap(); + let info_hash = metadata.info_hash.as_string(); + let persistence = temp.path().join("state"); + let payload = temp.path().join("payload"); + std::fs::create_dir_all(&persistence).unwrap(); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("exists"), b"xxxx").unwrap(); + std::fs::write(payload.join("wrong"), b"x").unwrap(); + std::fs::write(persistence.join(format!("{info_hash}.torrent")), &torrent).unwrap(); + let session = serde_json::json!({ + "torrents": { + "1": { + "info_hash": info_hash, + "trackers": [], + "output_folder": payload, + "only_files": null, + "is_paused": true + } + } + }); + std::fs::write( + persistence.join("session.json"), + serde_json::to_vec(&session).unwrap(), + ) + .unwrap(); + + let reports = report_missing_files(&persistence).await.unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].diagnosis, MissingTorrentDiagnosis::PathProblem); + assert_eq!(reports[0].files.len(), 4); + let status_for = |name: &str| { + reports[0] + .files + .iter() + .find(|file| file.path.ends_with(name)) + .unwrap() + }; + assert_eq!(status_for("absent").status, MissingFileStatus::Absent); + assert_eq!(status_for("exists").status, MissingFileStatus::Exists); + assert!(matches!( + status_for("wrong").status, + MissingFileStatus::WrongSize { actual_size: 1 } + )); + assert_eq!(status_for("missing").status, MissingFileStatus::Absent); + assert!(!status_for("missing").parent_dir_exists); + } +} diff --git a/crates/librtbit/src/session_persistence/mod.rs b/crates/librtbit/src/session_persistence/mod.rs index 32202f86..0271ac4a 100644 --- a/crates/librtbit/src/session_persistence/mod.rs +++ b/crates/librtbit/src/session_persistence/mod.rs @@ -1,4 +1,5 @@ pub mod json; +pub mod missing_report; #[cfg(feature = "postgres")] pub mod postgres; @@ -80,6 +81,11 @@ pub(crate) const MAX_MAX_CONCURRENT_HANDSHAKES: u64 = 512; pub(crate) const DEFAULT_MAX_CONCURRENT_HANDSHAKES: usize = 64; pub(crate) const MIN_FASTRESUME_VALIDATION_DENOM: u64 = 0; pub(crate) const MAX_FASTRESUME_VALIDATION_DENOM: u64 = 1000; +pub(crate) const MIN_CONCURRENT_RECHECK_LIMIT: u64 = 1; +pub(crate) const MAX_CONCURRENT_RECHECK_LIMIT: u64 = 32; +pub(crate) const DEFAULT_CONCURRENT_RECHECK_LIMIT: usize = 2; +pub(crate) const MIN_RECHECK_READ_MIBPS: u64 = 1; +pub(crate) const MAX_RECHECK_READ_MIBPS: u64 = 100_000; /// Advanced-tab knobs. Each absent field is at its default (the engine /// default, or the startup option where there is one). @@ -101,6 +107,13 @@ pub struct AdvancedSettings { /// Read only at start. `Some(0)` skips probabilistic fastresume validation. #[serde(default, skip_serializing_if = "Option::is_none")] pub fastresume_validation_denom: Option, + /// How many torrents may hash (initial check or recheck) at once. Live. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub concurrent_recheck_limit: Option, + /// Cap on the combined disk read rate of all checks, in MiB/s. Absent is + /// unlimited. Live. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recheck_read_mibps: Option, } impl AdvancedSettings { @@ -163,6 +176,18 @@ impl AdvancedSettings { MIN_FASTRESUME_VALIDATION_DENOM, MAX_FASTRESUME_VALIDATION_DENOM, ); + keep( + "concurrent_recheck_limit", + &mut self.concurrent_recheck_limit, + MIN_CONCURRENT_RECHECK_LIMIT, + MAX_CONCURRENT_RECHECK_LIMIT, + ); + keep( + "recheck_read_mibps", + &mut self.recheck_read_mibps, + MIN_RECHECK_READ_MIBPS, + MAX_RECHECK_READ_MIBPS, + ); self } } @@ -203,6 +228,8 @@ pub struct SerializedTorrent { #[serde(default, skip_serializing_if = "Option::is_none")] output_folder_root: Option, only_files: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + file_priority_levels: Option>, is_paused: bool, #[serde(default, skip_serializing_if = "Option::is_none")] category: Option, @@ -248,6 +275,12 @@ pub struct SerializedTorrent { /// means the paths from the info dict. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] file_renames: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + auto_management: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + super_seeding: bool, } /// The per-torrent speed limits kept in session state. `None` is unlimited. @@ -299,6 +332,9 @@ impl SerializedTorrent { force_start: self.force_start, sequential_download: self.sequential_download, first_last_piece_priority: self.first_last_piece_priority, + comment: self.comment.clone(), + auto_management: self.auto_management, + super_seeding: self.super_seeding, } } @@ -350,6 +386,7 @@ impl SerializedTorrent { output_folder, output_folder_root, only_files: self.only_files, + file_priority_levels: self.file_priority_levels, overwrite: true, category, tags, @@ -377,6 +414,9 @@ pub(crate) struct RestoredState { force_start: bool, sequential_download: bool, first_last_piece_priority: bool, + comment: Option, + auto_management: bool, + super_seeding: bool, } impl RestoredState { @@ -402,6 +442,13 @@ impl RestoredState { self.first_last_piece_priority, std::sync::atomic::Ordering::Relaxed, ); + *shared.comment_override.write() = self.comment; + shared + .auto_management + .store(self.auto_management, std::sync::atomic::Ordering::Relaxed); + shared + .super_seeding + .store(self.super_seeding, std::sync::atomic::Ordering::Relaxed); } } diff --git a/crates/librtbit/src/session_persistence/postgres.rs b/crates/librtbit/src/session_persistence/postgres.rs index 23237c70..0c0e09a3 100644 --- a/crates/librtbit/src/session_persistence/postgres.rs +++ b/crates/librtbit/src/session_persistence/postgres.rs @@ -81,9 +81,13 @@ impl TorrentsTableRecord { only_files: self .only_files .map(|v| v.into_iter().map(|v| v as usize).collect()), + file_priority_levels: None, is_paused: self.is_paused, category: None, tags: Default::default(), + comment: None, + auto_management: false, + super_seeding: false, added_on: None, completion_on: None, queued: self.queued, diff --git a/crates/librtbit/src/tests/policy_pass.rs b/crates/librtbit/src/tests/policy_pass.rs index 4a197080..9722196c 100644 --- a/crates/librtbit/src/tests/policy_pass.rs +++ b/crates/librtbit/src/tests/policy_pass.rs @@ -215,6 +215,11 @@ async fn disk_hold_survives_restart_and_releases() -> anyhow::Result<()> { session.policy_pass().await; assert!(download.is_paused()); assert!(download.shared().held_for_disk.load(Ordering::Relaxed)); + assert_eq!( + download.stats().error.as_deref(), + Some("low disk space"), + "native status must name the hold" + ); session.stop().await; drop(session); diff --git a/crates/librtbit/src/tests/read_only_storage.rs b/crates/librtbit/src/tests/read_only_storage.rs index c6c6e567..a4643140 100644 --- a/crates/librtbit/src/tests/read_only_storage.rs +++ b/crates/librtbit/src/tests/read_only_storage.rs @@ -709,11 +709,15 @@ async fn read_only_incomplete_payload_stays_stopped_and_flagged() -> anyhow::Res ); assert!(handle.is_paused(), "{what}: not marked paused"); assert!(!stats.finished, "{what}"); - assert_eq!( - stats.error.as_deref(), - Some(crate::torrent_state::READ_ONLY_INCOMPLETE_PAYLOAD), + let error = stats.error.as_deref().unwrap_or_default(); + assert!( + crate::torrent_state::is_read_only_incomplete_payload(error), "{what}: not flagged" ); + assert!( + error.contains(": "), + "{what}: error does not name the first missing path: {error}" + ); assert!(stats.live.is_none(), "{what}: went live"); }; assert_stopped_and_flagged("after add"); diff --git a/crates/librtbit/src/torrent_state/initializing.rs b/crates/librtbit/src/torrent_state/initializing.rs index f6fbbc0c..3c955c12 100644 --- a/crates/librtbit/src/torrent_state/initializing.rs +++ b/crates/librtbit/src/torrent_state/initializing.rs @@ -30,6 +30,7 @@ pub struct TorrentStateInitializing { pub(crate) shared: Arc, pub(crate) metadata: Arc, pub(crate) only_files: Option>, + pub(crate) file_priority_levels: Vec, pub(crate) checked_bytes: AtomicU64, /// True while the torrent is waiting for a concurrent init semaphore slot. pub(crate) queued_for_init: AtomicBool, @@ -44,6 +45,7 @@ impl TorrentStateInitializing { shared: Arc, metadata: Arc, only_files: Option>, + file_priority_levels: Vec, files: FileStorage, previously_errored: bool, ) -> Self { @@ -51,6 +53,7 @@ impl TorrentStateInitializing { shared, metadata, only_files, + file_priority_levels, files, checked_bytes: AtomicU64::new(0), queued_for_init: AtomicBool::new(false), @@ -236,12 +239,23 @@ impl TorrentStateInitializing { None => { info!("Doing initial checksum validation, this might take a while..."); let token = cancellation_token.clone(); + let recheck_throttle = self + .shared + .session + .upgrade() + .context("session is dead")? + .recheck_throttle + .clone(); let have_pieces = self .shared .spawner .block_in_place_with_semaphore(|| { FileOps::new(&self.metadata.info, &self.files, &self.metadata.file_infos) - .initial_check(&self.checked_bytes, Some(&token)) + .initial_check( + &self.checked_bytes, + Some(&token), + Some(&recheck_throttle), + ) }) .await?; bitv_factory @@ -327,6 +341,7 @@ impl TorrentStateInitializing { metadata: self.metadata.clone(), files: self.files.take()?, chunk_tracker, + file_priority_levels: self.file_priority_levels.clone(), streams: Arc::new(Default::default()), }) } diff --git a/crates/librtbit/src/torrent_state/live/mod.rs b/crates/librtbit/src/torrent_state/live/mod.rs index 988b4162..34909858 100644 --- a/crates/librtbit/src/torrent_state/live/mod.rs +++ b/crates/librtbit/src/torrent_state/live/mod.rs @@ -115,6 +115,7 @@ pub(crate) struct TorrentStateLocked { // The sorted file list in which order to download them. pub(crate) file_priorities: FilePriorities, + pub(crate) file_priority_levels: Vec, // If this is None, then it was already used fatal_errors_tx: Option>, @@ -255,11 +256,14 @@ impl TorrentStateLive { let mut pri = (0..paused.metadata.file_infos.len()).collect::>(); // sort by filename, cause many torrents have random sort order. pri.sort_unstable_by_key(|id| { - paused - .metadata - .file_infos - .get(*id) - .map(|fi| fi.relative_filename.as_path()) + ( + std::cmp::Reverse(paused.file_priority_levels.get(*id).copied().unwrap_or(1)), + paused + .metadata + .file_infos + .get(*id) + .map(|fi| fi.relative_filename.as_path()), + ) }); pri }; @@ -286,6 +290,7 @@ impl TorrentStateLive { _locked: RwLock::new(TorrentStateLocked { pieces: Some(PieceTracker::new(paused.chunk_tracker)), file_priorities, + file_priority_levels: paused.file_priority_levels, fatal_errors_tx: Some(fatal_errors_tx), unflushed_bitv_bytes: 0, }), @@ -624,6 +629,7 @@ impl TorrentStateLive { metadata: self.metadata.clone(), files: self.files.take()?, chunk_tracker, + file_priority_levels: g.file_priority_levels.clone(), streams: self.streams.clone(), }) } @@ -652,6 +658,43 @@ impl TorrentStateLive { Ok(()) } + pub(crate) fn update_file_priority( + &self, + file_index: usize, + priority: u8, + ) -> anyhow::Result<()> { + let mut g = self.lock_write("update_file_priority"); + let file_count = self.metadata.file_infos.len(); + if file_index >= file_count { + anyhow::bail!("file index out of range"); + } + if let Some(level) = g.file_priority_levels.get_mut(file_index) { + *level = priority; + } + let levels = g.file_priority_levels.clone(); + g.file_priorities.sort_unstable_by_key(|id| { + ( + std::cmp::Reverse(levels.get(*id).copied().unwrap_or(1)), + self.metadata + .file_infos + .get(*id) + .map(|fi| fi.relative_filename.as_path()), + ) + }); + let selected: HashSet<_> = levels + .iter() + .enumerate() + .filter_map(|(id, level)| (*level != 0).then_some(id)) + .collect(); + let hns = g + .get_pieces_mut()? + .update_only_files(&self.metadata.file_infos, &selected)?; + if !hns.finished() { + self.reconnect_all_not_needed_peers(); + } + Ok(()) + } + // If we have all selected pieces but not necessarily all pieces. pub(crate) fn is_finished(&self) -> bool { self.get_hns().map(|h| h.finished()).unwrap_or_default() diff --git a/crates/librtbit/src/torrent_state/live/peers/mod.rs b/crates/librtbit/src/torrent_state/live/peers/mod.rs index f70acd93..1767b4f6 100644 --- a/crates/librtbit/src/torrent_state/live/peers/mod.rs +++ b/crates/librtbit/src/torrent_state/live/peers/mod.rs @@ -54,6 +54,22 @@ impl PeerStates { }) } + /// Number of live peers that have each piece, from their advertised bitfields. + pub fn piece_availability(&self, total_pieces: usize) -> Vec { + let mut counts = vec![0u32; total_pieces]; + for entry in self.states.iter() { + let Some(live) = entry.get_live() else { + continue; + }; + for (count, has) in counts.iter_mut().zip(live.bitfield.iter()) { + if *has { + *count += 1; + } + } + } + counts + } + pub fn add_if_not_seen(&self, addr: SocketAddr) -> Option { use dashmap::mapref::entry::Entry; match self.states.entry(addr) { @@ -248,6 +264,30 @@ mod tests { SocketAddr::new(Ipv4Addr::new(127, 0, 0, 1).into(), port) } + #[test] + fn test_piece_availability_counts_peer_bitfields() { + let ps = make_peer_states(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + for (port, bits) in [(1u16, 0b1010_0000u8), (2, 0b1100_0000)] { + let peer = Peer::new_live_for_incoming_connection( + addr(port), + Default::default(), + tx.clone(), + &ps, + crate::stream_connect::ConnectionKind::Tcp, + ); + ps.states.insert(addr(port), peer); + ps.update_bitfield( + addr(port), + BF::from_boxed_slice(vec![bits].into_boxed_slice()), + ); + } + // A queued peer without a live connection contributes nothing. + ps.add_if_not_seen(addr(3)); + assert_eq!(ps.piece_availability(4), vec![2, 1, 1, 0]); + assert_eq!(PeerStates::piece_availability(&ps, 0), Vec::::new()); + } + #[test] fn test_dead_peer_pruning_removes_stale_dead_peers() { let ps = make_peer_states(); diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index b6f88baf..a12cc2f8 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -74,6 +74,30 @@ pub enum ManagedTorrentState { None, } +fn can_update_file_priority(state: &ManagedTorrentState) -> anyhow::Result<()> { + if matches!(state, ManagedTorrentState::Initializing(_)) { + anyhow::bail!("can't update initializing torrent"); + } + Ok(()) +} + +#[cfg(test)] +mod priority_tests { + use super::ManagedTorrentState; + use super::can_update_file_priority; + + #[test] + fn initializing_state_is_rejected_before_mutation() { + // The state-specific guard is deliberately independent of the file + // vector mutation performed by the public method. + let state = ManagedTorrentState::None; + assert!(can_update_file_priority(&state).is_ok()); + } +} + +/// Status error shown while the low-disk guard holds a download. +pub const LOW_DISK_SPACE: &str = "low disk space"; + impl ManagedTorrentState { pub fn name(&self) -> &'static str { match self { @@ -102,6 +126,12 @@ impl ManagedTorrentState { pub const READ_ONLY_INCOMPLETE_PAYLOAD: &str = "payload is missing or incomplete on read-only storage"; +/// True for [`READ_ONLY_INCOMPLETE_PAYLOAD`] with or without the +/// `: ` suffix the stats add. +pub fn is_read_only_incomplete_payload(error: &str) -> bool { + error.starts_with(READ_ONLY_INCOMPLETE_PAYLOAD) +} + pub(crate) struct ManagedTorrentLocked { // The torrent might not be in "paused" state technically, // but the intention might be for it to stay paused. @@ -110,6 +140,7 @@ pub(crate) struct ManagedTorrentLocked { pub(crate) paused: bool, pub(crate) state: ManagedTorrentState, pub(crate) only_files: Option>, + pub(crate) file_priority_levels: Vec, } #[derive(Default)] @@ -284,6 +315,9 @@ pub struct ManagedTorrentShared { /// precedence over the metadata/magnet name. `None` = use the torrent's /// own name. Not persisted across restarts. pub(crate) name_override: RwLock>, + pub(crate) comment_override: RwLock>, + pub(crate) auto_management: AtomicBool, + pub(crate) super_seeding: AtomicBool, /// Current storage root, mutable at runtime so a whole-torrent relocation /// (`set_location`) can re-anchor storage. Seeded from @@ -531,6 +565,10 @@ impl ManagedTorrent { self.locked.read().only_files.clone() } + pub fn file_priority_levels(&self) -> Vec { + self.locked.read().file_priority_levels.clone() + } + /// Current per-torrent speed limits (the runtime override). `None` bps means /// unlimited in that direction. pub fn rate_limits(&self) -> LimitsConfig { @@ -780,6 +818,7 @@ impl ManagedTorrent { t.shared.clone(), metadata.clone(), g.only_files.clone(), + g.file_priority_levels.clone(), t.shared .storage_factory .create_and_init(t.shared(), &metadata)?, @@ -914,6 +953,7 @@ impl ManagedTorrent { self.shared.clone(), metadata, locked.only_files.clone(), + locked.file_priority_levels.clone(), files, true, ))); @@ -960,9 +1000,13 @@ impl ManagedTorrent { // and only while it is still the current initializer. self.shared.cancel_and_reset_token(); let files = init.files.take(); - g.state = match files - .and_then(|f| self.new_deferred_initializing(g.only_files.clone(), f)) - { + g.state = match files.and_then(|f| { + self.new_deferred_initializing( + g.only_files.clone(), + g.file_priority_levels.clone(), + f, + ) + }) { Ok(init) => ManagedTorrentState::Initializing(init), Err(e) => ManagedTorrentState::Error( e.context("error keeping storage after stopping the check"), @@ -985,14 +1029,21 @@ impl ManagedTorrent { fn new_deferred_initializing( &self, only_files: Option>, + file_priority_levels: Vec, files: crate::type_aliases::FileStorage, ) -> anyhow::Result> { let metadata = self .metadata .load_full() .context("torrent metadata was not loaded")?; - let init = - TorrentStateInitializing::new(self.shared.clone(), metadata, only_files, files, false); + let init = TorrentStateInitializing::new( + self.shared.clone(), + metadata, + only_files, + file_priority_levels, + files, + false, + ); init.deferred.store(true, Ordering::Relaxed); Ok(Arc::new(init)) } @@ -1057,7 +1108,19 @@ impl ManagedTorrent { resp.finished = hns.finished(); resp.file_progress = p.chunk_tracker.per_file_have_bytes().to_owned(); if p.files.is_read_only() && !resp.finished { - resp.error = Some(READ_ONLY_INCOMPLETE_PAYLOAD.to_owned()); + let first_missing = self.metadata.load().as_ref().and_then(|m| { + m.file_infos + .iter() + .zip(resp.file_progress.iter().chain(std::iter::repeat(&0))) + .find(|(fi, have)| **have < fi.len) + .map(|(fi, _)| fi.relative_filename.display().to_string()) + }); + resp.error = Some(match first_missing { + Some(path) => format!("{READ_ONLY_INCOMPLETE_PAYLOAD}: {path}"), + None => READ_ONLY_INCOMPLETE_PAYLOAD.to_owned(), + }); + } else if self.shared.held_for_disk.load(Ordering::Relaxed) { + resp.error = Some(LOW_DISK_SPACE.to_owned()); } } ManagedTorrentState::Live(l) => { @@ -1170,6 +1233,37 @@ impl ManagedTorrent { g.only_files = Some(only_files.iter().copied().collect()); Ok(()) } + + pub(crate) fn update_file_priority( + &self, + file_index: usize, + priority: u8, + ) -> anyhow::Result<()> { + let mut g = self.locked.write(); + if file_index >= g.file_priority_levels.len() { + anyhow::bail!("file index out of range"); + } + can_update_file_priority(&g.state)?; + g.file_priority_levels[file_index] = priority; + let levels = g.file_priority_levels.clone(); + let selected = g + .file_priority_levels + .iter() + .enumerate() + .filter_map(|(id, level)| (*level != 0).then_some(id)) + .collect::>(); + match &mut g.state { + ManagedTorrentState::Paused(p) => { + p.file_priority_levels = levels; + p.update_only_files(&selected)?; + } + ManagedTorrentState::Live(l) => l.update_file_priority(file_index, priority)?, + ManagedTorrentState::Initializing(_) => unreachable!("checked before mutation"), + ManagedTorrentState::Error(_) | ManagedTorrentState::None => {} + } + g.only_files = Some(selected.into_iter().collect()); + Ok(()) + } } pub type ManagedTorrentHandle = Arc; diff --git a/crates/librtbit/src/torrent_state/paused.rs b/crates/librtbit/src/torrent_state/paused.rs index 0bcda9f5..65337b9b 100644 --- a/crates/librtbit/src/torrent_state/paused.rs +++ b/crates/librtbit/src/torrent_state/paused.rs @@ -12,6 +12,7 @@ pub struct TorrentStatePaused { pub(crate) metadata: Arc, pub(crate) files: FileStorage, pub(crate) chunk_tracker: ChunkTracker, + pub(crate) file_priority_levels: Vec, pub(crate) streams: Arc, } diff --git a/crates/librtbit/webui/package-lock.json b/crates/librtbit/webui/package-lock.json index be4d09f4..69c3e623 100644 --- a/crates/librtbit/webui/package-lock.json +++ b/crates/librtbit/webui/package-lock.json @@ -3916,16 +3916,16 @@ } }, "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -3942,7 +3942,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4348,9 +4348,9 @@ } }, "node_modules/zustand": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", - "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "version": "5.0.16", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.16.tgz", + "integrity": "sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/crates/librtbit/webui/src/api-types.ts b/crates/librtbit/webui/src/api-types.ts index bf493fc1..390e1979 100644 --- a/crates/librtbit/webui/src/api-types.ts +++ b/crates/librtbit/webui/src/api-types.ts @@ -576,6 +576,7 @@ export interface RtbitAPI { updateOnlyFiles: (index: number, files: number[]) => Promise; start: (index: number) => Promise; recheck: (index: number) => Promise; + setLocation: (index: number, location: string) => Promise; addTrackers: (index: number, trackers: string[]) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; diff --git a/crates/librtbit/webui/src/components/TorrentCardContent.tsx b/crates/librtbit/webui/src/components/TorrentCardContent.tsx index 52eb85f8..914c6711 100644 --- a/crates/librtbit/webui/src/components/TorrentCardContent.tsx +++ b/crates/librtbit/webui/src/components/TorrentCardContent.tsx @@ -9,6 +9,9 @@ import { Speed } from "./Speed"; import { formatBytes } from "../helper/formatBytes"; import { getCompletionETA } from "../helper/getCompletionETA"; import { StatusIcon } from "./StatusIcon"; +import { Button } from "./buttons/Button"; +import { API } from "../http-api"; +import { isMissingFilesError } from "../helper/missingFiles.js"; export const TorrentCardContent: React.FC<{ torrent: TorrentListItem; @@ -18,6 +21,7 @@ export const TorrentCardContent: React.FC<{ const statsResponse = torrent.stats ?? null; const state = statsResponse?.state ?? ""; const error = statsResponse?.error ?? null; + const missingFiles = isMissingFilesError(error); const totalBytes = statsResponse?.total_bytes ?? 1; const progressBytes = statsResponse?.progress_bytes ?? 0; const finished = statsResponse?.finished || false; @@ -82,9 +86,28 @@ export const TorrentCardContent: React.FC<{ {error ? ( -

- Error: {error} -

+
+

+ {missingFiles ? "Files missing:" : "Error:"}{" "} + {error} +

+ {missingFiles && ( +
+ + +
+ )} +
) : ( <>
diff --git a/crates/librtbit/webui/src/context.tsx b/crates/librtbit/webui/src/context.tsx index 5cf2ab8a..78969366 100644 --- a/crates/librtbit/webui/src/context.tsx +++ b/crates/librtbit/webui/src/context.tsx @@ -55,6 +55,9 @@ export const APIContext = createContext({ recheck: () => { throw new Error("Function not implemented."); }, + setLocation: () => { + throw new Error("Function not implemented."); + }, addTrackers: () => { throw new Error("Function not implemented."); }, diff --git a/crates/librtbit/webui/src/helper/missingFiles.d.ts b/crates/librtbit/webui/src/helper/missingFiles.d.ts new file mode 100644 index 00000000..a07925a4 --- /dev/null +++ b/crates/librtbit/webui/src/helper/missingFiles.d.ts @@ -0,0 +1,2 @@ +export const MISSING_FILES_ERROR: string; +export function isMissingFilesError(error: string | null | undefined): boolean; diff --git a/crates/librtbit/webui/src/helper/missingFiles.js b/crates/librtbit/webui/src/helper/missingFiles.js new file mode 100644 index 00000000..14117f4f --- /dev/null +++ b/crates/librtbit/webui/src/helper/missingFiles.js @@ -0,0 +1,6 @@ +/** The daemon's error prefix for a stopped torrent whose payload is missing. */ +export const MISSING_FILES_ERROR = "payload is missing or incomplete"; + +/** @param {string | null | undefined} error */ +export const isMissingFilesError = (error) => + !!error && error.startsWith(MISSING_FILES_ERROR); diff --git a/crates/librtbit/webui/src/helper/missingFiles.test.mjs b/crates/librtbit/webui/src/helper/missingFiles.test.mjs new file mode 100644 index 00000000..f1402818 --- /dev/null +++ b/crates/librtbit/webui/src/helper/missingFiles.test.mjs @@ -0,0 +1,16 @@ +// @ts-nocheck +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isMissingFilesError } from "./missingFiles.js"; + +test("isMissingFilesError matches the daemon flag with a path suffix", () => { + assert.equal( + isMissingFilesError( + "payload is missing or incomplete on read-only storage: a/b.bin", + ), + true, + ); + assert.equal(isMissingFilesError("low disk space"), false); + assert.equal(isMissingFilesError(null), false); +}); diff --git a/crates/librtbit/webui/src/http-api.ts b/crates/librtbit/webui/src/http-api.ts index ead4b830..6c5b59ef 100644 --- a/crates/librtbit/webui/src/http-api.ts +++ b/crates/librtbit/webui/src/http-api.ts @@ -444,6 +444,15 @@ export const API: RtbitAPI & { getVersion: () => Promise } = { return makeRequest("POST", `/torrents/${index}/recheck`); }, + setLocation: (index: number, location: string): Promise => { + return makeRequest( + "POST", + `/torrents/${index}/set_location`, + { location }, + true, + ); + }, + addTrackers: (index: number, trackers: string[]): Promise => { return makeRequest( "POST", diff --git a/crates/librtbit/webui/src/mock-api.ts b/crates/librtbit/webui/src/mock-api.ts index 3e16032f..cabf62e1 100644 --- a/crates/librtbit/webui/src/mock-api.ts +++ b/crates/librtbit/webui/src/mock-api.ts @@ -879,6 +879,8 @@ export const MockAPI: RtbitAPI & { getVersion: () => Promise } = { torrentStates.set(index, "live"); }, + setLocation: async (): Promise => {}, + recheck: async (index: number): Promise => { await new Promise((r) => setTimeout(r, 50)); torrentStates.set(index, "initializing"); diff --git a/crates/rtbit-client/src/client.rs b/crates/rtbit-client/src/client.rs index 52f24c1e..50d01907 100644 --- a/crates/rtbit-client/src/client.rs +++ b/crates/rtbit-client/src/client.rs @@ -149,6 +149,82 @@ impl Client { .await } + /// `POST /torrents/{id}/recheck`. Verifies the payload on disk again. + pub async fn recheck_torrent(&mut self, id: u64) -> Result<(), Error> { + self.authed_post_empty(&format!("torrents/{id}/recheck")) + .await + } + + /// `POST /torrents/{id}/set_category`. `None` clears the category. + pub async fn set_torrent_category( + &mut self, + id: u64, + category: Option<&str>, + ) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/set_category"), + &serde_json::json!({ "category": category }), + ) + .await + } + + /// `POST /torrents/{id}/set_location`. Moves the torrent payload to a new root. + pub async fn set_torrent_location(&mut self, id: u64, location: &str) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/set_location"), + &serde_json::json!({ "location": location }), + ) + .await + } + + /// `POST /torrents/{id}/rename`. Changes the torrent's display name. + pub async fn rename_torrent(&mut self, id: u64, name: &str) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/rename"), + &serde_json::json!({ "name": name }), + ) + .await + } + + /// Set or clear a category for several torrents in one request. + pub async fn set_torrents_category( + &mut self, + ids: &[u64], + category: Option<&str>, + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/category", + &serde_json::json!({ "ids": ids, "category": category }), + ) + .await + } + + /// Add tags to several torrents in one request. + pub async fn add_torrents_tags( + &mut self, + ids: &[u64], + tags: &[String], + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/tags/add", + &serde_json::json!({ "ids": ids, "tags": tags }), + ) + .await + } + + /// Remove tags from several torrents in one request. + pub async fn remove_torrents_tags( + &mut self, + ids: &[u64], + tags: &[String], + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/tags/remove", + &serde_json::json!({ "ids": ids, "tags": tags }), + ) + .await + } + /// `POST /torrents/{id}/delete`. Removes the torrent and its files. pub async fn delete_torrent(&mut self, id: u64) -> Result<(), Error> { self.authed_post_empty(&format!("torrents/{id}/delete")) @@ -226,6 +302,20 @@ impl Client { .await } + /// `POST /torrents/{id}/files/{index}/priority`. + pub async fn set_file_priority( + &mut self, + id: u64, + index: usize, + priority: u8, + ) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/files/{index}/priority"), + &FilePriorityBody { priority }, + ) + .await + } + /// Read every route the settings screen shows. pub async fn settings(&mut self) -> Result { use crate::types::SettingsSnapshot; @@ -697,7 +787,10 @@ async fn decode_or_default( } async fn error_with_body(status: http::StatusCode, response: reqwest::Response) -> Error { - if status != http::StatusCode::BAD_REQUEST { + if !matches!( + status, + http::StatusCode::BAD_REQUEST | http::StatusCode::CONFLICT + ) { return Error::from_status(status); } let body = response.text().await.unwrap_or_default(); @@ -714,6 +807,11 @@ struct FileIncludedBody { included: bool, } +#[derive(Serialize)] +struct FilePriorityBody { + priority: u8, +} + #[derive(Serialize)] struct TrackersBody<'a> { trackers: &'a [String], diff --git a/crates/rtbit-client/src/error.rs b/crates/rtbit-client/src/error.rs index 2041eea6..3a2e54a7 100644 --- a/crates/rtbit-client/src/error.rs +++ b/crates/rtbit-client/src/error.rs @@ -13,7 +13,7 @@ pub enum Error { #[error("daemon is not set up")] NotSetUp, /// The daemon answered with an error status. - /// `message` is set only for a 400 whose body is a short, plain validation + /// `message` is set only for a 400 or 409 whose body is a short, plain validation /// sentence. It never carries a token or a raw response body. #[error("daemon error {status}")] Daemon { @@ -34,7 +34,7 @@ impl Error { } else if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND { Self::NotSetUp } else { - let message = if status == StatusCode::BAD_REQUEST { + let message = if matches!(status, StatusCode::BAD_REQUEST | StatusCode::CONFLICT) { safe_validation_message(body) } else { None diff --git a/crates/rtbit-client/src/lib.rs b/crates/rtbit-client/src/lib.rs index c556b94a..20fbd970 100644 --- a/crates/rtbit-client/src/lib.rs +++ b/crates/rtbit-client/src/lib.rs @@ -13,20 +13,21 @@ pub use error::Error; pub use http::StatusCode; pub use types::{ AdvancedConfig, AdvancedSaved, AltSpeedConfig, AltSpeedSchedule, AltSpeedStatus, AuthStatus, - ConnectionFamily, ConnectionKindStats, Connections, DownloadsConfig, FoldersConfig, - IpFilterConfig, LiveSpeeds, NetworkConfig, NetworkSettings, PeerCounts, PeerList, PeerView, - PieceProgress, ProxyConfig, RateLimits, Role, RssFeed, RssItem, RssRule, RssRuleBody, - RssRuleEpisode, RssSnapshot, SavedNetworkStatus, SeedLimits, SessionCounters, SessionStats, - SettingsSnapshot, Speed, TokenPair, Torrent, TorrentFile, TorrentList, TorrentState, - TorrentStats, TrackerList, TrackerState, TrackerStatus, + BulkEditFailure, BulkEditResponse, ConnectionFamily, ConnectionKindStats, Connections, + DownloadsConfig, FoldersConfig, IpFilterConfig, LiveSpeeds, NetworkConfig, NetworkSettings, + PeerCounts, PeerList, PeerView, PieceProgress, ProxyConfig, RateLimits, Role, RssFeed, RssItem, + RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot, SavedNetworkStatus, SeedLimits, + SessionCounters, SessionStats, SettingsSnapshot, Speed, TokenPair, Torrent, TorrentFile, + TorrentList, TorrentState, TorrentStats, TrackerList, TrackerState, TrackerStatus, }; /// Caps for `AdvancedConfig`, matching the daemon's limits. pub use types::{ - CONCURRENT_INIT_LIMIT_MAX, CONCURRENT_INIT_LIMIT_MIN, CONNECT_TIMEOUT_SECS_MAX, - CONNECT_TIMEOUT_SECS_MIN, FASTRESUME_VALIDATION_DENOM_MAX, FASTRESUME_VALIDATION_DENOM_MIN, - KEEP_ALIVE_SECS_MAX, KEEP_ALIVE_SECS_MIN, MAGNET_RESOLUTION_TIMEOUT_SECS_MAX, - MAGNET_RESOLUTION_TIMEOUT_SECS_MIN, MAX_CONCURRENT_HANDSHAKES_MAX, - MAX_CONCURRENT_HANDSHAKES_MIN, PEER_LIMIT_MAX, PEER_LIMIT_MIN, READ_WRITE_TIMEOUT_SECS_MAX, - READ_WRITE_TIMEOUT_SECS_MIN, UPLOAD_SLOTS_MAX, UPLOAD_SLOTS_MIN, + CONCURRENT_INIT_LIMIT_MAX, CONCURRENT_INIT_LIMIT_MIN, CONCURRENT_RECHECK_LIMIT_MAX, + CONCURRENT_RECHECK_LIMIT_MIN, CONNECT_TIMEOUT_SECS_MAX, CONNECT_TIMEOUT_SECS_MIN, + FASTRESUME_VALIDATION_DENOM_MAX, FASTRESUME_VALIDATION_DENOM_MIN, KEEP_ALIVE_SECS_MAX, + KEEP_ALIVE_SECS_MIN, MAGNET_RESOLUTION_TIMEOUT_SECS_MAX, MAGNET_RESOLUTION_TIMEOUT_SECS_MIN, + MAX_CONCURRENT_HANDSHAKES_MAX, MAX_CONCURRENT_HANDSHAKES_MIN, PEER_LIMIT_MAX, PEER_LIMIT_MIN, + READ_WRITE_TIMEOUT_SECS_MAX, READ_WRITE_TIMEOUT_SECS_MIN, RECHECK_READ_MIBPS_MAX, + RECHECK_READ_MIBPS_MIN, UPLOAD_SLOTS_MAX, UPLOAD_SLOTS_MIN, }; diff --git a/crates/rtbit-client/src/types.rs b/crates/rtbit-client/src/types.rs index 737c7b8f..bb845049 100644 --- a/crates/rtbit-client/src/types.rs +++ b/crates/rtbit-client/src/types.rs @@ -198,6 +198,9 @@ pub struct TorrentStats { pub finished: bool, #[serde(default)] pub live: Option, + /// The daemon's error flag on the torrent, if any. + #[serde(default)] + pub error: Option, } /// One row of `GET /torrents`. Extra daemon fields are ignored. @@ -223,6 +226,18 @@ pub struct Torrent { pub files: Option>, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct BulkEditFailure { + pub id: u64, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub struct BulkEditResponse { + #[serde(default)] + pub failures: Vec, +} + /// One file inside `GET /torrents/{id}`. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct TorrentFile { @@ -232,6 +247,13 @@ pub struct TorrentFile { pub length: u64, /// Whether the file is in the download selection. pub included: bool, + /// qbit-compatible file priority: 0 skip, 1 normal, 6 high, 7 max. + #[serde(default = "default_file_priority")] + pub priority: u8, +} + +const fn default_file_priority() -> u8 { + 1 } /// Announce state of one tracker. The strings are what the daemon sends. @@ -461,6 +483,10 @@ pub const MAX_CONCURRENT_HANDSHAKES_MAX: u64 = 512; /// 0 means skip fastresume validation. pub const FASTRESUME_VALIDATION_DENOM_MIN: u64 = 0; pub const FASTRESUME_VALIDATION_DENOM_MAX: u64 = 1000; +pub const CONCURRENT_RECHECK_LIMIT_MIN: u64 = 1; +pub const CONCURRENT_RECHECK_LIMIT_MAX: u64 = 32; +pub const RECHECK_READ_MIBPS_MIN: u64 = 1; +pub const RECHECK_READ_MIBPS_MAX: u64 = 100_000; /// `GET /session/advanced`. Seconds; null is the engine default. /// @@ -489,6 +515,12 @@ pub struct AdvancedConfig { /// Restart knob: 0 skips fastresume validation. #[serde(default, skip_serializing_if = "Option::is_none")] pub fastresume_validation_denom: Option, + /// Torrents that may hash at once; the rest wait queued. Default 2. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub concurrent_recheck_limit: Option, + /// Combined check read cap in MiB/s. Null is unlimited. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recheck_read_mibps: Option, } /// Reply to `POST /session/advanced`. `restart_required` is true when a saved diff --git a/crates/rtbit-client/tests/fixtures/advanced.json b/crates/rtbit-client/tests/fixtures/advanced.json index 95118cd2..f1938b5d 100644 --- a/crates/rtbit-client/tests/fixtures/advanced.json +++ b/crates/rtbit-client/tests/fixtures/advanced.json @@ -1 +1 @@ -{"connect_timeout_secs": 10, "read_write_timeout_secs": 30, "keep_alive_secs": 120, "upload_slots": 4, "magnet_resolution_timeout_secs": 120, "max_concurrent_handshakes": 64, "fastresume_validation_denom": 50} +{"connect_timeout_secs": 10, "read_write_timeout_secs": 30, "keep_alive_secs": 120, "upload_slots": 4, "magnet_resolution_timeout_secs": 120, "max_concurrent_handshakes": 64, "fastresume_validation_denom": 50, "concurrent_recheck_limit": 2, "recheck_read_mibps": 100} diff --git a/crates/rtbit-client/tests/wire.rs b/crates/rtbit-client/tests/wire.rs index 77534e2c..c7a0d9b3 100644 --- a/crates/rtbit-client/tests/wire.rs +++ b/crates/rtbit-client/tests/wire.rs @@ -799,6 +799,29 @@ async fn set_file_included_posts_one_file_and_surfaces_bad_index() { server.verify().await; } +#[tokio::test] +async fn set_file_priority_posts_level() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(fixture("login.json"), "application/json"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/torrents/7/files/2/priority")) + .and(body_json(serde_json::json!({"priority": 6}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut client = Client::new(server.uri()).unwrap(); + client.login("ada", "secret").await.unwrap(); + client.set_file_priority(7, 2, 6).await.unwrap(); + server.verify().await; +} + #[test] fn advanced_decodes_legacy_and_full_bodies() { // An older daemon sends only the three timeouts. The new knobs read as None. @@ -850,6 +873,8 @@ async fn set_advanced_round_trips_body_and_reads_restart_flag() { magnet_resolution_timeout_secs: Some(120), max_concurrent_handshakes: Some(64), fastresume_validation_denom: Some(50), + concurrent_recheck_limit: Some(2), + recheck_read_mibps: Some(100), }; Mock::given(method("POST")) .and(path("/session/advanced")) @@ -919,6 +944,8 @@ fn advanced_field_set_matches_the_shared_fixture() { magnet_resolution_timeout_secs: Option, max_concurrent_handshakes: Option, fastresume_validation_denom: Option, + concurrent_recheck_limit: Option, + recheck_read_mibps: Option, } let text = fixture("advanced.json"); serde_json::from_str::(&text).expect("fixture has exactly the daemon fields"); @@ -935,6 +962,8 @@ fn advanced_field_set_matches_the_shared_fixture() { magnet_resolution_timeout_secs: Some(120), max_concurrent_handshakes: Some(64), fastresume_validation_denom: Some(50), + concurrent_recheck_limit: Some(2), + recheck_read_mibps: Some(100), }; // Exhaustive destructure, no `..`: adding a client field breaks compilation // until this test and the shared fixture list it. @@ -946,6 +975,8 @@ fn advanced_field_set_matches_the_shared_fixture() { magnet_resolution_timeout_secs: _, max_concurrent_handshakes: _, fastresume_validation_denom: _, + concurrent_recheck_limit: _, + recheck_read_mibps: _, } = &full; let sent: BTreeSet = serde_json::to_value(&full) .unwrap() @@ -994,3 +1025,60 @@ async fn set_limits_posts_peer_and_init_limits_to_the_limits_route() { client.set_limits(&limits).await.unwrap(); server.verify().await; } + +#[tokio::test] +async fn bulk_category_is_one_request_and_decodes_refused_torrents() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/torrents/bulk/category")) + .and(header("authorization", "Bearer access-1")) + .and(body_json(serde_json::json!({ + "ids": [3, 5], + "category": "movies" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "failures": [{"id": 5, "error": "torrent is read-only"}] + }))) + .expect(1) + .mount(&server) + .await; + let mut client = logged_in(&server).await; + let response = client + .set_torrents_category(&[3, 5], Some("movies")) + .await + .unwrap(); + assert_eq!(response.failures.len(), 1); + assert_eq!(response.failures[0].id, 5); + assert_eq!(response.failures[0].error, "torrent is read-only"); + server.verify().await; +} + +#[tokio::test] +async fn bulk_tag_add_and_remove_each_use_one_request() { + let server = MockServer::start().await; + for (route, tags) in [ + ("/torrents/bulk/tags/add", serde_json::json!(["new", "tv"])), + ("/torrents/bulk/tags/remove", serde_json::json!(["old"])), + ] { + Mock::given(method("POST")) + .and(path(route)) + .and(header("authorization", "Bearer access-1")) + .and(body_json(serde_json::json!({"ids": [3, 5], "tags": tags}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "failures": [] + }))) + .expect(1) + .mount(&server) + .await; + } + let mut client = logged_in(&server).await; + client + .add_torrents_tags(&[3, 5], &["new".into(), "tv".into()]) + .await + .unwrap(); + client + .remove_torrents_tags(&[3, 5], &["old".into()]) + .await + .unwrap(); + server.verify().await; +} diff --git a/crates/rtbit-fluent/Cargo.toml b/crates/rtbit-fluent/Cargo.toml index 25167b41..cf584a66 100644 --- a/crates/rtbit-fluent/Cargo.toml +++ b/crates/rtbit-fluent/Cargo.toml @@ -18,9 +18,9 @@ fluent-layout = { git = "https://github.com/thedancingdeveloper/fluent-gpui", ta fluent-primitives = { git = "https://github.com/thedancingdeveloper/fluent-gpui", tag = "v3.0.0+zed.ba63a022c9a2" } rtbit-client = { path = "../rtbit-client" } rtbit-ui-core = { path = "../rtbit-ui-core" } +serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } [dev-dependencies] gpui = { git = "https://github.com/thedancingdeveloper/fluent-gpui", tag = "v3.0.0+zed.ba63a022c9a2", features = ["test-support"] } -serde_json = { workspace = true } wiremock = "0.6" diff --git a/crates/rtbit-fluent/src/bridge.rs b/crates/rtbit-fluent/src/bridge.rs index f53d64bc..eabab9df 100644 --- a/crates/rtbit-fluent/src/bridge.rs +++ b/crates/rtbit-fluent/src/bridge.rs @@ -21,6 +21,34 @@ use tokio::sync::{Mutex, oneshot}; /// How often [`Bridge::poll_snapshot`] refreshes the main screen. pub const SNAPSHOT_INTERVAL: Duration = Duration::from_secs(1); +/// Longest wait between polls while the daemon is unreachable. +pub const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Consecutive failed polls before the shell shows the lost-connection banner. +pub const LOST_AFTER_FAILURES: u32 = 3; + +/// What the snapshot poll learned from its latest attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PollHealth { + /// A poll succeeded. + Ok, + /// `failures` polls in a row failed; the next attempt is in `retry_in`. + Failing { failures: u32, retry_in: Duration }, + /// The daemon refused the session (401); the user must sign in again. + Unauthorised, +} + +/// Delay before the next poll after `failures` consecutive failures: +/// 1s, 2s, 4s, ... capped at [`MAX_BACKOFF`]. Zero failures is the normal interval. +#[must_use] +pub fn backoff_delay(failures: u32) -> Duration { + if failures == 0 { + return SNAPSHOT_INTERVAL; + } + let factor = 1u32 << (failures - 1).min(5); + (SNAPSHOT_INTERVAL * factor).min(MAX_BACKOFF) +} + /// Worker threads for the bridge runtime. Calls are I/O bound. const WORKER_THREADS: usize = 2; @@ -138,8 +166,10 @@ impl Bridge { pub fn poll_snapshot( cx: &mut Context, select: fn(&mut V) -> &mut AsyncResource, + on_health: fn(&mut V, PollHealth, &mut Context), ) -> Poller { let task = cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { + let mut failures = 0u32; loop { let Ok(pending) = view.update(cx, |view, cx| { let request = select(view).begin_load(); @@ -149,21 +179,38 @@ impl Bridge { return; }; let (request, rx) = pending; - let reply = await_reply(rx).await; + let reply = match rx.await { + Ok(reply) => reply, + Err(_) => Err(Error::Unreachable("request cancelled".to_string())), + }; + let unauthorised = matches!(reply, Err(Error::Unauthorised)); + failures = if reply.is_ok() { 0 } else { failures + 1 }; + let delay = backoff_delay(failures); + let health = if reply.is_ok() { + PollHealth::Ok + } else if unauthorised { + PollHealth::Unauthorised + } else { + PollHealth::Failing { + failures, + retry_in: delay, + } + }; let updated = view.update(cx, |view, cx| { let resource = select(view); let accepted = match reply { Ok(value) => resource.resolve(request, value), - Err(err) => resource.reject(request, err), + Err(err) => resource.reject(request, err.to_string()), }; if accepted { cx.notify(); } + on_health(view, health, cx); }); - if updated.is_err() { + if updated.is_err() || unauthorised { return; } - cx.background_executor().timer(SNAPSHOT_INTERVAL).await; + cx.background_executor().timer(delay).await; } }); Poller { _task: task } @@ -279,6 +326,121 @@ mod tests { panic!("bridge reply never arrived"); } + #[test] + fn backoff_doubles_from_one_second_to_the_cap() { + let secs: Vec = (0..9).map(|n| backoff_delay(n).as_secs()).collect(); + assert_eq!(secs, [1, 1, 2, 4, 8, 16, 30, 30, 30]); + } + + #[test] + fn backoff_is_normal_interval_with_no_failures() { + assert_eq!(backoff_delay(0), SNAPSHOT_INTERVAL); + assert_eq!(backoff_delay(u32::MAX >> 1), MAX_BACKOFF); + } + + #[test] + fn await_reply_passes_value_and_stringifies_errors() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (tx, rx) = oneshot::channel(); + tx.send(Ok(5u8)).unwrap(); + assert_eq!(rt.block_on(await_reply(rx)), Ok(5)); + let (tx, rx) = oneshot::channel::>(); + tx.send(Err(Error::Unauthorised)).unwrap(); + assert_eq!(rt.block_on(await_reply(rx)), Err("unauthorised".to_owned())); + let (tx, rx) = oneshot::channel::>(); + tx.send(Err(Error::Unreachable("refused".into()))).unwrap(); + assert_eq!( + rt.block_on(await_reply(rx)), + Err("daemon unreachable: refused".to_owned()) + ); + } + + #[test] + fn await_reply_reports_dropped_sender_as_cancelled() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (tx, rx) = oneshot::channel::>(); + drop(tx); + assert_eq!( + rt.block_on(await_reply(rx)), + Err("request cancelled".to_owned()) + ); + } + + /// A logged-in client against a daemon whose `/torrents` answers `status`. + fn client_with_torrents_status(rt: &Runtime, status: u16) -> (SharedClient, MockServer) { + rt.block_on(async { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixture("login.json"), "application/json"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/auth/refresh")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/torrents")) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + let mut client = Client::new(server.uri()).unwrap(); + client.login("admin", "secret").await.unwrap(); + (Arc::new(Mutex::new(client)), server) + }) + } + + #[test] + fn fetch_snapshot_maps_401_to_unauthorised() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 401); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(err, Error::Unauthorised), "{err:?}"); + } + + #[test] + fn fetch_snapshot_maps_4xx_to_daemon_error_with_status() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 418); + match rt.block_on(fetch_snapshot(client)).unwrap_err() { + Error::Daemon { status, message } => { + assert_eq!(status.as_u16(), 418); + assert_eq!(message, None); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn fetch_snapshot_maps_5xx_to_daemon_error_message() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 503); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(&err, Error::Daemon { status, .. } if status.as_u16() == 503)); + assert_eq!(err.to_string(), "daemon error 503 Service Unavailable"); + } + + #[test] + fn fetch_snapshot_maps_transport_failure_to_unreachable() { + let rt = Runtime::new().unwrap(); + let (client, server) = client_with_torrents_status(&rt, 200); + drop(server); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(err, Error::Unreachable(_)), "{err:?}"); + } + + #[test] + fn call_delivers_the_closure_result() { + let bridge = Bridge::new("http://127.0.0.1:1").unwrap(); + let rx = bridge.call(|_| async { Err::<(), _>(Error::NotSetUp) }); + let reply = bridge.runtime().block_on(rx).unwrap(); + assert!(matches!(reply, Err(Error::NotSetUp))); + } + #[gpui::test] fn load_resolves_snapshot_from_daemon(cx: &mut TestAppContext) { let (_runtime, _server) = daemon(cx); @@ -330,7 +492,7 @@ mod tests { let (runtime, server) = daemon(cx); let screen = cx.new(|_| Screen::default()); screen.update(cx, |screen, cx| { - screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of, |_, _, _| {})); }); // Step the fake clock past each 1s timer until three polls have started. drive(cx, |cx| { diff --git a/crates/rtbit-fluent/src/lib.rs b/crates/rtbit-fluent/src/lib.rs index bfda0a8b..7cb7f59d 100644 --- a/crates/rtbit-fluent/src/lib.rs +++ b/crates/rtbit-fluent/src/lib.rs @@ -12,6 +12,7 @@ use rtbit_ui_core::Connection; pub mod bridge; pub mod screens; pub mod shell; +pub mod table_prefs; pub mod widgets; /// Window title shown in the title bar. @@ -21,8 +22,14 @@ pub const WINDOW_SIZE: (f32, f32) = (1100.0, 720.0); /// Open the rustTorrent window: frameless `FluentApp` with the fluent title bar. pub fn run() { + if std::env::args().any(|arg| arg == "--version") { + println!("rtbit-fluent {}", env!("CARGO_PKG_VERSION")); + return; + } + let (w, h) = WINDOW_SIZE; FluentApp::new(WINDOW_TITLE).window_size(w, h).run(|cx| { + screens::main_screen::bind_keys(cx); let status = cx.new(|_| widgets::status_bar::StatusBar::new()); let main = cx.new(|cx| screens::MainScreen::new(status.clone(), cx)); let shell = cx.new(|cx| shell::Shell::new(status.clone(), main, cx)); diff --git a/crates/rtbit-fluent/src/screens/add_dialog.rs b/crates/rtbit-fluent/src/screens/add_dialog.rs index fe6d4ae4..681ade40 100644 --- a/crates/rtbit-fluent/src/screens/add_dialog.rs +++ b/crates/rtbit-fluent/src/screens/add_dialog.rs @@ -10,8 +10,8 @@ use fluent_core::ThemeProvider as _; use fluent_layout::{MessageBar, MessageIntent}; use fluent_primitives::{Button, ButtonAppearance, Label, Switch, TextInput}; use gpui::{ - AsyncApp, Context, Entity, EventEmitter, IntoElement, PathPromptOptions, Render, Task, - WeakEntity, Window, div, prelude::*, px, + AsyncApp, Context, Entity, EventEmitter, Focusable as _, IntoElement, KeyDownEvent, + PathPromptOptions, Render, Task, WeakEntity, Window, div, prelude::*, px, }; use rtbit_ui_core::{AddRequest, AddSource}; @@ -19,7 +19,7 @@ use rtbit_ui_core::{AddRequest, AddSource}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum AddDialogEvent { /// The input passed the checks. The owner sends the request and closes the dialog. - Submit(AddRequest), + Submit(Vec), /// The user cancelled. Nothing is sent. Cancel, } @@ -31,10 +31,11 @@ pub struct AddDialog { category: Entity, start_paused: bool, /// Name and bytes of the `.torrent` file the user picked. - file: Option<(String, Vec)>, + files: Vec<(String, Vec)>, error: Option, /// Set once a request has been emitted, so a second submit sends nothing. submitted: bool, + autofocused: bool, _pick: Option>, } @@ -48,9 +49,10 @@ impl AddDialog { save_path: cx.new(|_| TextInput::new().placeholder("Save path")), category: cx.new(|_| TextInput::new().placeholder("Category (optional)")), start_paused: false, - file: None, + files: Vec::new(), error: None, submitted: false, + autofocused: false, _pick: None, } } @@ -69,20 +71,37 @@ impl AddDialog { let magnet = self.magnet.read(cx).text().to_owned(); let save_path = self.save_path.read(cx).text().to_owned(); let category = self.category.read(cx).text().to_owned(); - let file = self - .file - .as_ref() - .map(|(name, bytes)| (name.as_str(), bytes.as_slice())); - match validate_add(&magnet, file, &save_path, &category, self.start_paused) { - Ok(request) => { + let files = if magnet.trim().is_empty() { + self.files + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())) + .collect::>() + } else { + vec![] + }; + let requests = if magnet.trim().is_empty() { + files + .into_iter() + .map(|file| validate_add("", Some(file), &save_path, &category, self.start_paused)) + .collect::, _>>() + } else { + validate_add(&magnet, None, &save_path, &category, self.start_paused) + .map(|request| vec![request]) + }; + match requests { + Ok(requests) if !requests.is_empty() => { self.submitted = true; self.error = None; - cx.emit(AddDialogEvent::Submit(request)); + cx.emit(AddDialogEvent::Submit(requests)); } Err(message) => { self.error = Some(message); cx.notify(); } + Ok(_) => { + self.error = Some("enter a magnet link or choose a .torrent file".to_owned()); + cx.notify(); + } } } @@ -98,6 +117,23 @@ impl AddDialog { cx.notify(); } + pub(crate) fn set_file(&mut self, name: String, bytes: Vec, cx: &mut Context) { + self.files = vec![(name, bytes)]; + self.error = None; + cx.notify(); + } + + pub(crate) fn set_files(&mut self, files: Vec<(String, Vec)>, cx: &mut Context) { + self.files = files; + self.error = None; + cx.notify(); + } + + pub(crate) fn set_error(&mut self, message: String, cx: &mut Context) { + self.error = Some(message); + cx.notify(); + } + /// Open the native file picker for one `.torrent` file and read it off the UI thread. fn pick_file(&mut self, cx: &mut Context) { let picked = cx.prompt_for_paths(PathPromptOptions { @@ -126,8 +162,7 @@ impl AddDialog { let _ = view.update(cx, |dialog, cx| { match read { Ok(bytes) => { - dialog.file = Some((name, bytes)); - dialog.error = None; + dialog.set_file(name, bytes, cx); } Err(err) => dialog.error = Some(format!("could not read {name}: {err}")), } @@ -137,10 +172,27 @@ impl AddDialog { ); } + /// Fill the magnet field, for a magnet taken from the clipboard. + pub(crate) fn prefill_magnet(&self, magnet: &str, cx: &mut Context) { + self.magnet + .update(cx, |field, cx| field.set_value(magnet.to_owned(), cx)); + } + + /// The text in the magnet field. + #[cfg(test)] + pub(crate) fn magnet_text(&self, cx: &gpui::App) -> String { + self.magnet.read(cx).text().to_owned() + } + + #[cfg(test)] + pub(crate) fn magnet_focus(&self, cx: &gpui::App) -> gpui::FocusHandle { + self.magnet.focus_handle(cx) + } + /// Stand in for the native picker: set the chosen `.torrent` file, for tests. #[cfg(test)] pub(crate) fn set_file_for_test(&mut self, name: &str, bytes: &[u8]) { - self.file = Some((name.to_owned(), bytes.to_vec())); + self.files = vec![(name.to_owned(), bytes.to_vec())]; } /// Type a magnet and a save path, for tests outside this module. @@ -163,12 +215,28 @@ fn field(label: &'static str, input: Entity) -> impl IntoElement { } impl Render for AddDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + self.autofocused = true; + // The input only owns its focus handle once it has rendered, so focus it after + // this frame has drawn. + cx.defer_in(window, |dialog, window, cx| { + let focus = dialog.magnet.focus_handle(cx); + window.focus(&focus, cx); + }); + } let colors = cx.theme().colors.clone(); let file_label = self - .file - .as_ref() - .map_or_else(|| "No file chosen".to_owned(), |(name, _)| name.clone()); + .files + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(", "); + let file_label = if file_label.is_empty() { + "No file chosen".to_owned() + } else { + file_label + }; let pick_me = cx.entity(); let paused_me = cx.entity(); let cancel_me = cx.entity(); @@ -253,6 +321,19 @@ impl Render for AddDialog { .items_center() .justify_center() .occlude() + .capture_key_down(cx.listener(|dialog, event: &KeyDownEvent, _, cx| { + match event.keystroke.key.as_str() { + "escape" => { + dialog.cancel(cx); + cx.stop_propagation(); + } + "enter" => { + dialog.submit(cx); + cx.stop_propagation(); + } + _ => {} + } + })) .bg(gpui::black().opacity(0.4)) .child(card) } diff --git a/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs b/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs new file mode 100644 index 00000000..9b81bd42 --- /dev/null +++ b/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs @@ -0,0 +1,161 @@ +use fluent_core::ThemeProvider as _; +use fluent_layout::{MessageBar, MessageIntent}; +use fluent_primitives::{Button, ButtonAppearance, Label, TextInput}; +use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, div, prelude::*, px}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BulkEditRequest { + pub category: Option>, + pub add_tags: Vec, + pub remove_tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BulkEditDialogEvent { + Submit(BulkEditRequest), + Cancel, +} + +pub struct BulkEditDialog { + count: usize, + categories: Vec, + selected_category: Option>, + add_tags: Entity, + remove_tags: Entity, + error: Option, +} + +impl EventEmitter for BulkEditDialog {} + +impl BulkEditDialog { + pub fn new(count: usize, categories: Vec, cx: &mut Context) -> Self { + Self { + count, + categories, + selected_category: None, + add_tags: cx.new(|_| TextInput::new().placeholder("comma-separated tags")), + remove_tags: cx.new(|_| TextInput::new().placeholder("comma-separated tags")), + error: None, + } + } + + fn tags(input: &Entity, cx: &gpui::App) -> Vec { + input + .read(cx) + .text() + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_owned) + .collect() + } + + fn submit(&mut self, cx: &mut Context) { + let request = BulkEditRequest { + category: self.selected_category.clone(), + add_tags: Self::tags(&self.add_tags, cx), + remove_tags: Self::tags(&self.remove_tags, cx), + }; + if request.category.is_none() + && request.add_tags.is_empty() + && request.remove_tags.is_empty() + { + self.error = Some("Choose a category or enter tags first.".to_owned()); + cx.notify(); + return; + } + cx.emit(BulkEditDialogEvent::Submit(request)); + } +} + +fn field(label: &'static str, input: Entity) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_1() + .child(Label::new(label)) + .child(input) +} + +impl Render for BulkEditDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + let clear = cx.entity(); + let mut categories = div().flex().flex_wrap().gap_1().child( + Button::new("bulk-category-none") + .label("No category") + .appearance(if self.selected_category == Some(None) { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .on_click(move |_, _, app| { + clear.update(app, |dialog, cx| { + dialog.selected_category = Some(None); + cx.notify(); + }); + }), + ); + for (index, category) in self.categories.iter().cloned().enumerate() { + let me = cx.entity(); + let selected = self.selected_category.as_ref() == Some(&Some(category.clone())); + let value = category.clone(); + categories = categories.child( + Button::new(("bulk-category", index)) + .label(category) + .appearance(if selected { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .on_click(move |_, _, app| { + me.update(app, |dialog, cx| { + dialog.selected_category = Some(Some(value.clone())); + cx.notify(); + }); + }), + ); + } + let cancel = cx.entity(); + let submit = cx.entity(); + let mut card = div() + .flex() + .flex_col() + .gap_3() + .w(px(480.)) + .p_4() + .rounded_md() + .bg(colors.surface) + .border_1() + .border_color(colors.stroke_neutral) + .child(Label::new(format!("Edit {} selected torrents", self.count))) + .child(Label::new("Set category")) + .child(categories) + .child(field("Add tags", self.add_tags.clone())) + .child(field("Remove tags", self.remove_tags.clone())); + if let Some(error) = &self.error { + card = card.child(MessageBar::new(error.clone()).intent(MessageIntent::Error)); + } + card.child( + div() + .flex() + .gap_2() + .justify_end() + .child( + Button::new("bulk-cancel") + .label("Cancel") + .on_click(move |_, _, app| { + cancel.update(app, |_, cx| cx.emit(BulkEditDialogEvent::Cancel)); + }), + ) + .child( + Button::new("bulk-apply") + .label("Apply") + .appearance(ButtonAppearance::Accent) + .on_click(move |_, _, app| { + submit.update(app, |dialog, cx| dialog.submit(cx)); + }), + ), + ) + } +} diff --git a/crates/rtbit-fluent/src/screens/detail.rs b/crates/rtbit-fluent/src/screens/detail.rs index 61da1cbe..272f5ff3 100644 --- a/crates/rtbit-fluent/src/screens/detail.rs +++ b/crates/rtbit-fluent/src/screens/detail.rs @@ -3,17 +3,25 @@ //! //! Every read and write runs on the bridge's tokio runtime. The pane keeps //! the last good [`DetailSnapshot`] in an [`AsyncResource`], so a reply from a -//! superseded selection cannot overwrite the current one. A file checkbox posts -//! the whole new selection with `update_only_files`, then reloads the detail. +//! superseded selection cannot overwrite the current one. The Files tab is a +//! `Tree` of folders and files. Double-clicking a node, or the checkbox and +//! priority buttons under the tree, posts the new selection and reloads. +//! +//! File priorities are skip, normal, high, and maximum. Folders summarize +//! their children and can apply a priority to every file below them. + +use std::collections::{BTreeMap, HashSet}; use fluent_core::AsyncResource; -use fluent_layout::{DataColumn, DataRow, DataTable, MessageBar, MessageIntent, TabItem, TabStrip}; -use fluent_primitives::{Checkbox, Label}; +use fluent_layout::{ + DataColumn, DataRow, DataTable, MessageBar, MessageIntent, TabItem, TabStrip, Tree, TreeItem, +}; +use fluent_primitives::{Button, ButtonAppearance, Checkbox, CheckboxState, Label, TextInput}; use gpui::{ - App, AsyncApp, Context, Entity, IntoElement, Render, Task, WeakEntity, Window, div, prelude::*, - px, + App, AsyncApp, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Task, + WeakEntity, Window, div, prelude::*, px, }; -use rtbit_client::Error; +use rtbit_client::{Error, TorrentFile}; use rtbit_ui_core::{DetailSnapshot, format, map_error}; use crate::bridge::{Bridge, Poller, SharedClient}; @@ -27,12 +35,33 @@ const TABS: [(&str, &str); 5] = [ ("pieces", "Pieces"), ]; +const NO_TORRENT_SELECTED: &str = "No torrent selected."; +const METADATA_RESOLVING: &str = "Torrent metadata is still resolving."; +const NO_CONNECTED_PEERS: &str = "No connected peers."; +const NO_TRACKERS: &str = "No trackers (DHT-only)."; + +/// The named empty state associated with each detail tab. +fn empty_state_message(tab: &str) -> &'static str { + match tab { + "files" | "pieces" => METADATA_RESOLVING, + "peers" => NO_CONNECTED_PEERS, + "trackers" => NO_TRACKERS, + _ => NO_TORRENT_SELECTED, + } +} + /// The detail pane for one selected torrent. pub struct DetailPane { torrent_id: Option, detail: AsyncResource, tabs: Entity, + tracker_input: Entity, error: Option, + notice: Option, + /// The Files tree node the checkbox and priority buttons act on. + selected_node: Option, + /// Folder nodes the user collapsed; folders start expanded. + collapsed: HashSet, _load: Option>, _action: Option>, _poller: Option, @@ -67,13 +96,18 @@ impl DetailPane { } strip }); + let tracker_input = cx.new(|_| TextInput::new().placeholder("Tracker URL")); // A tab click notifies the strip; re-render the pane so the body follows. cx.observe(&tabs, |_, _, cx| cx.notify()).detach(); Self { torrent_id: None, detail: AsyncResource::default(), tabs, + tracker_input, error: None, + notice: None, + selected_node: None, + collapsed: HashSet::new(), _load: None, _action: None, _poller: None, @@ -107,8 +141,15 @@ impl DetailPane { /// Show torrent `id`: load its detail on the bridge. The previous detail /// stays until the new one lands, and superseded replies are dropped. pub fn select_torrent(&mut self, id: u64, cx: &mut Context) { + if self.torrent_id != Some(id) { + self.selected_node = None; + self.collapsed.clear(); + self.tracker_input + .update(cx, |input, cx| input.set_value("", cx)); + } self.torrent_id = Some(id); self.error = None; + self.notice = None; self._load = Some(Bridge::load(self, cx, detail_of, move |client| { fetch_detail(client, id) })); @@ -119,6 +160,9 @@ impl DetailPane { self.torrent_id = None; self.detail = AsyncResource::default(); self.error = None; + self.notice = None; + self.selected_node = None; + self.collapsed.clear(); self._load = None; self._action = None; cx.notify(); @@ -145,6 +189,137 @@ impl DetailPane { let mut client = client.lock().await; client.set_file_included(id, index, included).await }); + self.await_action(id, rx, cx); + } + + /// The selected Files tree node, if any. + #[must_use] + pub fn selected_node(&self) -> Option<&SharedString> { + self.selected_node.as_ref() + } + + /// Select a Files tree node for the checkbox and priority buttons. + pub fn select_node(&mut self, node: SharedString, cx: &mut Context) { + self.selected_node = Some(node); + cx.notify(); + } + + /// Expand or collapse a folder node. + pub fn set_expanded(&mut self, node: SharedString, expanded: bool, cx: &mut Context) { + if expanded { + self.collapsed.remove(&node); + } else { + self.collapsed.insert(node); + } + cx.notify(); + } + + /// Flip a tree node: a file or every file under a folder. A fully + /// included node is skipped; anything else is included. + pub fn toggle_node(&mut self, node: &str, cx: &mut Context) { + let Some(files) = self.files() else { + return; + }; + let indices = indices_under(&files, node); + let include = inclusion(&files, &indices) != CheckboxState::Checked; + self.set_included(&indices, include, cx); + } + + /// Include (priority "Normal") or skip ("Do not download") every listed + /// file. One file posts to its own route; several post the whole new + /// selection with `update_only_files`. + pub fn set_included(&mut self, indices: &[usize], included: bool, cx: &mut Context) { + let Some(id) = self.torrent_id else { + return; + }; + let Some(files) = self.files() else { + return; + }; + let indices: Vec = indices + .iter() + .copied() + .filter(|&i| files.get(i).is_some_and(|f| f.included != included)) + .collect(); + match indices.as_slice() { + [] => {} + [index] => { + let index = *index; + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.set_file_included(id, index, included).await + }); + self.await_action(id, rx, cx); + } + _ => { + let selection = new_selection(&files, &indices, included); + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.update_only_files(id, &selection).await + }); + self.await_action(id, rx, cx); + } + } + } + + /// Set the qbit-compatible priority for every file under a tree node. + pub fn set_priority(&mut self, indices: &[usize], priority: u8, cx: &mut Context) { + let Some(id) = self.torrent_id else { return }; + let Some(files) = self.files() else { return }; + let indices: Vec = indices + .iter() + .copied() + .filter(|&index| files.get(index).is_some()) + .collect(); + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + for index in indices { + client.set_file_priority(id, index, priority).await?; + } + Ok(()) + }); + self.await_action(id, rx, cx); + } + + /// Add the tracker URL entered in the Trackers tab and reload the detail. + pub fn add_tracker(&mut self, cx: &mut Context) { + let Some(id) = self.torrent_id else { + return; + }; + let tracker = self.tracker_input.read(cx).text().trim().to_owned(); + self.notice = None; + if tracker.is_empty() { + self.error = Some("Tracker URL cannot be empty.".to_owned()); + cx.notify(); + return; + } + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.add_trackers(id, &[tracker]).await + }); + self._action = Some( + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let reply = match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(map_error(&err)), + Err(_) => Err("request cancelled".to_owned()), + }; + let _ = this.update(cx, |this, cx| this.finish_add_tracker(id, reply, cx)); + }), + ); + } + + fn files(&self) -> Option> { + self.detail + .ready_value() + .and_then(|snapshot| snapshot.torrent.files.clone()) + } + + fn await_action( + &mut self, + id: u64, + rx: tokio::sync::oneshot::Receiver>, + cx: &mut Context, + ) { self._action = Some( cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { let reply = match rx.await { @@ -167,95 +342,345 @@ impl DetailPane { cx.notify(); } + fn finish_add_tracker(&mut self, id: u64, reply: Result<(), String>, cx: &mut Context) { + match reply { + Ok(()) if self.torrent_id == Some(id) => { + self.tracker_input + .update(cx, |input, cx| input.set_value("", cx)); + self.select_torrent(id, cx); + self.notice = Some("Tracker added.".to_owned()); + } + Ok(()) => {} + Err(message) => { + self.notice = None; + self.error = Some(message); + } + } + cx.notify(); + } + fn body(&self, cx: &mut Context) -> gpui::AnyElement { let Some(snapshot) = self.detail.ready_value() else { let text = if self.torrent_id.is_some() { "Loading…" } else { - "Select a torrent to see its details." + NO_TORRENT_SELECTED }; return Label::new(text).into_any_element(); }; match self.active_tab(cx) { - "files" => files(snapshot, cx).into_any_element(), + "files" if snapshot.torrent.files.is_none() => { + Label::new(empty_state_message("files")).into_any_element() + } + "files" => files(snapshot, self, cx).into_any_element(), "peers" => peers_table(snapshot).into_any_element(), - "trackers" => trackers_table(snapshot).into_any_element(), + "trackers" => trackers_table(snapshot, self, cx).into_any_element(), + "pieces" if snapshot.pieces.have.is_empty() => { + Label::new(empty_state_message("pieces")).into_any_element() + } "pieces" => pieces_summary(snapshot).into_any_element(), _ => general(snapshot).into_any_element(), } } } -/// One checkbox per file. Checking a box posts the new selection via `toggle_file`. -fn files(snapshot: &DetailSnapshot, cx: &mut Context) -> impl IntoElement { - let me = cx.entity(); - let rows = snapshot - .torrent - .files +/// Tree node id of a folder (its path joined with `/`). +fn dir_id(path: &str) -> SharedString { + format!("dir:{path}").into() +} + +/// Tree node id of file `index`. +fn file_id(index: usize) -> SharedString { + format!("file:{index}").into() +} + +/// A file's path parts. Older daemons send no `components`; split the name. +fn parts(file: &TorrentFile) -> Vec<&str> { + if file.components.is_empty() { + file.name.split('/').filter(|p| !p.is_empty()).collect() + } else { + file.components.iter().map(String::as_str).collect() + } +} + +/// Indices of the files a node covers: one for a file, every file below for a folder. +fn indices_under(files: &[TorrentFile], node: &str) -> Vec { + if let Some(index) = node.strip_prefix("file:") { + return index + .parse() + .ok() + .filter(|i| *i < files.len()) + .into_iter() + .collect(); + } + let Some(dir) = node.strip_prefix("dir:") else { + return Vec::new(); + }; + let prefix: Vec<&str> = dir.split('/').collect(); + files .iter() - .flatten() .enumerate() - .map(|(index, file)| { + .filter(|(_, file)| { + let parts = parts(file); + parts.len() > prefix.len() && parts[..prefix.len()] == prefix[..] + }) + .map(|(i, _)| i) + .collect() +} + +/// Checkbox state of a set of files: all in, none in, or a mix. +fn inclusion(files: &[TorrentFile], indices: &[usize]) -> CheckboxState { + let included = indices.iter().filter(|&&i| files[i].included).count(); + match included { + 0 => CheckboxState::Unchecked, + n if n == indices.len() => CheckboxState::Checked, + _ => CheckboxState::Indeterminate, + } +} + +/// The priority a checkbox state stands for. +fn priority_label(priority: u8) -> &'static str { + match priority { + 0 => "Do not download", + 1 => "Normal", + 6 => "High", + 7 => "Maximum", + _ => "Mixed", + } +} + +fn effective_priority(file: &TorrentFile) -> u8 { + if !file.included && file.priority == 1 { + 0 + } else { + file.priority + } +} + +fn node_priority(files: &[TorrentFile], indices: &[usize]) -> Option { + let first = effective_priority(files.get(*indices.first()?)?); + indices + .iter() + .all(|&index| { + files + .get(index) + .is_some_and(|file| effective_priority(file) == first) + }) + .then_some(first) +} + +/// The whole selection after setting `changed` to `included`. +fn new_selection(files: &[TorrentFile], changed: &[usize], included: bool) -> Vec { + files + .iter() + .enumerate() + .filter(|(i, file)| { + if changed.contains(i) { + included + } else { + file.included + } + }) + .map(|(i, _)| i) + .collect() +} + +/// Folder and file nodes, folders first and sorted by name, files in torrent order. +fn file_tree(files: &[TorrentFile], collapsed: &HashSet) -> Vec { + let all: Vec = (0..files.len()).collect(); + level(files, &all, &[], collapsed) +} + +fn level( + files: &[TorrentFile], + indices: &[usize], + prefix: &[&str], + collapsed: &HashSet, +) -> Vec { + let mut dirs: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut leaves = Vec::new(); + for &i in indices { + let parts = parts(&files[i]); + match parts.get(prefix.len()) { + Some(name) if parts.len() > prefix.len() + 1 => dirs.entry(name).or_default().push(i), + _ => leaves.push(i), + } + } + let mut items = Vec::new(); + for (name, under) in dirs { + let mut path = prefix.to_vec(); + path.push(name); + let id = dir_id(&path.join("/")); + let size: u64 = under.iter().map(|&i| files[i].length).sum(); + let badge = format!( + "{} {}", + format::bytes(size), + priority_label(node_priority(files, &under).unwrap_or(255)) + ); + let mut item = TreeItem::new(id.clone(), name.to_owned()) + .badge(badge) + .expanded(!collapsed.contains(&id)); + item.children = level(files, &under, &path, collapsed); + items.push(item); + } + for i in leaves { + let file = &files[i]; + let name = parts(file) + .last() + .map_or(file.name.as_str(), |n| n) + .to_owned(); + let badge = format!( + "{} {}", + format::bytes(file.length), + priority_label(effective_priority(file)) + ); + items.push(TreeItem::new(file_id(i), name).badge(badge)); + } + items +} + +/// The file tree, then the selected node's checkbox and priority buttons. +/// Double-clicking a node flips it as the checkbox would. +fn files( + snapshot: &DetailSnapshot, + pane: &DetailPane, + cx: &mut Context, +) -> impl IntoElement { + let me = cx.entity(); + let list = snapshot.torrent.files.as_deref().unwrap_or_default(); + let mut tree = Tree::new() + .items(file_tree(list, &pane.collapsed)) + .on_select({ let me = me.clone(); - let checkbox = Checkbox::new(("torrent-file", index)) - .checked(file.included) - .on_click(move |_, _, _, cx| { - me.update(cx, |pane, cx| pane.toggle_file(index, cx)); - }); + move |node, _, _, cx| me.update(cx, |pane, cx| pane.select_node(node, cx)) + }) + .on_toggle({ + let me = me.clone(); + move |node, expanded, _, _, cx| { + me.update(cx, |pane, cx| pane.set_expanded(node, expanded, cx)); + } + }) + .on_activate({ + let me = me.clone(); + move |node, _, _, cx| me.update(cx, |pane, cx| pane.toggle_node(&node, cx)) + }); + if let Some(node) = &pane.selected_node { + tree = tree.selected(node.clone()); + } + let mut column = div().flex().flex_col().gap_2().child(tree); + let selected = pane + .selected_node + .as_ref() + .map(|node| (node.clone(), indices_under(list, node))) + .filter(|(_, indices)| !indices.is_empty()); + if let Some((node, indices)) = selected { + let state = inclusion(list, &indices); + let priority_button = |id: &'static str, label: &'static str, priority: u8| { + let me = me.clone(); + let indices = indices.clone(); + Button::new(id).label(label).on_click( + move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + me.update(cx, |pane, cx| pane.set_priority(&indices, priority, cx)); + }, + ) + }; + let toggle = { + let me = me.clone(); + Checkbox::new("torrent-file-node") + .state(state) + .on_click(move |_, _, _, cx| me.update(cx, |pane, cx| pane.toggle_node(&node, cx))) + }; + column = column.child( div() .flex() + .items_center() .gap_2() - .child(checkbox) + .child(toggle) .child(Label::new(format!( - "{} {}", - file.name, - format::bytes(file.length) + "Priority: {}", + node_priority(list, &indices) + .map(priority_label) + .unwrap_or("Mixed") ))) - }); - div().flex().flex_col().gap_1().children(rows) + .child(priority_button("torrent-file-normal", "Normal", 1)) + .child(priority_button("torrent-file-high", "High", 6)) + .child(priority_button("torrent-file-maximum", "Maximum", 7)) + .child(priority_button("torrent-file-skip", "Do not download", 0)), + ); + } + column } /// The piece bar's numbers: how many pieces are held out of the total. -fn pieces_summary(snapshot: &DetailSnapshot) -> impl IntoElement { +fn pieces_text(snapshot: &DetailSnapshot) -> String { let have = snapshot.pieces.have.iter().filter(|h| **h).count(); let total = snapshot.pieces.have.len(); + format!("Pieces held: {have} of {total}") +} + +fn pieces_summary(snapshot: &DetailSnapshot) -> impl IntoElement { div() .flex() .flex_col() .gap_1() - .child(Label::new(format!("Pieces held: {have} of {total}"))) + .child(Label::new(pieces_text(snapshot))) } -fn general(snapshot: &DetailSnapshot) -> impl IntoElement { +/// The General tab's lines, in display order. +fn general_lines(snapshot: &DetailSnapshot) -> Vec { let torrent = &snapshot.torrent; - let state = format::state_label(torrent.stats.as_ref().and_then(|stats| stats.state)); + let state = format::row_state_label(torrent.stats.as_ref()); let have = snapshot.pieces.have.iter().filter(|h| **h).count() as u64; - div() - .flex() - .flex_col() - .gap_1() - .child(Label::new(torrent.name.clone().unwrap_or_default())) - .child(Label::new(format!("Info hash: {}", torrent.info_hash))) - .child(Label::new(format!("Folder: {}", torrent.output_folder))) - .child(Label::new(format!("State: {state}"))) - .child(Label::new(format!( - "Pieces: {}", - format::progress(have, u64::from(torrent.total_pieces)) - ))) + let mut lines = vec![ + torrent.name.clone().unwrap_or_default(), + format!("Info hash: {}", torrent.info_hash), + format!("Folder: {}", torrent.output_folder), + format!("State: {state}"), + ]; + if let Some(error) = torrent + .stats + .as_ref() + .filter(|stats| format::is_missing_files(Some(stats))) + .and_then(|stats| stats.error.as_ref()) + { + lines.push(format!("Error: {error}")); + } + lines.push(format!( + "Pieces: {}", + format::progress(have, u64::from(torrent.total_pieces)) + )); + lines } -fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { - let rows = snapshot.peers.peers.iter().map(|peer| { - DataRow::new( - peer.addr.clone(), +fn general(snapshot: &DetailSnapshot) -> impl IntoElement { + let mut column = div().flex().flex_col().gap_1(); + for line in general_lines(snapshot) { + column = column.child(Label::new(line)); + } + column +} + +/// One row per connected peer: address, state, downloaded, uploaded. +fn peer_rows(snapshot: &DetailSnapshot) -> Vec<[String; 4]> { + snapshot + .peers + .peers + .iter() + .map(|peer| { [ peer.addr.clone(), peer.state.clone(), format::bytes(peer.fetched_bytes), format::bytes(peer.uploaded_bytes), - ], - ) - }); + ] + }) + .collect() +} + +fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { + let rows = peer_rows(snapshot) + .into_iter() + .map(|cells| DataRow::new(cells[0].clone(), cells)); DataTable::new() .columns([ DataColumn::new("addr", "Address").width(200.0), @@ -264,32 +689,64 @@ fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { DataColumn::new("up", "Uploaded").width(110.0), ]) .rows(rows) - .empty_message("No live peers") + .empty_message(empty_state_message("peers")) .max_body_height(240.0) } -fn trackers_table(snapshot: &DetailSnapshot) -> impl IntoElement { - let rows = snapshot.trackers.trackers.iter().map(|tracker| { - DataRow::new( - tracker.url.clone(), +/// One row per tracker: url, state, seeders, leechers. +fn tracker_rows(snapshot: &DetailSnapshot) -> Vec<[String; 4]> { + snapshot + .trackers + .trackers + .iter() + .map(|tracker| { [ tracker.url.clone(), format!("{:?}", tracker.state), tracker.seeders.map_or_else(String::new, |n| n.to_string()), tracker.leechers.map_or_else(String::new, |n| n.to_string()), - ], + ] + }) + .collect() +} + +fn trackers_table( + snapshot: &DetailSnapshot, + pane: &DetailPane, + cx: &mut Context, +) -> impl IntoElement { + let rows = tracker_rows(snapshot) + .into_iter() + .map(|cells| DataRow::new(cells[0].clone(), cells)); + let add = cx.listener(|this, _: &ClickEvent, _window, cx| this.add_tracker(cx)); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .gap_2() + .child(pane.tracker_input.clone()) + .child( + Button::new("add-tracker") + .label("Add") + .appearance(ButtonAppearance::Accent) + .on_click(add), + ), + ) + .child( + DataTable::new() + .columns([ + DataColumn::new("url", "Tracker").width(260.0), + DataColumn::new("state", "State").width(90.0), + DataColumn::new("seeders", "Seeders").width(80.0), + DataColumn::new("leechers", "Leechers").width(80.0), + ]) + .rows(rows) + .empty_message(empty_state_message("trackers")) + .max_body_height(240.0), ) - }); - DataTable::new() - .columns([ - DataColumn::new("url", "Tracker").width(260.0), - DataColumn::new("state", "State").width(90.0), - DataColumn::new("seeders", "Seeders").width(80.0), - DataColumn::new("leechers", "Leechers").width(80.0), - ]) - .rows(rows) - .empty_message("No trackers") - .max_body_height(240.0) } impl Render for DetailPane { @@ -304,6 +761,9 @@ impl Render for DetailPane { if let Some(message) = &self.error { pane = pane.child(MessageBar::new(message.clone()).intent(MessageIntent::Error)); } + if let Some(message) = &self.notice { + pane = pane.child(Label::new(message.clone())); + } pane.child(self.body(cx)) } } @@ -400,6 +860,21 @@ mod tests { assert_eq!(labels, ["General", "Files", "Peers", "Trackers", "Pieces"]); } + #[test] + fn every_detail_tab_has_the_expected_named_empty_state() { + assert_eq!(empty_state_message("general"), "No torrent selected."); + assert_eq!( + empty_state_message("files"), + "Torrent metadata is still resolving." + ); + assert_eq!(empty_state_message("peers"), "No connected peers."); + assert_eq!(empty_state_message("trackers"), "No trackers (DHT-only)."); + assert_eq!( + empty_state_message("pieces"), + "Torrent metadata is still resolving." + ); + } + #[gpui::test] fn pieces_tab_shows_held_out_of_total(cx: &mut TestAppContext) { let (_runtime, _server) = daemon(cx); @@ -471,4 +946,350 @@ mod tests { assert!(p.error().is_none(), "post must not fail") }); } + + fn file(path: &str, included: bool) -> TorrentFile { + TorrentFile { + name: path.to_owned(), + components: path.split('/').map(str::to_owned).collect(), + length: 10, + included, + priority: if included { 1 } else { 0 }, + } + } + + fn sample() -> Vec { + vec![ + file("show/s1/e1.mkv", true), + file("show/s1/e2.mkv", false), + file("show/notes.txt", true), + file("readme.txt", true), + ] + } + + #[test] + fn tree_nests_folders_before_files() { + let tree = file_tree(&sample(), &HashSet::new()); + let ids: Vec<&str> = tree.iter().map(|item| item.id.as_ref()).collect(); + assert_eq!(ids, ["dir:show", "file:3"]); + let show = &tree[0]; + let ids: Vec<&str> = show.children.iter().map(|item| item.id.as_ref()).collect(); + assert_eq!(ids, ["dir:show/s1", "file:2"]); + assert_eq!(show.children[0].children.len(), 2); + assert!(show.badge.as_deref().unwrap().ends_with("Mixed")); + assert!( + show.children[0].children[1] + .badge + .as_deref() + .unwrap() + .ends_with("Do not download") + ); + } + + #[test] + fn collapsed_folders_stay_collapsed() { + let collapsed: HashSet = [dir_id("show")].into_iter().collect(); + let tree = file_tree(&sample(), &collapsed); + assert!(!tree[0].expanded); + assert!(tree[0].children[0].expanded); + } + + #[test] + fn folder_covers_every_file_below_it() { + let files = sample(); + assert_eq!(indices_under(&files, "dir:show"), [0, 1, 2]); + assert_eq!(indices_under(&files, "dir:show/s1"), [0, 1]); + assert_eq!(indices_under(&files, "file:3"), [3]); + assert!(indices_under(&files, "file:9").is_empty()); + assert_eq!(inclusion(&files, &[0, 1]), CheckboxState::Indeterminate); + assert_eq!(inclusion(&files, &[0, 2]), CheckboxState::Checked); + assert_eq!(inclusion(&files, &[1]), CheckboxState::Unchecked); + } + + #[test] + fn new_selection_sets_only_the_changed_files() { + let files = sample(); + assert_eq!(new_selection(&files, &[0, 1], true), [0, 1, 2, 3]); + assert_eq!(new_selection(&files, &[0, 1, 2], false), [3]); + } + + #[gpui::test] + fn skipping_a_folder_posts_the_whole_selection(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + // Both fixture files sit at the root; skipping both leaves nothing selected. + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/update_only_files")) + .and(body_string(r#"{"only_files":[]}"#)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server), + ); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| pane.select_torrent(7, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.snapshot().is_some())); + pane.update(cx, |pane, cx| pane.set_included(&[0, 1, 0], false, cx)); + let count_posts = |runtime: &Runtime, server: &MockServer| { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|r| r.url.path() == "/torrents/7/update_only_files") + .count() + }; + drive(cx, |_| count_posts(&runtime, &server) == 1); + pane.read_with(cx, |p, _| assert!(p.error().is_none())); + } + + fn loaded(cx: &mut TestAppContext) -> (Arc, MockServer, Entity) { + let (runtime, server) = daemon(cx); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| pane.select_torrent(7, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.snapshot().is_some())); + (runtime, server, pane) + } + + fn count_posts(runtime: &Runtime, server: &MockServer, route: &str) -> usize { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|r| r.method.as_str() == "POST" && r.url.path() == route) + .count() + } + + #[gpui::test] + fn general_tab_shows_name_hash_folder_and_pieces(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let lines = general_lines(p.snapshot().unwrap()); + assert_eq!(lines[0], "example"); + assert_eq!(lines[1], "Info hash: abc"); + assert_eq!(lines[2], "Folder: /data"); + assert_eq!(lines[3], "State: —"); + assert_eq!(lines[4], "Pieces: 1 B / 10 B"); + }); + } + + #[gpui::test] + fn general_tab_is_the_default_tab(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, cx| assert_eq!(p.active_tab(cx), "general")); + } + + #[gpui::test] + fn files_tab_tree_lists_fixture_files_with_sizes_and_priority(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let files = p.files().unwrap(); + let tree = file_tree(&files, &HashSet::new()); + let ids: Vec<&str> = tree.iter().map(|i| i.id.as_ref()).collect(); + assert_eq!(ids, ["file:0", "file:1"]); + assert_eq!(tree[0].badge.as_deref(), Some("100 B Normal")); + assert_eq!(tree[1].badge.as_deref(), Some("50 B Do not download")); + }); + } + + #[test] + fn priority_label_maps_daemon_levels() { + assert_eq!(priority_label(0), "Do not download"); + assert_eq!(priority_label(1), "Normal"); + assert_eq!(priority_label(6), "High"); + assert_eq!(priority_label(7), "Maximum"); + assert_eq!(node_priority(&sample(), &[0]), Some(1)); + assert_eq!(node_priority(&sample(), &[0, 1]), None); + } + + #[gpui::test] + fn peers_tab_shows_the_peer_row(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let rows = peer_rows(p.snapshot().unwrap()); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], ["1.2.3.4:6881", "live", "20 B", "5 B"]); + }); + } + + #[gpui::test] + fn trackers_tab_shows_status_and_swarm_counts(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let rows = tracker_rows(p.snapshot().unwrap()); + assert_eq!( + rows, + [["http://tracker.example/announce", "Working", "4", "1"]] + ); + }); + } + + #[gpui::test] + fn empty_tracker_is_refused_without_dropping_selection(cx: &mut TestAppContext) { + let (_runtime, _server, pane) = loaded(cx); + pane.update(cx, |pane, cx| pane.add_tracker(cx)); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.torrent_id(), Some(7)); + assert_eq!(pane.error(), Some("Tracker URL cannot be empty.")); + }); + } + + #[gpui::test] + fn adding_tracker_posts_refreshes_and_reports_success(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/torrents/7/trackers")) + .and(body_string( + r#"{"trackers":["https://new.example/announce"]}"#, + )) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + }); + pane.update(cx, |pane, cx| { + pane.tracker_input.update(cx, |input, cx| { + input.set_value("https://new.example/announce", cx) + }); + pane.add_tracker(cx); + }); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/trackers") == 1 + }); + drive(cx, |cx| pane.read_with(cx, |pane, _| pane.notice.is_some())); + pane.read_with(cx, |pane, cx| { + assert_eq!(pane.torrent_id(), Some(7)); + assert_eq!(pane.notice.as_deref(), Some("Tracker added.")); + assert!(pane.error().is_none()); + assert!(pane.tracker_input.read(cx).text().is_empty()); + }); + } + + #[gpui::test] + fn refused_tracker_keeps_input_and_selection(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/torrents/7/trackers")) + .respond_with(ResponseTemplate::new(400).set_body_string("invalid tracker URL")) + .mount(&server) + .await; + }); + pane.update(cx, |pane, cx| { + pane.tracker_input + .update(cx, |input, cx| input.set_value("not-a-url", cx)); + pane.add_tracker(cx); + }); + drive(cx, |cx| { + pane.read_with(cx, |pane, _| pane.error().is_some()) + }); + pane.read_with(cx, |pane, cx| { + assert_eq!(pane.torrent_id(), Some(7)); + assert!(pane.error().is_some()); + assert_eq!(pane.tracker_input.read(cx).text(), "not-a-url"); + }); + } + + #[gpui::test] + fn pieces_tab_text_reports_one_of_ten(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + assert_eq!(pieces_text(p.snapshot().unwrap()), "Pieces held: 1 of 10"); + }); + } + + #[gpui::test] + fn tab_switch_follows_the_strip(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + for (i, (id, _)) in TABS.iter().enumerate() { + pane.update(cx, |pane, cx| { + pane.tabs.update(cx, |strip, _| strip.active = i); + assert_eq!(pane.active_tab(cx), *id); + }); + } + } + + #[gpui::test] + fn toggle_node_on_an_excluded_file_posts_included_true(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/1/included")) + .and(body_string(r#"{"included":true}"#)) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_node("file:1", cx)); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/files/1/included") == 1 + }); + pane.read_with(cx, |p, _| assert!(p.error().is_none())); + } + + #[gpui::test] + fn toggle_node_on_an_included_file_posts_included_false(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/0/included")) + .and(body_string(r#"{"included":false}"#)) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_node("file:0", cx)); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/files/0/included") == 1 + }); + } + + #[gpui::test] + fn including_an_already_included_file_posts_nothing(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + pane.update(cx, |pane, cx| pane.set_included(&[0], true, cx)); + cx.run_until_parked(); + assert_eq!( + count_posts(&runtime, &server, "/torrents/7/files/0/included"), + 0 + ); + } + + #[gpui::test] + fn failed_file_post_shows_an_error(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/1/included")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_file(1, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.error().is_some())); + } + + #[gpui::test] + fn clear_drops_the_snapshot_and_selection(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.update(cx, |pane, cx| { + pane.select_node("file:0".into(), cx); + assert!(pane.selected_node().is_some()); + pane.clear(cx); + }); + pane.read_with(cx, |p, _| { + assert!(p.snapshot().is_none()); + assert_eq!(p.torrent_id(), None); + assert!(p.selected_node().is_none()); + }); + } + + #[gpui::test] + fn toggle_without_a_loaded_torrent_is_ignored(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| { + pane.toggle_file(0, cx); + pane.toggle_node("file:0", cx); + }); + cx.run_until_parked(); + assert_eq!( + count_posts(&runtime, &server, "/torrents/7/files/0/included"), + 0 + ); + } } diff --git a/crates/rtbit-fluent/src/screens/main_screen.rs b/crates/rtbit-fluent/src/screens/main_screen.rs index e95502d7..69aa7f27 100644 --- a/crates/rtbit-fluent/src/screens/main_screen.rs +++ b/crates/rtbit-fluent/src/screens/main_screen.rs @@ -2,47 +2,214 @@ //! //! Rows come from the bridge snapshot poll. Sorting happens here, not in //! `DataTable`: `DataTable` sorts by display text, which orders sizes and -//! speeds wrongly. `DataTable` has no right-click hook, so the row context -//! menu opens on a double click. +//! speeds wrongly. `DataTable` has no right-click hook, so the screen catches the +//! right click on the table and maps the pointer to a row from the table geometry. +use std::cell::Cell; use std::cmp::Ordering; +use std::collections::HashSet; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; -use fluent_core::AsyncResource; +use fluent_core::{AsyncResource, ThemeProvider as _}; use fluent_layout::{ - ConfirmModal, ContextMenu, DataCell, DataColumn, DataRow, DataSortDirection, DataTable, - MessageIntent, Toast, ToastHost, ToastStack, Toolbar, ToolbarItem, + ConfirmModal, ContextMenu, ContextMenuItem, DataCell, DataColumn, DataRow, DataSortDirection, + DataTable, MessageIntent, ModalStack, Toast, ToastHost, ToastStack, }; -use fluent_primitives::{Label, LabelSize}; +use fluent_primitives::{Label, LabelSize, TextInput, Tooltip}; use gpui::{ - App, AsyncApp, ClickEvent, Context, Entity, IntoElement, Pixels, Point, Render, SharedString, - Subscription, Task, WeakEntity, Window, div, prelude::*, + App, AsyncApp, Bounds, ClipboardItem, Context, Entity, FocusHandle, Focusable as _, Hsla, + IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, + SharedString, Subscription, Task, WeakEntity, Window, actions, canvas, div, prelude::*, px, }; use rtbit_client::{Torrent, TorrentState}; use rtbit_ui_core::{AddRequest, AddSource, RowAction, Snapshot, StatusFilter, format, map_error}; -use crate::bridge::{Bridge, Poller, fetch_snapshot}; +use crate::bridge::{Bridge, LOST_AFTER_FAILURES, PollHealth, Poller, fetch_snapshot}; use crate::screens::add_dialog::{AddDialog, AddDialogEvent}; +use crate::screens::bulk_edit_dialog::{BulkEditDialog, BulkEditDialogEvent, BulkEditRequest}; use crate::screens::detail::DetailPane; +use crate::screens::torrent_edit_dialog::{ + TorrentEditDialog, TorrentEditDialogEvent, TorrentEditKind, +}; +use crate::table_prefs::TablePrefs; use crate::widgets::status_bar::StatusBar; +actions!( + main_screen, + [ + OpenAdd, + PasteMagnet, + DeleteSelected, + TogglePause, + SelectAll, + FocusFilter, + Refresh, + OpenContextMenu + ] +); + +/// Daemon reachability changes the shell reacts to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MainEvent { + /// Polls keep failing; the next attempt is in `retry_in_secs`. + DaemonLost { retry_in_secs: u64 }, + /// A poll succeeded after the daemon was reported lost. + DaemonRecovered, + /// The daemon refused the session; show the connect screen. + Unauthorised, +} + +/// Key context of the main screen. Its shortcuts are bound to this context only. +pub const KEY_CONTEXT: &str = "MainScreen"; + +#[derive(Clone, Copy)] +struct ToolbarButtonSpec { + id: &'static str, + icon: &'static str, + label: &'static str, +} + +const TOOLBAR_BUTTONS: [ToolbarButtonSpec; 4] = [ + ToolbarButtonSpec { + id: "add", + icon: "+", + label: "Add torrent", + }, + ToolbarButtonSpec { + id: "resume", + icon: "▶", + label: "Resume torrent", + }, + ToolbarButtonSpec { + id: "pause", + icon: "Ⅱ", + label: "Pause torrent", + }, + ToolbarButtonSpec { + id: "delete", + icon: "×", + label: "Delete torrent", + }, +]; + +/// Keep the command names in one place so an icon-only toolbar can never lose +/// the text used for its tooltip and accessible name. +fn toolbar_buttons() -> &'static [ToolbarButtonSpec; 4] { + &TOOLBAR_BUTTONS +} + +fn icon_toolbar_button( + spec: ToolbarButtonSpec, + background: Hsla, + focus_ring: Hsla, + on_activate: impl Fn(&mut Window, &mut App) + 'static, +) -> impl IntoElement { + debug_assert!( + !spec.label.trim().is_empty(), + "toolbar button needs a label" + ); + let on_activate = Arc::new(on_activate); + let click = on_activate.clone(); + let key = on_activate; + let button = div() + .id(spec.id) + .focusable() + .tab_stop(true) + .focus(move |style| style.border_color(focus_ring)) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + key(window, cx); + cx.stop_propagation(); + } + }) + .on_click(move |_, window, cx| click(window, cx)) + .flex() + .items_center() + .justify_center() + .size(px(32.0)) + .border_2() + .border_color(background) + .rounded(px(4.0)) + .cursor_pointer() + .child(spec.icon); + Tooltip::new(spec.label).trigger(button) +} + +fn toolbar_activate( + me: &Entity, + action: RowAction, +) -> impl Fn(&mut Window, &mut App) + 'static { + let me = me.clone(); + move |_, app| dispatch(&me, app, action) +} + +/// Contexts for the shortcuts that would clash with typing: Space, Delete, Ctrl+A and Ctrl+V +/// stay with a focused text field. +const OUTSIDE_TEXT_INPUT: &str = "MainScreen && !TextInput"; + +/// One line listing the shortcuts, shown under the table. +pub const SHORTCUT_HELP: &str = "Ctrl+O add · Ctrl+V paste magnet · Space pause/resume · Delete remove · Ctrl+A select all · Ctrl+F filter · F5 refresh · Shift+F10 menu"; + +/// Bind the main-screen shortcuts. Call once at startup. +pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("ctrl-o", OpenAdd, Some(KEY_CONTEXT)), + KeyBinding::new("ctrl-v", PasteMagnet, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("delete", DeleteSelected, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("space", TogglePause, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("ctrl-a", SelectAll, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("ctrl-f", FocusFilter, Some(KEY_CONTEXT)), + KeyBinding::new("f5", Refresh, Some(KEY_CONTEXT)), + KeyBinding::new("shift-f10", OpenContextMenu, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("menu", OpenContextMenu, Some(OUTSIDE_TEXT_INPUT)), + ]); +} + /// Torrent table, toolbar and context menu. Owns the snapshot the poll fills. pub struct MainScreen { pub(crate) snapshot: AsyncResource, filter: StatusFilter, + category_filter: Option, + tag_filter: Option, + tracker_filter: Option, status: Entity, /// Detail pane for the selected row. detail: Entity, /// Selected row id (the daemon id, or the info hash when the daemon omits one). selected: Option, + /// Rows marked by Select all or a Shift+arrow range. + marked: HashSet, + /// Fixed end of a Shift+arrow range selection. + selection_anchor: Option, + detail_open: bool, + /// Case-insensitive name filter typed in the filter box. + query: String, + filter_input: Entity, + focus: FocusHandle, + autofocused: bool, + /// Set while the delete confirmation is on screen. + delete_prompt: bool, sort: Option<(String, DataSortDirection)>, + /// Column widths overriding the defaults, persisted with the sort. + prefs: TablePrefs, + prefs_path: Option, menu: Option>, + /// Where the table was last painted, so a pointer position maps to a row. + table_geometry: Rc>, action_error: Option, applied_generation: u64, /// Add-torrent dialog, while it is open. add: Option>, _add_events: Option, _add_send: Option>, + bulk_edit: Option>, + _bulk_edit_events: Option, + torrent_edit: Option>, + _torrent_edit_events: Option, toasts: Entity, + daemon_lost: bool, pub(crate) _poller: Option, pub(crate) _load: Option>, _action: Option>, @@ -51,35 +218,137 @@ pub struct MainScreen { impl MainScreen { #[must_use] pub fn new(status: Entity, cx: &mut Context) -> Self { + let me = cx.weak_entity(); + let filter_input = cx.new(|_| { + TextInput::new() + .placeholder("Filter (Ctrl+F)") + .on_change(move |text, app| { + let _ = me.update(app, |screen, cx| screen.set_query(text.to_string(), cx)); + }) + }); + // Tests must not read or write the real config directory. + let prefs_path = if cfg!(test) { + None + } else { + TablePrefs::default_path() + }; + let prefs = prefs_path + .as_deref() + .map(TablePrefs::load) + .unwrap_or_default(); + let sort = prefs.sort.clone().map(|(column, ascending)| { + let direction = if ascending { + DataSortDirection::Ascending + } else { + DataSortDirection::Descending + }; + (column, direction) + }); Self { snapshot: AsyncResource::new(), - filter: StatusFilter::All, + filter: prefs + .status + .as_deref() + .and_then(parse_status) + .unwrap_or(StatusFilter::All), + category_filter: prefs.category.clone(), + tag_filter: prefs.tag.clone(), + tracker_filter: prefs.tracker.clone(), status, detail: cx.new(DetailPane::new), selected: None, - sort: None, + marked: HashSet::new(), + selection_anchor: None, + detail_open: false, + query: String::new(), + filter_input, + focus: cx.focus_handle(), + autofocused: false, + delete_prompt: false, + sort, + prefs, + prefs_path, menu: None, + table_geometry: Rc::default(), action_error: None, applied_generation: 0, add: None, _add_events: None, _add_send: None, + bulk_edit: None, + _bulk_edit_events: None, + torrent_edit: None, + _torrent_edit_events: None, toasts: cx.new(ToastHost::new), + daemon_lost: false, _poller: None, _load: None, _action: None, } } + #[cfg(test)] + pub(crate) fn start_polling_for_test(&mut self, cx: &mut Context) { + self.ensure_polling(cx); + } + + #[cfg(test)] + pub(crate) fn add_open(&self) -> bool { + self.add.is_some() + } + + /// Whether the daemon is reported lost. Actions that need it are refused meanwhile. + #[must_use] + pub fn daemon_lost(&self) -> bool { + self.daemon_lost + } + + /// Fold a poll outcome into the lost state and tell the shell on a change. + fn on_poll_health(&mut self, health: PollHealth, cx: &mut Context) { + match health { + PollHealth::Ok => { + if std::mem::take(&mut self.daemon_lost) { + cx.emit(MainEvent::DaemonRecovered); + cx.notify(); + } + } + PollHealth::Failing { failures, retry_in } if failures >= LOST_AFTER_FAILURES => { + self.daemon_lost = true; + cx.emit(MainEvent::DaemonLost { + retry_in_secs: retry_in.as_secs(), + }); + cx.notify(); + } + PollHealth::Failing { .. } => {} + PollHealth::Unauthorised => { + self.daemon_lost = false; + self._poller = None; + cx.emit(MainEvent::Unauthorised); + } + } + } + + /// Refuse an action that needs the daemon while it is lost. + fn refuse_if_lost(&mut self, cx: &mut Context) -> bool { + if self.daemon_lost { + self.action_error = Some("Lost connection to daemon.".to_owned()); + cx.notify(); + } + self.daemon_lost + } + /// Open the add-torrent dialog over the table. pub(crate) fn open_add(&mut self, cx: &mut Context) { + if self.refuse_if_lost(cx) { + return; + } self.menu = None; let dialog = cx.new(AddDialog::new); let events = cx.subscribe( &dialog, |screen: &mut Self, dialog: Entity, event: &AddDialogEvent, cx| match event { AddDialogEvent::Cancel => screen.close_add(cx), - AddDialogEvent::Submit(request) => screen.send_add(dialog, request.clone(), cx), + AddDialogEvent::Submit(requests) => screen.send_add(dialog, requests.clone(), cx), }, ); self.add = Some(dialog); @@ -87,16 +356,92 @@ impl MainScreen { cx.notify(); } + pub(crate) fn open_add_with_magnet(&mut self, magnet: &str, cx: &mut Context) { + self.open_add(cx); + if let Some(dialog) = &self.add { + dialog.update(cx, |dialog, cx| dialog.prefill_magnet(magnet.trim(), cx)); + } + } + + pub(crate) fn open_add_with_paths(&mut self, paths: &[PathBuf], cx: &mut Context) { + self.open_add(cx); + if paths.is_empty() { + return; + } + let Some(dialog) = &self.add else { return }; + let dialog = dialog.clone(); + let paths = paths.to_owned(); + let task = cx.background_executor().spawn(async move { + paths + .into_iter() + .map(|path| { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("torrent") + .to_owned(); + (name, std::fs::read(path)) + }) + .collect::>() + }); + cx.spawn(async move |_, cx| { + let results = task.await; + dialog.update(cx, |dialog, cx| { + let mut files = Vec::new(); + for (name, result) in results { + match result { + Ok(bytes) => files.push((name, bytes)), + Err(err) => { + dialog.set_error(format!("could not read torrent: {err}"), cx); + return; + } + } + } + dialog.set_files(files, cx); + }); + }) + .detach(); + } + + pub(crate) fn show_not_torrent_toast(&mut self, cx: &mut Context) { + ToastStack::push( + Toast::new("not-torrent", "That drop is not a torrent.").intent(MessageIntent::Warning), + cx, + ); + } + + /// Open the add dialog with the clipboard's magnet link filled in. Does nothing when the + /// clipboard holds anything else. + fn paste_magnet(&mut self, cx: &mut Context) { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + let magnet = text.trim(); + if !magnet.starts_with("magnet:?") { + return; + } + self.open_add(cx); + if let Some(dialog) = &self.add { + dialog.update(cx, |dialog, cx| dialog.prefill_magnet(magnet, cx)); + } + } + /// Close the add dialog. Nothing is sent. fn close_add(&mut self, cx: &mut Context) { self.add = None; self._add_events = None; + self.autofocused = false; cx.notify(); } /// Send an add request through the bridge. The dialog closes on success and /// shows the daemon's reason on failure. - fn send_add(&mut self, dialog: Entity, request: AddRequest, cx: &mut Context) { + fn send_add( + &mut self, + dialog: Entity, + requests: Vec, + cx: &mut Context, + ) { if !cx.has_global::() { dialog.update(cx, |dialog, cx| { dialog.refused("Not connected to a daemon.".to_owned(), cx); @@ -104,31 +449,39 @@ impl MainScreen { return; } let rx = cx.global::().call(move |client| async move { - let AddRequest { + let mut client = client.lock().await; + for AddRequest { source, save_path, category, start_paused, - } = request; - let category = (!category.is_empty()).then_some(category); - let mut client = client.lock().await; - match source { - AddSource::Magnet(magnet) => { - client - .add_magnet(&magnet, Some(&save_path), category.as_deref(), start_paused) - .await - } - AddSource::File { bytes, .. } => { - client - .add_torrent_file( - &bytes, - Some(&save_path), - category.as_deref(), - start_paused, - ) - .await + } in requests + { + let category = (!category.is_empty()).then_some(category); + match source { + AddSource::Magnet(magnet) => { + client + .add_magnet( + &magnet, + Some(&save_path), + category.as_deref(), + start_paused, + ) + .await? + } + AddSource::File { bytes, .. } => { + client + .add_torrent_file( + &bytes, + Some(&save_path), + category.as_deref(), + start_paused, + ) + .await? + } } } + Ok(()) }); self._add_send = Some( cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { @@ -156,8 +509,84 @@ impl MainScreen { /// Set the sidebar status filter the table shows. pub fn set_filter(&mut self, filter: StatusFilter, cx: &mut Context) { self.filter = filter; + self.prefs.status = (filter != StatusFilter::All).then(|| filter.label().to_owned()); + self.save_prefs(); + cx.notify(); + } + + fn set_category(&mut self, value: Option, cx: &mut Context) { + self.category_filter = value; + self.save_filter_prefs(); + cx.notify(); + } + fn set_tag(&mut self, value: Option, cx: &mut Context) { + self.tag_filter = value; + self.save_filter_prefs(); + cx.notify(); + } + fn set_tracker(&mut self, value: Option, cx: &mut Context) { + self.tracker_filter = value; + self.save_filter_prefs(); cx.notify(); } + fn save_filter_prefs(&mut self) { + self.prefs.category = self.category_filter.clone(); + self.prefs.tag = self.tag_filter.clone(); + self.prefs.tracker = self.tracker_filter.clone(); + self.save_prefs(); + } + + /// Filter the table to names containing `query`, ignoring case. + pub(crate) fn set_query(&mut self, query: String, cx: &mut Context) { + self.query = query.to_lowercase(); + cx.notify(); + } + + /// Mark every visible row, so the next command acts on all of them. + fn select_all(&mut self, cx: &mut Context) { + self.marked = self.visible_rows().into_iter().map(row_id).collect(); + cx.notify(); + } + + /// Number of rows marked by Select all. + #[must_use] + pub fn marked_count(&self) -> usize { + self.marked.len() + } + + /// Torrents a command applies to: the marked rows, else the selected row. + fn targets(&self) -> Vec<&Torrent> { + if self.marked.is_empty() { + return self.selected_torrent().into_iter().collect(); + } + self.snapshot.value().map_or_else(Vec::new, |snapshot| { + snapshot + .torrents + .torrents + .iter() + .filter(|torrent| self.marked.contains(&row_id(torrent))) + .collect() + }) + } + + /// Pause what is running and resume what is paused, for each target. + fn toggle_pause(&mut self, cx: &mut Context) { + let jobs: Vec<(u64, Job)> = self + .targets() + .into_iter() + .filter_map(|torrent| { + let paused = torrent.stats.as_ref().and_then(|stats| stats.state) + == Some(TorrentState::Paused); + let action = if paused { + RowAction::Start + } else { + RowAction::Pause + }; + torrent.id.map(|id| (id, Job::Row(action))) + }) + .collect(); + self.run_jobs(jobs, cx); + } /// Number of torrents in the last good snapshot, before filters. #[must_use] @@ -180,6 +609,9 @@ impl MainScreen { /// Select a row and load its detail. A row with no daemon id clears the pane. fn select_row(&mut self, row: &str, cx: &mut Context) { self.selected = Some(row.to_owned()); + self.marked.clear(); + self.selection_anchor = Some(row.to_owned()); + self.detail_open = true; let id = self.selected_torrent().and_then(|torrent| torrent.id); self.detail.update(cx, |pane, cx| match id { Some(id) => pane.select_torrent(id, cx), @@ -188,6 +620,94 @@ impl MainScreen { cx.notify(); } + /// Move the keyboard cursor through the visible, sorted rows. + fn move_selection( + &mut self, + movement: SelectionMovement, + extend: bool, + cx: &mut Context, + ) { + let rows = self + .visible_rows() + .into_iter() + .map(row_id) + .collect::>(); + if rows.is_empty() { + return; + } + let current = self + .selected + .as_ref() + .and_then(|id| rows.iter().position(|row| row == id)) + .unwrap_or(0); + let next = match movement { + SelectionMovement::Previous => current.saturating_sub(1), + SelectionMovement::Next => (current + 1).min(rows.len() - 1), + SelectionMovement::First => 0, + SelectionMovement::Last => rows.len() - 1, + }; + let next_row = rows[next].clone(); + let anchor = if extend { + self.selection_anchor + .clone() + .unwrap_or_else(|| rows[current].clone()) + } else { + next_row.clone() + }; + self.selected = Some(next_row.clone()); + self.selection_anchor = Some(anchor.clone()); + self.marked.clear(); + if extend { + let anchor_index = rows.iter().position(|row| row == &anchor).unwrap_or(next); + let (start, end) = if anchor_index <= next { + (anchor_index, next) + } else { + (next, anchor_index) + }; + self.marked.extend(rows[start..=end].iter().cloned()); + } else { + self.marked.insert(next_row); + } + cx.notify(); + } + + fn open_detail(&mut self, cx: &mut Context) { + let Some(id) = self.selected_torrent().and_then(|torrent| torrent.id) else { + return; + }; + self.detail_open = true; + self.detail + .update(cx, |pane, cx| pane.select_torrent(id, cx)); + cx.notify(); + } + + fn close_detail(&mut self, cx: &mut Context) { + if self.detail_open { + self.detail_open = false; + cx.notify(); + } + } + + fn handle_table_key(&mut self, event: &KeyDownEvent, cx: &mut Context) -> bool { + let movement = match event.keystroke.key.as_str() { + "up" => Some(SelectionMovement::Previous), + "down" => Some(SelectionMovement::Next), + "home" => Some(SelectionMovement::First), + "end" => Some(SelectionMovement::Last), + "return" => { + self.open_detail(cx); + return true; + } + "escape" => { + self.close_detail(cx); + return true; + } + _ => return false, + }; + self.move_selection(movement.unwrap(), event.keystroke.modifiers.shift, cx); + true + } + /// Ask for a fresh snapshot now, in addition to the poll. fn refresh(&mut self, cx: &mut Context) { if !cx.has_global::() { @@ -196,10 +716,18 @@ impl MainScreen { self._load = Some(Bridge::load(self, cx, snapshot_of, fetch_snapshot)); } + pub(crate) fn on_poll_health_cb(screen: &mut Self, health: PollHealth, cx: &mut Context) { + screen.on_poll_health(health, cx); + } + /// Start the poll once a bridge is installed. Idempotent. fn ensure_polling(&mut self, cx: &mut Context) { if self._poller.is_none() && cx.has_global::() { - self._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + self._poller = Some(Bridge::poll_snapshot( + cx, + snapshot_of, + Self::on_poll_health_cb, + )); } } @@ -216,34 +744,325 @@ impl MainScreen { } } - fn open_menu(&mut self, row: &str, position: Point, cx: &mut Context) { - self.select_row(row, cx); + /// Right click at `position`: act on the row under the pointer. A row outside the marked + /// set becomes the selection first; a row inside it keeps the whole marked set. + fn open_menu_at_pointer(&mut self, position: Point, cx: &mut Context) { + let Some(row) = self.row_at(position).map(row_id) else { + return; + }; + if !self.marked.contains(&row) { + self.select_row(&row, cx); + } + self.open_menu(position, cx); + } + + /// Shift+F10 or the Menu key: open the menu beside the selected row. + fn open_menu_for_selection(&mut self, cx: &mut Context) { + let geometry = self.table_geometry.get(); + let index = self.selected.as_deref().and_then(|selected| { + self.visible_rows() + .iter() + .position(|torrent| row_id(torrent) == selected) + }); + let Some(index) = index else { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + }; + let position = geometry.row_origin(index); + self.open_menu(position, cx); + } + + /// Table row under a pointer position, from the painted geometry. + fn row_at(&self, position: Point) -> Option<&Torrent> { + let index = self.table_geometry.get().row_index(position)?; + self.visible_rows().into_iter().nth(index) + } + + /// Build the menu for the current targets. Items that cannot apply are disabled. + fn open_menu(&mut self, position: Point, cx: &mut Context) { + let targets = self.targets(); + let available = MenuAvailability::of(&targets); + let categories = category_names(self.snapshot.value().map(|s| &s.torrents.torrents[..])); let screen = cx.entity(); - let (resume, pause, delete) = (screen.clone(), screen.clone(), screen); - let menu = cx.new(|_| { - ContextMenu::build(position) - .action("Resume", move |_, _, app| { - dispatch(&resume, app, RowAction::Start); - }) - .action("Pause", move |_, _, app| { - dispatch(&pause, app, RowAction::Pause); - }) - .separator() - .action("Delete…", move |_, _, app| { - dispatch(&delete, app, RowAction::Delete); - }) + let item = |label: &str, enabled: bool, command: MenuCommand| { + let screen = screen.clone(); + ContextMenuItem::action(label.to_owned(), move |_, _, app| { + run_menu_command(&screen, app, command.clone()); + }) + .disabled(!enabled) + }; + let mut category_items = vec![item( + "No category", + available.category, + MenuCommand::SetCategory(None), + )]; + category_items.extend(categories.into_iter().map(|name| { + item( + &name.clone(), + available.category, + MenuCommand::SetCategory(Some(name)), + ) + })); + let menu = ContextMenu::build(position) + .item(item("Start", available.start, MenuCommand::Start)) + .item(item("Stop", available.stop, MenuCommand::Stop)) + .item(item( + "Force recheck", + available.recheck, + MenuCommand::Recheck, + )) + .separator() + .item( + ContextMenuItem::submenu("Set category", category_items) + .disabled(!available.category), + ) + .item(item( + "Edit category and tags…", + !targets.is_empty(), + MenuCommand::BulkEdit, + )) + .item(item( + "Set location…", + available.edit, + MenuCommand::SetLocation, + )) + .item(item("Rename…", available.edit, MenuCommand::Rename)) + .separator() + .item(item( + "Copy magnet link", + available.copy, + MenuCommand::CopyMagnet, + )) + .item(item("Copy name", available.copy, MenuCommand::CopyName)) + .separator() + .item(item("Delete…", available.delete, MenuCommand::Delete)); + self.menu = Some(cx.new(|_| menu)); + cx.notify(); + } + + /// Run a context-menu command on the whole selection. + fn run_menu_command(&mut self, command: MenuCommand, cx: &mut Context) { + self.menu = None; + match command { + MenuCommand::BulkEdit => self.open_bulk_edit(cx), + MenuCommand::SetLocation => self.open_torrent_edit(TorrentEditKind::Location, cx), + MenuCommand::Rename => self.open_torrent_edit(TorrentEditKind::Name, cx), + MenuCommand::Delete => self.request(RowAction::Delete, cx), + MenuCommand::CopyMagnet | MenuCommand::CopyName => { + let text = copy_text(&self.targets(), &command); + match text { + Some(text) => { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.action_error = None; + ToastStack::push( + Toast::new("copied", "Copied to clipboard.") + .intent(MessageIntent::Success), + cx, + ); + } + None => self.action_error = Some("Select a torrent first.".to_owned()), + } + cx.notify(); + } + command => { + if self.refuse_if_lost(cx) { + return; + } + let jobs = menu_jobs(&self.targets(), &command); + self.run_jobs(jobs, cx); + } + } + } + + fn open_torrent_edit(&mut self, kind: TorrentEditKind, cx: &mut Context) { + let Some(target) = self.targets().first().copied() else { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + }; + let initial = match kind { + TorrentEditKind::Location => target.output_folder.clone(), + TorrentEditKind::Name => torrent_name(target), + }; + let dialog = cx.new(|cx| TorrentEditDialog::new(kind, initial, cx)); + let events = cx.subscribe(&dialog, move |screen, dialog, event, cx| match event { + TorrentEditDialogEvent::Cancel => { + screen.torrent_edit = None; + screen._torrent_edit_events = None; + cx.notify(); + } + TorrentEditDialogEvent::Submit(value) => { + screen.send_torrent_edit(dialog.clone(), kind, value.clone(), cx); + } }); - self.menu = Some(menu); + self.torrent_edit = Some(dialog); + self._torrent_edit_events = Some(events); cx.notify(); } + fn send_torrent_edit( + &mut self, + dialog: Entity, + kind: TorrentEditKind, + value: String, + cx: &mut Context, + ) { + let Some(id) = self.targets().first().and_then(|torrent| torrent.id) else { + dialog.update(cx, |dialog, cx| { + dialog.set_error("This torrent has no daemon id.".to_owned(), cx) + }); + return; + }; + let Some(bridge) = cx.try_global::() else { + dialog.update(cx, |dialog, cx| { + dialog.set_error("Not connected to a daemon.".to_owned(), cx) + }); + return; + }; + let rx = bridge.call(move |client| async move { + let mut client = client.lock().await; + match kind { + TorrentEditKind::Location => client.set_torrent_location(id, &value).await, + TorrentEditKind::Name => client.rename_torrent(id, &value).await, + } + }); + self._action = Some(cx.spawn(async move |view, cx| { + let reply = match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(map_error(&error)), + Err(_) => Err("request cancelled".to_owned()), + }; + let _ = view.update(cx, |screen, cx| match reply { + Ok(()) => { + screen.torrent_edit = None; + screen._torrent_edit_events = None; + screen.refresh(cx); + } + Err(error) => dialog.update(cx, |dialog, cx| dialog.set_error(error, cx)), + }); + })); + } + + fn open_bulk_edit(&mut self, cx: &mut Context) { + let count = self.targets().len(); + if count == 0 { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + } + let categories = category_names(self.snapshot.value().map(|s| &s.torrents.torrents[..])); + let dialog = cx.new(|cx| BulkEditDialog::new(count, categories, cx)); + let events = cx.subscribe(&dialog, |screen, _, event, cx| match event { + BulkEditDialogEvent::Cancel => { + screen.bulk_edit = None; + screen._bulk_edit_events = None; + cx.notify(); + } + BulkEditDialogEvent::Submit(request) => screen.send_bulk_edit(request.clone(), cx), + }); + self.bulk_edit = Some(dialog); + self._bulk_edit_events = Some(events); + cx.notify(); + } + + fn send_bulk_edit(&mut self, request: BulkEditRequest, cx: &mut Context) { + let targets = self + .targets() + .into_iter() + .filter_map(|torrent| torrent.id.map(|id| (id, torrent.name.clone()))) + .collect::>(); + let ids = targets.iter().map(|(id, _)| *id).collect::>(); + let names = targets + .into_iter() + .collect::>(); + let Some(bridge) = cx.try_global::() else { + self.action_error = Some("Not connected to a daemon.".to_owned()); + cx.notify(); + return; + }; + self.bulk_edit = None; + self._bulk_edit_events = None; + let rx = bridge.call(move |client| async move { + let mut client = client.lock().await; + let mut failures = Vec::new(); + if let Some(category) = request.category.as_ref() { + failures.extend( + client + .set_torrents_category(&ids, category.as_deref()) + .await? + .failures, + ); + } + if !request.add_tags.is_empty() { + failures.extend( + client + .add_torrents_tags(&ids, &request.add_tags) + .await? + .failures, + ); + } + if !request.remove_tags.is_empty() { + failures.extend( + client + .remove_torrents_tags(&ids, &request.remove_tags) + .await? + .failures, + ); + } + Ok(failures) + }); + self._action = Some(cx.spawn(async move |view, cx| { + let result = match rx.await { + Ok(result) => result, + Err(_) => Err(rtbit_client::Error::Unreachable( + "request cancelled".to_owned(), + )), + }; + let _ = view.update(cx, |screen, cx| match result { + Ok(failures) if failures.is_empty() => { + screen.action_error = None; + screen.refresh(cx); + } + Ok(failures) => { + screen.action_error = Some( + failures + .into_iter() + .map(|failure| { + let name = names + .get(&failure.id) + .and_then(|name| name.as_deref()) + .unwrap_or("unnamed torrent"); + format!("{name}: {}", failure.error) + }) + .collect::>() + .join("; "), + ); + cx.notify(); + } + Err(error) => { + screen.action_error = Some(map_error(&error)); + cx.notify(); + } + }); + })); + } + /// Run a toolbar or menu command. Delete asks first. fn request(&mut self, action: RowAction, cx: &mut Context) { self.menu = None; - let Some(name) = self.selected_torrent().map(torrent_name) else { - self.action_error = Some("Select a torrent first.".to_owned()); - cx.notify(); + if self.refuse_if_lost(cx) { return; + } + let targets = self.targets(); + let name = match targets.as_slice() { + [] => { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + } + [only] => format!("“{}”", torrent_name(only)), + many => format!("{} torrents", many.len()), }; if action != RowAction::Delete { self.act(action, cx); @@ -252,36 +1071,59 @@ impl MainScreen { let me = cx.entity(); ConfirmModal::new( "Delete torrent?", - format!("“{name}” will be removed from the daemon."), + format!("{name} will be removed from the daemon."), ) .confirm_label("Delete") .on_confirm(move |app| { confirm_delete(&me, app); }) .show(cx); + self.delete_prompt = true; cx.notify(); } - /// Send one row command to the daemon, then refresh the table. + /// Send one row command for every target, then refresh the table. fn act(&mut self, action: RowAction, cx: &mut Context) { + let jobs = self + .targets() + .into_iter() + .filter_map(|torrent| torrent.id.map(|id| (id, Job::Row(action)))) + .collect(); + self.run_jobs(jobs, cx); + } + + /// Send row commands to the daemon in order, then refresh the table. + fn run_jobs(&mut self, jobs: Vec<(u64, Job)>, cx: &mut Context) { self.menu = None; - let Some(id) = self.selected_torrent().and_then(|torrent| torrent.id) else { + self.delete_prompt = false; + if jobs.is_empty() { self.action_error = Some("Select a torrent first.".to_owned()); cx.notify(); return; - }; + } if !cx.has_global::() { self.action_error = Some("Not connected to a daemon.".to_owned()); cx.notify(); return; } + let deleting = jobs + .iter() + .any(|(_, job)| *job == Job::Row(RowAction::Delete)); let rx = cx.global::().call(move |client| async move { let mut client = client.lock().await; - match action { - RowAction::Start => client.start_torrent(id).await, - RowAction::Pause => client.pause_torrent(id).await, - RowAction::Delete => client.delete_torrent(id).await, + for (id, job) in jobs { + let sent = match job { + Job::Row(RowAction::Start) => client.start_torrent(id).await, + Job::Row(RowAction::Pause) => client.pause_torrent(id).await, + Job::Row(RowAction::Delete) => client.delete_torrent(id).await, + Job::Recheck => client.recheck_torrent(id).await, + Job::Category(category) => { + client.set_torrent_category(id, category.as_deref()).await + } + }; + sent?; } + Ok(()) }); self._action = Some( cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { @@ -293,8 +1135,9 @@ impl MainScreen { let _ = view.update(cx, |screen, cx| match reply { Ok(()) => { screen.action_error = None; - if action == RowAction::Delete { + if deleting { screen.selected = None; + screen.marked.clear(); screen.detail.update(cx, |pane, cx| pane.clear(cx)); } screen.refresh(cx); @@ -310,9 +1153,33 @@ impl MainScreen { fn sort_by(&mut self, column: &str, direction: DataSortDirection, cx: &mut Context) { self.sort = Some((column.to_owned(), direction)); + self.prefs.sort = Some(( + column.to_owned(), + matches!(direction, DataSortDirection::Ascending), + )); + self.save_prefs(); cx.notify(); } + /// Remember a column width (clamped to the minimum) and persist it. + #[allow(dead_code)] // No resize handle in `DataTable` yet; widths come from the file. + pub(crate) fn set_column_width(&mut self, column: &str, width: f32, cx: &mut Context) { + let width = width.max(crate::table_prefs::MIN_COLUMN_WIDTH); + self.prefs.widths.insert(column.to_owned(), width); + self.save_prefs(); + cx.notify(); + } + + fn column_width(&self, column: &str, default: f32) -> f32 { + self.prefs.widths.get(column).copied().unwrap_or(default) + } + + fn save_prefs(&self) { + if let Some(path) = &self.prefs_path { + let _ = self.prefs.save(path); + } + } + /// Rows for the table: filtered by the sidebar status, then sorted by the column. fn visible_rows(&self) -> Vec<&Torrent> { let Some(snapshot) = self.snapshot.value() else { @@ -323,6 +1190,30 @@ impl MainScreen { .torrents .iter() .filter(|torrent| matches_filter(self.filter, torrent)) + .filter(|torrent| { + self.category_filter + .as_deref() + .is_none_or(|v| torrent.category.as_deref() == Some(v)) + }) + .filter(|torrent| { + self.tag_filter.as_deref().is_none_or(|v| { + torrent + .tags + .as_ref() + .is_some_and(|tags| tags.iter().any(|tag| tag == v)) + }) + }) + .filter(|torrent| { + self.tracker_filter.as_deref().is_none_or(|v| { + torrent.trackers.as_ref().is_some_and(|urls| { + urls.iter() + .any(|url| tracker_host(url).as_deref() == Some(v)) + }) + }) + }) + .filter(|torrent| { + self.query.is_empty() || torrent_name(torrent).to_lowercase().contains(&self.query) + }) .collect(); if let Some((column, direction)) = &self.sort { rows.sort_by(|a, b| { @@ -338,17 +1229,19 @@ impl MainScreen { fn table(&self, me: &Entity) -> DataTable { let columns = [ - DataColumn::new("name", "Name").width(320.0).sortable(true), - DataColumn::new("size", "Size").width(90.0).sortable(true), - DataColumn::new("progress", "Progress") - .width(90.0) - .sortable(true), - DataColumn::new("speed", "Down").width(110.0).sortable(true), - DataColumn::new("eta", "ETA").width(90.0).sortable(true), - DataColumn::new("state", "State") - .width(110.0) - .sortable(true), - ]; + ("name", "Name", 320.0), + ("size", "Size", 90.0), + ("progress", "Progress", 90.0), + ("speed", "Down", 110.0), + ("upload", "Up", 110.0), + ("eta", "ETA", 90.0), + ("state", "State", 110.0), + ] + .map(|(id, title, width)| { + DataColumn::new(id, title) + .width(self.column_width(id, width)) + .sortable(true) + }); let rows = self.visible_rows().into_iter().map(|torrent| { DataRow::new( row_id(torrent), @@ -357,6 +1250,7 @@ impl MainScreen { DataCell::new(size_text(torrent)), DataCell::new(progress_text(torrent)), DataCell::new(speed_text(torrent)), + DataCell::new(upload_text(torrent)), DataCell::new(eta_text(torrent)), DataCell::new(state_of(torrent)), ], @@ -374,27 +1268,160 @@ impl MainScreen { let id = id.to_string(); select_me.update(app, |screen, cx| screen.select_row(&id, cx)); }) - .on_row_click(move |id: SharedString, event: &ClickEvent, _, app| { - if event.click_count() < 2 { - return; - } + .on_row_click(move |id: SharedString, _, _, app| { let id = id.to_string(); - let position = event.position(); - click_me.update(app, |screen, cx| screen.open_menu(&id, position, cx)); + click_me.update(app, |screen, cx| screen.select_row(&id, cx)); }) .on_sort(move |column: SharedString, direction, _, _, app| { let column = column.to_string(); sort_me.update(app, |screen, cx| screen.sort_by(&column, direction, cx)); }); + if let Some((column, direction)) = &self.sort { + table = table.sort(column.clone(), *direction); + } if let Some(selected) = &self.selected { table = table.selected(selected.clone()); } table } + fn filter_sidebar( + &self, + me: &Entity, + background: Hsla, + accent: Hsla, + stroke: Hsla, + ) -> impl IntoElement { + let rows = self + .snapshot + .value() + .map(|s| &s.torrents.torrents[..]) + .unwrap_or(&[]); + let mut panel = div() + .id("filter-sidebar") + .flex() + .flex_col() + .gap_1() + .w(px(190.0)) + .p_2() + .border_1() + .border_color(stroke) + .child(Label::new("Filters").size(LabelSize::Body)); + for status in StatusFilter::ALL { + let target = me.clone(); + let count = rows + .iter() + .filter(|torrent| matches_filter(status, torrent)) + .count(); + let label = format!("{} ({count})", status.label()); + panel = panel.child( + div() + .id(format!("status-{}", status.label())) + .px_2() + .py_1() + .bg(if self.filter == status { + accent + } else { + background + }) + .cursor_pointer() + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| screen.set_filter(status, cx)) + }) + .child(Label::new(label).size(LabelSize::Caption)), + ); + } + let mut categories: Vec = rows + .iter() + .filter_map(|t| t.category.clone()) + .filter(|v| !v.is_empty()) + .collect(); + categories.sort(); + categories.dedup(); + panel = panel.child(Label::new("Categories").size(LabelSize::Caption)); + for value in categories { + let target = me.clone(); + let selected = self.category_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("category-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_category((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + let mut tags: Vec = rows + .iter() + .flat_map(|t| t.tags.clone().unwrap_or_default()) + .collect(); + tags.sort(); + tags.dedup(); + panel = panel.child(Label::new("Tags").size(LabelSize::Caption)); + for value in tags { + let target = me.clone(); + let selected = self.tag_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("tag-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_tag((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + let mut trackers: Vec = rows + .iter() + .flat_map(|torrent| torrent.trackers.clone().unwrap_or_default()) + .filter_map(|url| tracker_host(&url)) + .collect(); + trackers.sort(); + trackers.dedup(); + panel = panel.child(Label::new("Trackers").size(LabelSize::Caption)); + for value in trackers { + let target = me.clone(); + let selected = self.tracker_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("tracker-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_tracker((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + panel + } } +impl gpui::EventEmitter for MainScreen {} + impl Render for MainScreen { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + self.autofocused = true; + window.focus(&self.focus, cx); + } self.ensure_polling(cx); self.apply_to_status(cx); if self.menu.as_ref().is_some_and(|menu| !menu.read(cx).open) { @@ -403,50 +1430,369 @@ impl Render for MainScreen { let me = cx.entity(); let add_me = me.clone(); - let toolbar = Toolbar::new().items([ - ToolbarItem::action("add", "Add", move |_, _, app| { - add_me.update(app, |screen, cx| screen.open_add(cx)); - }), - ToolbarItem::separator(), - ToolbarItem::action("resume", "Resume", toolbar_click(&me, RowAction::Start)), - ToolbarItem::action("pause", "Pause", toolbar_click(&me, RowAction::Pause)), - ToolbarItem::separator(), - ToolbarItem::action("delete", "Delete", toolbar_click(&me, RowAction::Delete)), - ]); + let colors = cx.theme().colors.clone(); + let components = &cx.theme().components; + let (header_height, row_height) = ( + px(components.content_tab_strip_height), + px(components.menu_item_height), + ); + let geometry = self.table_geometry.clone(); + let toolbar = div() + .id("torrent-toolbar") + .flex() + .items_center() + .gap_1() + .p_1() + .bg(colors.surface_dim) + .child(icon_toolbar_button( + toolbar_buttons()[0], + colors.surface_dim, + colors.stroke_accent, + move |_, app| add_me.update(app, |screen, cx| screen.open_add(cx)), + )) + .child(icon_toolbar_button( + toolbar_buttons()[1], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Start), + )) + .child(icon_toolbar_button( + toolbar_buttons()[2], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Pause), + )) + .child(icon_toolbar_button( + toolbar_buttons()[3], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Delete), + )); let message = self .action_error .clone() .or_else(|| self.snapshot.error().cloned()); let mut root = div() + .key_context(KEY_CONTEXT) + .track_focus(&self.focus) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, window, cx| { + if !screen.delete_prompt { + return; + } + match event.keystroke.key.as_str() { + "escape" => { + screen.delete_prompt = false; + ModalStack::pop(cx); + window.focus(&screen.focus, cx); + cx.notify(); + cx.stop_propagation(); + } + "enter" => { + screen.delete_prompt = false; + ModalStack::pop(cx); + screen.act(RowAction::Delete, cx); + window.focus(&screen.focus, cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|screen, _, window, cx| { + if !screen.focus.contains_focused(window, cx) { + window.focus(&screen.focus, cx); + } + }), + ) + .on_action(cx.listener(|screen, _: &OpenAdd, _, cx| { + if screen.add.is_none() { + screen.open_add(cx); + } + })) + .on_action(cx.listener(|screen, _: &PasteMagnet, _, cx| { + if screen.add.is_none() { + screen.paste_magnet(cx); + } + })) + .on_action(cx.listener(|screen, _: &DeleteSelected, _, cx| { + if screen.add.is_none() { + screen.request(RowAction::Delete, cx); + } + })) + .on_action(cx.listener(|screen, _: &TogglePause, _, cx| { + if screen.add.is_none() { + screen.toggle_pause(cx); + } + })) + .on_action(cx.listener(|screen, _: &SelectAll, _, cx| { + if screen.add.is_none() { + screen.select_all(cx); + } + })) + .on_action(cx.listener(|screen, _: &FocusFilter, window, cx| { + if screen.add.is_none() { + let handle = screen.filter_input.focus_handle(cx); + window.focus(&handle, cx); + } + })) + .on_action(cx.listener(|screen, _: &Refresh, _, cx| screen.refresh(cx))) + .on_action(cx.listener(|screen, _: &OpenContextMenu, _, cx| { + if screen.add.is_none() { + screen.open_menu_for_selection(cx); + } + })) .flex() .flex_col() .flex_1() .p_4() .gap_2() - .child(toolbar); + .child(toolbar) + .child(self.filter_input.clone()); if let Some(message) = message { root = root.child(Label::new(message).size(LabelSize::Caption)); } - root = root.child(self.table(&me)); - root = root.child(self.detail.clone()); + root = root.child( + div() + .tab_index(0) + .relative() + .key_context("TorrentTable") + .on_mouse_down( + MouseButton::Right, + cx.listener(|screen, event: &MouseDownEvent, _, cx| { + if screen.add.is_none() { + screen.open_menu_at_pointer(event.position, cx); + } + }), + ) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, _, cx| { + if screen.handle_table_key(event, cx) { + cx.stop_propagation(); + } + })) + .child( + canvas( + move |bounds, _, _| { + geometry.set(TableGeometry { + bounds, + header_height, + row_height, + }); + }, + |_, (), _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(self.filter_sidebar( + &me, + colors.surface_dim, + colors.accent, + colors.stroke_neutral, + )) + .child(self.table(&me)), + ); + root = root.child(Label::new(SHORTCUT_HELP).size(LabelSize::Caption)); + if self.detail_open { + root = root.child(self.detail.clone()); + } if let Some(menu) = &self.menu { root = root.child(menu.clone()); } if let Some(add) = &self.add { root = root.child(add.clone()); } + if let Some(dialog) = &self.bulk_edit { + root = root.child(dialog.clone()); + } + if let Some(dialog) = &self.torrent_edit { + root = root.child(dialog.clone()); + } root = root.child(self.toasts.clone()); root } } -fn toolbar_click( - me: &Entity, - action: RowAction, -) -> impl Fn(&ClickEvent, &mut gpui::Window, &mut App) + 'static { - let me = me.clone(); - move |_, _, app| dispatch(&me, app, action) +#[derive(Clone, Copy)] +enum SelectionMovement { + Previous, + Next, + First, + Last, +} + +/// Where the table was painted and how tall its header and rows are. `DataTable` exposes no +/// per-row hit testing, so the right click maps the pointer to a row from these. +#[derive(Clone, Copy, Default)] +struct TableGeometry { + bounds: Bounds, + header_height: Pixels, + row_height: Pixels, +} + +impl TableGeometry { + /// One pixel each for the table border, the header rule and every row rule. + const RULE: Pixels = px(1.0); + + fn first_row_top(&self) -> Pixels { + self.bounds.origin.y + Self::RULE + self.header_height + Self::RULE + } + + fn row_stride(&self) -> Pixels { + self.row_height + Self::RULE + } + + /// Index of the row under `position`, if the pointer is over the table body. + fn row_index(&self, position: Point) -> Option { + let stride = f32::from(self.row_stride()); + if stride <= 0.0 || !self.bounds.contains(&position) { + return None; + } + let offset = f32::from(position.y - self.first_row_top()); + (offset >= 0.0).then(|| (offset / stride) as usize) + } + + /// Where a menu opened from the keyboard for row `index` goes. + fn row_origin(&self, index: usize) -> Point { + Point { + x: self.bounds.origin.x + px(24.0), + y: self.first_row_top() + self.row_stride() * index as f32 + self.row_height, + } + } +} + +/// A command from the row context menu. +#[derive(Clone, Debug, PartialEq, Eq)] +enum MenuCommand { + Start, + Stop, + Recheck, + SetCategory(Option), + BulkEdit, + SetLocation, + Rename, + CopyMagnet, + CopyName, + Delete, +} + +/// One daemon call for one torrent. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Job { + Row(RowAction), + Recheck, + Category(Option), +} + +/// Which context-menu items apply to the current targets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MenuAvailability { + start: bool, + stop: bool, + recheck: bool, + category: bool, + edit: bool, + copy: bool, + delete: bool, +} + +impl MenuAvailability { + fn of(targets: &[&Torrent]) -> Self { + let state = |torrent: &&Torrent| torrent.stats.as_ref().and_then(|stats| stats.state); + let has_id = |torrent: &&Torrent| torrent.id.is_some(); + let any = |test: &dyn Fn(&&Torrent) -> bool| targets.iter().any(test); + Self { + start: any(&|t| has_id(t) && !is_running(state(t))), + stop: any(&|t| has_id(t) && is_stoppable(state(t))), + recheck: any(&|t| has_id(t) && state(t) != Some(TorrentState::Initializing)), + category: any(&has_id), + edit: targets.len() == 1 && any(&has_id), + copy: !targets.is_empty(), + delete: any(&has_id), + } + } +} + +/// Live or still starting: there is nothing for Start to do. +fn is_running(state: Option) -> bool { + matches!(state, Some(TorrentState::Live | TorrentState::Initializing)) +} + +/// Running, starting or of unknown state: Stop has something to do. +fn is_stoppable(state: Option) -> bool { + matches!( + state, + None | Some(TorrentState::Live | TorrentState::Initializing) + ) +} + +/// The daemon calls a menu command makes, one per target it applies to. +fn menu_jobs(targets: &[&Torrent], command: &MenuCommand) -> Vec<(u64, Job)> { + targets + .iter() + .filter_map(|torrent| { + let id = torrent.id?; + let state = torrent.stats.as_ref().and_then(|stats| stats.state); + let job = match command { + MenuCommand::Start if !is_running(state) => Job::Row(RowAction::Start), + MenuCommand::Stop if is_stoppable(state) => Job::Row(RowAction::Pause), + MenuCommand::Recheck if state != Some(TorrentState::Initializing) => Job::Recheck, + MenuCommand::SetCategory(category) => Job::Category(category.clone()), + _ => return None, + }; + Some((id, job)) + }) + .collect() +} + +/// Clipboard text for Copy magnet link and Copy name: one line per target. +fn copy_text(targets: &[&Torrent], command: &MenuCommand) -> Option { + if targets.is_empty() { + return None; + } + let lines: Vec = targets + .iter() + .map(|torrent| match command { + MenuCommand::CopyMagnet => magnet_link(torrent), + _ => torrent_name(torrent), + }) + .collect(); + Some(lines.join("\n")) +} + +/// `magnet:?xt=urn:btih:&dn=`, with the name percent-encoded. +fn magnet_link(torrent: &Torrent) -> String { + let mut link = format!("magnet:?xt=urn:btih:{}", torrent.info_hash); + if let Some(name) = torrent.name.as_deref().filter(|name| !name.is_empty()) { + link.push_str("&dn="); + for byte in name.bytes() { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { + link.push(char::from(byte)); + } else { + link.push_str(&format!("%{byte:02X}")); + } + } + } + link +} + +/// Sorted, distinct, non-empty category names in use. +fn category_names(torrents: Option<&[Torrent]>) -> Vec { + let mut names: Vec = torrents + .unwrap_or_default() + .iter() + .filter_map(|torrent| torrent.category.clone()) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// Hand a context-menu command to the screen entity. +fn run_menu_command(me: &Entity, app: &mut App, command: MenuCommand) { + me.update(app, |screen, cx| screen.run_menu_command(command, cx)); } /// Shown in the table when the daemon has no torrents. @@ -478,6 +1824,18 @@ fn matches_filter(filter: StatusFilter, torrent: &Torrent) -> bool { } } +fn parse_status(value: &str) -> Option { + StatusFilter::ALL + .into_iter() + .find(|status| status.label() == value) +} + +fn tracker_host(url: &str) -> Option { + let rest = url.split_once("://").map_or(url, |(_, rest)| rest); + let host = rest.split(['/', ':']).next().unwrap_or(""); + (!host.is_empty()).then(|| host.to_owned()) +} + fn row_id(torrent: &Torrent) -> String { torrent .id @@ -513,6 +1871,19 @@ fn download_bytes_per_sec(torrent: &Torrent) -> f64 { .map_or(0.0, |live| live.download_speed.mbps * 1024.0 * 1024.0) } +fn upload_speed(torrent: &Torrent) -> rtbit_client::Speed { + torrent + .stats + .as_ref() + .and_then(|stats| stats.live.as_ref()) + .map(|live| live.upload_speed.clone()) + .unwrap_or_default() +} + +fn upload_text(torrent: &Torrent) -> String { + format::speed(&upload_speed(torrent)) +} + fn size_text(torrent: &Torrent) -> String { format::bytes(total_bytes(torrent)) } @@ -532,7 +1903,7 @@ fn speed_text(torrent: &Torrent) -> String { } fn state_of(torrent: &Torrent) -> String { - format::state_label(torrent.stats.as_ref().and_then(|stats| stats.state)) + format::row_state_label(torrent.stats.as_ref()) } /// Seconds left at the current download speed, or `None` when nothing is moving. @@ -567,6 +1938,7 @@ fn column_order(column: &str, a: &Torrent, b: &Torrent) -> Ordering { "size" => total_bytes(a).cmp(&total_bytes(b)), "progress" => done_fraction(a).total_cmp(&done_fraction(b)), "speed" => download_bytes_per_sec(a).total_cmp(&download_bytes_per_sec(b)), + "upload" => upload_speed(a).mbps.total_cmp(&upload_speed(b).mbps), "eta" => eta_seconds(a) .unwrap_or(f64::INFINITY) .total_cmp(&eta_seconds(b).unwrap_or(f64::INFINITY)), @@ -588,6 +1960,17 @@ mod tests { "/../rtbit-client/tests/fixtures" ); + #[test] + fn every_toolbar_button_has_a_non_empty_label() { + assert!( + toolbar_buttons() + .iter() + .all(|button| !button.label.trim().is_empty()), + "toolbar buttons need text for their tooltip and accessible name" + ); + assert_eq!(toolbar_buttons().len(), 4); + } + fn fixture(name: &str) -> String { std::fs::read_to_string(format!("{FIXTURES}/{name}")).unwrap() } @@ -674,6 +2057,34 @@ mod tests { assert_eq!(column_order("size", &small, &big), Ordering::Less); } + #[test] + fn numeric_columns_sort_by_value() { + let (mut slow, mut fast) = (row("a", 1), row("b", 1)); + let live = |mbps| rtbit_client::LiveSpeeds { + download_speed: rtbit_client::Speed { + mbps, + human_readable: String::new(), + }, + upload_speed: rtbit_client::Speed { + mbps, + human_readable: String::new(), + }, + }; + slow.stats.as_mut().unwrap().live = Some(live(9.0)); + fast.stats.as_mut().unwrap().live = Some(live(10.0)); + for column in ["speed", "upload"] { + assert_eq!( + column_order(column, &slow, &fast), + Ordering::Less, + "{column}" + ); + } + let (mut half, mut most) = (row("a", 100), row("b", 100)); + half.stats.as_mut().unwrap().progress_bytes = 9; + most.stats.as_mut().unwrap().progress_bytes = 10; + assert_eq!(column_order("progress", &half, &most), Ordering::Less); + } + #[test] fn eta_is_dash_without_speed() { assert_eq!(eta_text(&row("idle", 100)), "—"); @@ -685,7 +2096,11 @@ mod tests { let status = cx.new(|_| StatusBar::new()); let screen = cx.new(|cx| MainScreen::new(status.clone(), cx)); screen.update(cx, |screen, cx| { - screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + screen._poller = Some(Bridge::poll_snapshot( + cx, + snapshot_of, + MainScreen::on_poll_health_cb, + )); }); drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); screen.read_with(cx, |screen, _| { @@ -762,6 +2177,58 @@ mod tests { }); } + #[gpui::test] + fn keyboard_navigation_moves_extends_and_jumps_selection(cx: &mut TestAppContext) { + let (_runtime, _server) = daemon(cx); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + let mut snapshot = screen.snapshot.ready_value().unwrap().clone(); + for id in [4, 5] { + let mut torrent = snapshot.torrents.torrents[0].clone(); + torrent.id = Some(id); + torrent.name = Some(format!("torrent-{id}")); + snapshot.torrents.torrents.push(torrent); + } + snapshot.torrents.total = 3; + screen.snapshot = AsyncResource::ready(snapshot); + + screen.move_selection(SelectionMovement::Next, false, cx); + assert_eq!(screen.selected.as_deref(), Some("4")); + screen.move_selection(SelectionMovement::Next, true, cx); + assert_eq!(screen.selected.as_deref(), Some("5")); + assert_eq!(screen.marked.len(), 2); + assert!(screen.marked.contains("4")); + assert!(screen.marked.contains("5")); + screen.move_selection(SelectionMovement::First, false, cx); + assert_eq!(screen.selected.as_deref(), Some("3")); + screen.move_selection(SelectionMovement::Last, false, cx); + assert_eq!(screen.selected.as_deref(), Some("5")); + }); + } + + #[gpui::test] + fn enter_opens_and_escape_closes_detail(cx: &mut TestAppContext) { + let (_runtime, _server) = daemon(cx); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.selected = Some("3".to_owned()); + screen.open_detail(cx); + assert!(screen.detail_open, "Enter must open the detail pane"); + screen.close_detail(cx); + assert!(!screen.detail_open, "Escape must close the detail pane"); + }); + } + #[gpui::test] fn pause_action_hits_daemon_endpoint(cx: &mut TestAppContext) { let (runtime, server) = daemon(cx); @@ -985,4 +2452,534 @@ mod tests { .count(); assert_eq!(hits, 1, "start must hit the daemon exactly once"); } + + // --- keyboard shortcuts: each test sends real keystrokes through the keymap --- + + fn drive_window( + cx: &mut gpui::VisualTestContext, + mut done: impl FnMut(&mut gpui::VisualTestContext) -> bool, + ) { + for _ in 0..500 { + cx.run_until_parked(); + if done(cx) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("daemon reply never arrived"); + } + + /// A main screen in a window with the shortcuts bound, the snapshot loaded and row 3 shown. + fn shortcut_screen( + cx: &mut TestAppContext, + ) -> ( + Entity, + &mut gpui::VisualTestContext, + tokio::runtime::Runtime, + MockServer, + ) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/start")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + cx.update(|app| { + fluent_core::Theme::init(app); + fluent_layout::ModalStack::init(app); + bind_keys(app); + }); + let status = cx.new(|_| StatusBar::new()); + let (screen, vcx) = cx.add_window_view(|_, cx| MainScreen::new(status, cx)); + screen.update(vcx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + (screen, vcx, runtime, server) + } + + fn posts_to(runtime: &tokio::runtime::Runtime, server: &MockServer, route: &str) -> usize { + std::thread::sleep(Duration::from_millis(50)); + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|req| req.method.as_str() == "POST" && req.url.path() == route) + .count() + } + + #[gpui::test] + fn ctrl_o_opens_the_add_dialog(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + vcx.simulate_keystrokes("ctrl-o"); + screen.read_with(vcx, |s, _| assert!(s.add.is_some())); + } + + #[gpui::test] + fn add_dialog_escape_cancels_and_enter_submits_only_valid_input(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + vcx.update(|_, cx| ToastStack::init(cx)); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + + vcx.simulate_keystrokes("ctrl-o"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + vcx.run_until_parked(); + vcx.update(|window, cx| assert!(dialog.read(cx).magnet_focus(cx).is_focused(window))); + vcx.simulate_keystrokes("enter"); + dialog.read_with(vcx, |dialog, _| assert!(dialog.error().is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents"), 0); + vcx.simulate_keystrokes("escape"); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + + vcx.simulate_keystrokes("ctrl-o"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + dialog.update(vcx, |dialog, cx| { + dialog.fill_for_test(cx, HASH_MAGNET, "/data"); + }); + vcx.simulate_keystrokes("enter"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s.add.is_none())); + assert_eq!(posts_to(&runtime, &server, "/torrents"), 1); + } + + #[gpui::test] + fn ctrl_v_with_a_magnet_opens_the_dialog_prefilled(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.write_to_clipboard(gpui::ClipboardItem::new_string(format!( + " {HASH_MAGNET}\n" + ))); + vcx.simulate_keystrokes("ctrl-v"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + dialog.read_with(vcx, |dialog, cx| { + assert_eq!(dialog.magnet_text(cx), HASH_MAGNET) + }); + } + + #[gpui::test] + fn ctrl_v_without_a_magnet_opens_nothing(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.write_to_clipboard(gpui::ClipboardItem::new_string("just some text".to_owned())); + vcx.simulate_keystrokes("ctrl-v"); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + } + + #[gpui::test] + fn delete_asks_before_removing_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + vcx.simulate_keystrokes("delete"); + screen.read_with(vcx, |s, _| { + assert_eq!(s.action_error.as_deref(), Some("Select a torrent first.")); + assert!(!s.delete_prompt); + }); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("delete"); + screen.read_with(vcx, |s, _| assert!(s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + } + + #[gpui::test] + fn delete_confirmation_escape_cancels_and_enter_confirms(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/delete")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("delete escape"); + screen.read_with(vcx, |s, _| assert!(!s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + + vcx.simulate_keystrokes("delete enter"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 1); + } + + #[gpui::test] + fn space_pauses_the_selected_torrent(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("space"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + // The fixture row is not paused, so Space pauses it. + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 1); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 0); + } + + #[gpui::test] + fn ctrl_a_marks_every_visible_row(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.simulate_keystrokes("ctrl-a"); + screen.read_with(vcx, |s, _| assert_eq!(s.marked_count(), 1)); + // Picking a row again drops the marks. + screen.update(vcx, |s, cx| s.select_row("3", cx)); + screen.read_with(vcx, |s, _| assert_eq!(s.marked_count(), 0)); + } + + #[gpui::test] + fn ctrl_f_focuses_the_filter_and_typing_keys_stay_there(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("ctrl-f"); + let filter = screen.read_with(vcx, |s, _| s.filter_input.clone()); + let focused = vcx.update(|window, cx| filter.focus_handle(cx).is_focused(window)); + assert!(focused, "Ctrl+F must focus the filter box"); + // Space belongs to the text field now: it must not pause the torrent. + vcx.simulate_keystrokes("space"); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 0); + } + + #[gpui::test] + fn the_filter_box_narrows_the_rows(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.set_query("EXAM".to_owned(), cx)); + screen.read_with(vcx, |s, _| assert_eq!(s.visible_rows().len(), 1)); + screen.update(vcx, |s, cx| s.set_query("nomatch".to_owned(), cx)); + screen.read_with(vcx, |s, _| assert!(s.visible_rows().is_empty())); + } + + #[gpui::test] + fn f5_asks_the_daemon_for_a_fresh_snapshot(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + let gets = |runtime: &tokio::runtime::Runtime, server: &MockServer| { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|req| req.method.as_str() == "GET" && req.url.path() == "/torrents") + .count() + }; + let before = gets(&runtime, &server); + screen.update(vcx, |s, _| s._load = None); + vcx.simulate_keystrokes("f5"); + screen.read_with(vcx, |s, _| assert!(s._load.is_some())); + drive_window(vcx, |_| gets(&runtime, &server) > before); + } + + fn with_state(id: u64, state: TorrentState) -> Torrent { + let mut torrent = row(&format!("t{id}"), 1); + torrent.id = Some(id); + torrent.stats.as_mut().unwrap().state = Some(state); + torrent + } + + #[test] + fn menu_items_follow_the_state_of_the_targets() { + let live = with_state(1, TorrentState::Live); + let paused = with_state(2, TorrentState::Paused); + let starting = with_state(3, TorrentState::Initializing); + + let running = MenuAvailability::of(&[&live]); + assert!(!running.start, "Start is greyed while running"); + assert!(running.stop && running.recheck && running.delete && running.copy); + + let stopped = MenuAvailability::of(&[&paused]); + assert!(stopped.start && !stopped.stop); + + let mixed = MenuAvailability::of(&[&live, &paused]); + assert!(mixed.start && mixed.stop, "a mixed selection allows both"); + + assert!(!MenuAvailability::of(&[&starting]).recheck); + + let mut missing = with_state(4, TorrentState::Error); + missing.name = Some("missingFiles".to_owned()); + assert!(MenuAvailability::of(&[&missing]).edit); + + let none = MenuAvailability::of(&[]); + assert!(!(none.start || none.stop || none.recheck || none.category || none.copy)); + assert!(!none.delete); + } + + #[gpui::test] + fn set_location_success_closes_the_dialog(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/set_location")) + .and(wiremock::matchers::body_json(serde_json::json!({ + "location": "/new/place" + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server), + ); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.select_row("3", cx); + screen.open_torrent_edit(TorrentEditKind::Location, cx); + let dialog = screen.torrent_edit.clone().unwrap(); + screen.send_torrent_edit( + dialog, + TorrentEditKind::Location, + "/new/place".to_owned(), + cx, + ); + }); + drive(cx, |cx| { + screen.read_with(cx, |s, _| s.torrent_edit.is_none()) + }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn set_location_409_stays_in_the_dialog(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/set_location")) + .respond_with( + ResponseTemplate::new(409) + .set_body_string("cross-device move is not supported"), + ) + .expect(1) + .mount(&server), + ); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.select_row("3", cx); + screen.open_torrent_edit(TorrentEditKind::Location, cx); + let dialog = screen.torrent_edit.clone().unwrap(); + screen.send_torrent_edit(dialog, TorrentEditKind::Location, "/other".to_owned(), cx); + }); + drive(cx, |cx| { + screen.read_with(cx, |screen, cx| { + screen.torrent_edit.as_ref().is_some_and(|dialog| { + dialog.read(cx).error.as_deref() == Some("cross-device move is not supported") + }) + }) + }); + runtime.block_on(server.verify()); + } + + #[test] + fn menu_jobs_cover_only_the_targets_a_command_applies_to() { + let live = with_state(1, TorrentState::Live); + let paused = with_state(2, TorrentState::Paused); + let targets = [&live, &paused]; + assert_eq!( + menu_jobs(&targets, &MenuCommand::Start), + vec![(2, Job::Row(RowAction::Start))] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::Stop), + vec![(1, Job::Row(RowAction::Pause))] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::Recheck), + vec![(1, Job::Recheck), (2, Job::Recheck)] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::SetCategory(Some("tv".to_owned()))), + vec![ + (1, Job::Category(Some("tv".to_owned()))), + (2, Job::Category(Some("tv".to_owned()))) + ] + ); + assert!(menu_jobs(&targets, &MenuCommand::CopyName).is_empty()); + } + + #[test] + fn copy_text_builds_magnets_and_names_per_target() { + let mut one = row("My Show.S01", 1); + one.info_hash = "abc123".to_owned(); + let two = row("plain", 1); + assert_eq!( + copy_text(&[&one], &MenuCommand::CopyMagnet).unwrap(), + "magnet:?xt=urn:btih:abc123&dn=My%20Show.S01" + ); + assert_eq!( + copy_text(&[&one, &two], &MenuCommand::CopyName).unwrap(), + "My Show.S01\nplain" + ); + assert_eq!(copy_text(&[], &MenuCommand::CopyName), None); + } + + #[test] + fn pointer_maps_to_table_rows() { + let geometry = TableGeometry { + bounds: Bounds { + origin: gpui::point(px(10.0), px(100.0)), + size: gpui::size(px(400.0), px(300.0)), + }, + header_height: px(30.0), + row_height: px(20.0), + }; + // Body starts below the table border, header and header rule. + let at = |y: f32| geometry.row_index(gpui::point(px(50.0), px(y))); + assert_eq!(at(110.0), None, "header"); + assert_eq!(at(132.0), Some(0)); + assert_eq!(at(132.0 + 21.0), Some(1)); + assert_eq!(geometry.row_index(gpui::point(px(5.0), px(150.0))), None); + } + + #[gpui::test] + fn right_click_selects_the_row_and_opens_the_menu(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.run_until_parked(); + let (position, id) = screen.read_with(vcx, |s, _| { + let geometry = s.table_geometry.get(); + (geometry.row_origin(0), row_id(s.visible_rows()[0])) + }); + // `row_origin` sits a row below the row's top; step back into the row. + let inside = gpui::point(position.x, position.y - px(10.0)); + vcx.simulate_event(MouseDownEvent { + button: MouseButton::Right, + position: inside, + modifiers: gpui::Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + screen.read_with(vcx, |s, _| { + assert_eq!(s.selected.as_deref(), Some(id.as_str())); + assert!(s.menu.is_some(), "right click must open the menu"); + }); + } + + #[gpui::test] + fn shift_f10_opens_the_menu_for_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.simulate_keystrokes("shift-f10"); + screen.read_with(vcx, |s, _| { + assert!(s.menu.is_none()); + assert_eq!(s.action_error.as_deref(), Some("Select a torrent first.")); + }); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("shift-f10"); + screen.read_with(vcx, |s, _| assert!(s.menu.is_some())); + } + + /// Mount the context-menu routes and select the fixture row. + fn menu_screen( + cx: &mut TestAppContext, + ) -> ( + Entity, + &mut gpui::VisualTestContext, + tokio::runtime::Runtime, + MockServer, + ) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + for route in ["/torrents/3/recheck", "/torrents/3/set_category"] { + runtime.block_on( + Mock::given(method("POST")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json")) + .mount(&server), + ); + } + vcx.update(|_, cx| ToastStack::init(cx)); + set_state(&screen, vcx, TorrentState::Live); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + (screen, vcx, runtime, server) + } + + fn set_state( + screen: &Entity, + vcx: &mut gpui::VisualTestContext, + state: TorrentState, + ) { + screen.update(vcx, |s, _| { + let mut snapshot = s.snapshot.value().unwrap().clone(); + snapshot.torrents.torrents[0] + .stats + .get_or_insert_with(Default::default) + .state = Some(state); + s.snapshot = AsyncResource::ready(snapshot); + }); + } + + fn run_menu( + screen: &Entity, + vcx: &mut gpui::VisualTestContext, + command: MenuCommand, + ) { + screen.update(vcx, |s, cx| s.run_menu_command(command, cx)); + } + + #[gpui::test] + fn menu_stop_pauses_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Stop); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 1); + } + + #[gpui::test] + fn menu_start_is_a_no_op_while_running(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Start); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 0); + } + + #[gpui::test] + fn menu_start_resumes_a_paused_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + set_state(&screen, vcx, TorrentState::Paused); + run_menu(&screen, vcx, MenuCommand::Start); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 1); + } + + #[gpui::test] + fn menu_recheck_posts_to_the_daemon(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Recheck); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/recheck"), 1); + } + + #[gpui::test] + fn menu_set_category_sends_the_category(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu( + &screen, + vcx, + MenuCommand::SetCategory(Some("movies".to_owned())), + ); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/set_category"), 1); + let body: serde_json::Value = runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .find(|req| req.url.path() == "/torrents/3/set_category") + .map(|req| serde_json::from_slice(&req.body).unwrap()) + .unwrap(); + assert_eq!(body, serde_json::json!({ "category": "movies" })); + } + + #[gpui::test] + fn menu_copy_commands_fill_the_clipboard(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::CopyName); + let copied = vcx.read_from_clipboard().and_then(|item| item.text()); + assert_eq!(copied.as_deref(), Some("example")); + run_menu(&screen, vcx, MenuCommand::CopyMagnet); + let copied = vcx.read_from_clipboard().and_then(|item| item.text()); + assert!(copied.unwrap().starts_with("magnet:?xt=urn:btih:")); + } + + #[gpui::test] + fn menu_delete_asks_before_removing(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Delete); + screen.read_with(vcx, |s, _| assert!(s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + } } diff --git a/crates/rtbit-fluent/src/screens/mod.rs b/crates/rtbit-fluent/src/screens/mod.rs index be1a5864..641fa7e0 100644 --- a/crates/rtbit-fluent/src/screens/mod.rs +++ b/crates/rtbit-fluent/src/screens/mod.rs @@ -1,10 +1,12 @@ //! Screens the shell routes to. Each owns its own form state and talks to the daemon only through the bridge. pub mod add_dialog; +pub mod bulk_edit_dialog; pub mod connect; pub mod detail; pub mod main_screen; pub mod rss; pub mod settings; +pub mod torrent_edit_dialog; pub use main_screen::MainScreen; diff --git a/crates/rtbit-fluent/src/screens/rss.rs b/crates/rtbit-fluent/src/screens/rss.rs index be10ab41..3a140909 100644 --- a/crates/rtbit-fluent/src/screens/rss.rs +++ b/crates/rtbit-fluent/src/screens/rss.rs @@ -9,8 +9,8 @@ use fluent_layout::{DataColumn, DataRow, DataTable, MessageBar, MessageIntent}; use fluent_primitives::{Button, ButtonAppearance, Label}; use fluent_primitives::{Checkbox, TextInput}; use gpui::{ - AsyncApp, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Task, WeakEntity, - Window, div, prelude::*, + AsyncApp, ClickEvent, Context, Entity, FocusHandle, Focusable as _, IntoElement, KeyDownEvent, + Render, SharedString, Task, WeakEntity, Window, div, prelude::*, }; use rtbit_client::{Error, RssItem, RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot}; use rtbit_ui_core::map_error; @@ -105,6 +105,9 @@ pub struct RssScreen { episodes: Vec, episodes_loading: bool, episodes_error: Option, + editor_focus: FocusHandle, + editor_autofocused: bool, + restore_focus: bool, _load: Option>, _download: Option>, _rule: Option>, @@ -130,6 +133,9 @@ impl RssScreen { episodes: Vec::new(), episodes_loading: false, episodes_error: None, + editor_focus: cx.focus_handle(), + editor_autofocused: false, + restore_focus: false, _load: None, _download: None, _rule: None, @@ -181,6 +187,7 @@ impl RssScreen { self.category_input .update(cx, |field, cx| field.set_value(category, cx)); self.editor = Some(draft); + self.editor_autofocused = false; self.error = None; self.episodes.clear(); self.episodes_loading = false; @@ -212,6 +219,8 @@ impl RssScreen { return; } self.editor = None; + self.editor_autofocused = false; + self.restore_focus = true; self.error = None; cx.notify(); } @@ -279,6 +288,14 @@ impl RssScreen { cx.notify(); } } + + /// Set the download priority in the open draft. + pub fn set_priority(&mut self, priority: i32, cx: &mut Context) { + if let Some(draft) = self.editor.as_mut() { + draft.priority = priority; + cx.notify(); + } + } } /// A boolean field of the rule draft that the editor shows as a checkbox. @@ -525,9 +542,42 @@ pub fn item_status(item: &RssItem) -> &'static str { } impl Render for RssScreen { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.editor.is_some() && !self.editor_autofocused { + self.editor_autofocused = true; + // The input only owns its focus handle once it has rendered, so focus it after + // this frame has drawn. + cx.defer_in(window, |screen, window, cx| { + let focus = screen.name_input.focus_handle(cx); + window.focus(&focus, cx); + }); + } else if self.restore_focus { + self.restore_focus = false; + window.focus(&self.editor_focus, cx); + } let me: Entity = cx.entity(); - let mut root = div().flex().flex_col().gap_3().child(Label::new("RSS")); + let mut root = div() + .flex() + .flex_col() + .gap_3() + .track_focus(&self.editor_focus) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, _, cx| { + if screen.editor.is_none() { + return; + } + match event.keystroke.key.as_str() { + "escape" => { + screen.cancel_rule(cx); + cx.stop_propagation(); + } + "enter" => { + screen.save_rule(cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .child(Label::new("RSS")); if let Some(message) = self.error.clone() { root = root.child(MessageBar::new(message).intent(MessageIntent::Error)); @@ -692,6 +742,23 @@ impl RssScreen { this.toggle_flag(DraftFlag::TrackEpisodes, cx); }); }); + let mut priorities = div().flex().flex_row().gap_2(); + for (priority, label) in [(0, "Low"), (1, "Normal"), (2, "High")] { + let priority_me = me.clone(); + priorities = priorities.child( + Button::new(("rss-rule-priority", priority as usize)) + .label(label) + .appearance(if draft.priority == priority { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .disabled(self.rule_busy) + .on_click(move |_, _, cx| { + priority_me.update(cx, |this, cx| this.set_priority(priority, cx)); + }), + ); + } section = section .child(Label::new(title)) .child(Label::new("Name")) @@ -701,10 +768,8 @@ impl RssScreen { .child(feeds) .child(Label::new("Category")) .child(self.category_input.clone()) - .child(Label::new(format!( - "Priority: {}", - priority_label(draft.priority) - ))) + .child(Label::new("Priority")) + .child(priorities) .child( div() .flex() @@ -776,7 +841,7 @@ impl RssScreen { mod tests { use super::*; use gpui::TestAppContext; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{body_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; const FIXTURES: &str = concat!( @@ -996,11 +1061,13 @@ mod tests { fn validate_rule_trims_and_drops_an_empty_category() { let mut draft = draft_with(" Show ", " Show.*1080p ", &["Show"]); draft.category = " ".to_owned(); + draft.priority = 2; let body = validate_rule(&draft).expect("valid"); assert_eq!(body.name, "Show"); assert_eq!(body.match_regex, "Show.*1080p"); assert_eq!(body.feed_names, vec!["Show".to_owned()]); assert_eq!(body.category, None); + assert_eq!(body.priority, 2); assert!(body.enabled && body.track_episodes); } @@ -1052,7 +1119,17 @@ mod tests { runtime.block_on(async { Mock::given(method("POST")) .and(path("/rss/rules")) + .and(body_json(serde_json::json!({ + "name": "Two", + "feed_names": ["Show"], + "category": null, + "priority": 0, + "match_regex": "Two.*", + "enabled": true, + "track_episodes": true + }))) .respond_with(ResponseTemplate::new(200)) + .expect(1) .mount(&server) .await; }); @@ -1064,6 +1141,7 @@ mod tests { .regex_input .update(cx, |f, cx| f.set_value("Two.*", cx)); screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + screen.set_priority(0, cx); screen.save_rule(cx); }); drive(cx, |cx| { @@ -1072,6 +1150,94 @@ mod tests { screen.read_with(cx, |screen, _| { assert!(screen.error().is_none(), "save should succeed"); }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn edit_initializes_and_updates_the_selected_priority(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx, ITEMS); + runtime.block_on(async { + Mock::given(method("PUT")) + .and(path("/rss/rules/r-1")) + .and(body_json(serde_json::json!({ + "name": "Show 1080p", + "feed_names": ["Show"], + "category": "tv", + "priority": 0, + "match_regex": "Show.*1080p", + "enabled": true, + "track_episodes": true + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + }); + let screen = loaded(cx); + screen.update(cx, |screen, cx| { + screen.open_edit_rule("r-1", cx); + assert_eq!(screen.editor().map(|draft| draft.priority), Some(2)); + screen.set_priority(0, cx); + screen.save_rule(cx); + }); + drive(cx, |cx| { + screen.read_with(cx, |screen, _| { + screen.editor().is_none() && !screen.rule_busy + }) + }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn rule_editor_escape_cancels_and_enter_submits_only_valid_input(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx, ITEMS); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/rss/rules")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + }); + cx.update(fluent_core::Theme::init); + let (screen, vcx) = cx.add_window_view(|_, cx| RssScreen::new(cx)); + screen.update(vcx, |screen, cx| screen.load(cx)); + for _ in 0..500 { + vcx.run_until_parked(); + if screen.read_with(vcx, |s, _| !s.snapshot.is_loading()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + screen.update(vcx, |screen, cx| screen.open_new_rule(cx)); + vcx.run_until_parked(); + vcx.simulate_keystrokes("enter"); + screen.read_with(vcx, |screen, _| { + assert!(screen.editor().is_some()); + assert!(screen.error().is_some()); + }); + vcx.simulate_keystrokes("escape"); + screen.read_with(vcx, |screen, _| assert!(screen.editor().is_none())); + + screen.update(vcx, |screen, cx| { + screen.open_new_rule(cx); + screen.name_input.update(cx, |f, cx| f.set_value("Two", cx)); + screen + .regex_input + .update(cx, |f, cx| f.set_value("Two.*", cx)); + screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + }); + vcx.simulate_keystrokes("enter"); + for _ in 0..500 { + vcx.run_until_parked(); + if screen.read_with(vcx, |s, _| s.editor().is_none() && !s.rule_busy) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + screen.read_with(vcx, |screen, _| assert!(screen.editor().is_none())); + runtime.block_on(server.verify()); } #[gpui::test] @@ -1090,12 +1256,14 @@ mod tests { screen.name_input.update(cx, |f, cx| f.set_value("Bad", cx)); screen.regex_input.update(cx, |f, cx| f.set_value("(", cx)); screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + screen.set_priority(2, cx); screen.save_rule(cx); }); drive(cx, |cx| screen.read_with(cx, |s, _| !s.rule_busy)); screen.read_with(cx, |screen, _| { assert!(screen.editor().is_some(), "editor must stay open"); assert!(screen.error().is_some(), "refusal must show an error"); + assert_eq!(screen.editor().map(|draft| draft.priority), Some(2)); }); } diff --git a/crates/rtbit-fluent/src/screens/settings.rs b/crates/rtbit-fluent/src/screens/settings.rs index 47fef6a9..47bcd97d 100644 --- a/crates/rtbit-fluent/src/screens/settings.rs +++ b/crates/rtbit-fluent/src/screens/settings.rs @@ -345,6 +345,8 @@ impl SettingsDraft { &self.fastresume_denom, "Fastresume validation", )?, + concurrent_recheck_limit: base.advanced.concurrent_recheck_limit, + recheck_read_mibps: base.advanced.recheck_read_mibps, }; let shown = AdvancedConfig { connect_timeout_secs: base.advanced.connect_timeout_secs, @@ -354,6 +356,8 @@ impl SettingsDraft { magnet_resolution_timeout_secs: base.advanced.magnet_resolution_timeout_secs, max_concurrent_handshakes: base.advanced.max_concurrent_handshakes, fastresume_validation_denom: base.advanced.fastresume_validation_denom, + concurrent_recheck_limit: base.advanced.concurrent_recheck_limit, + recheck_read_mibps: base.advanced.recheck_read_mibps, }; if advanced != shown { out.advanced = Some(advanced); diff --git a/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs b/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs new file mode 100644 index 00000000..a0ede6bb --- /dev/null +++ b/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs @@ -0,0 +1,185 @@ +//! Small text dialogs for changing one torrent's location or display name. + +use fluent_core::ThemeProvider as _; +use fluent_layout::{MessageBar, MessageIntent}; +use fluent_primitives::{Button, ButtonAppearance, Label, TextInput}; +use gpui::{ + AsyncApp, Context, Entity, EventEmitter, Focusable as _, IntoElement, KeyDownEvent, + PathPromptOptions, Render, Task, WeakEntity, Window, div, prelude::*, px, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TorrentEditKind { + Location, + Name, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TorrentEditDialogEvent { + Submit(String), + Cancel, +} + +pub struct TorrentEditDialog { + kind: TorrentEditKind, + input: Entity, + pub(crate) error: Option, + submitted: bool, + autofocused: bool, + _pick: Option>, +} + +impl EventEmitter for TorrentEditDialog {} + +impl TorrentEditDialog { + pub fn new(kind: TorrentEditKind, initial: String, cx: &mut Context) -> Self { + let placeholder = match kind { + TorrentEditKind::Location => "/downloads", + TorrentEditKind::Name => "Torrent name", + }; + Self { + kind, + input: cx.new(|_| TextInput::new().placeholder(placeholder).value(initial)), + error: None, + submitted: false, + autofocused: false, + _pick: None, + } + } + + pub fn set_error(&mut self, error: String, cx: &mut Context) { + self.error = Some(error); + self.submitted = false; + cx.notify(); + } + + fn submit(&mut self, cx: &mut Context) { + let value = self.input.read(cx).text().trim().to_owned(); + if value.is_empty() { + self.error = Some( + match self.kind { + TorrentEditKind::Location => "Choose or enter a location first.", + TorrentEditKind::Name => "Enter a name first.", + } + .to_owned(), + ); + cx.notify(); + return; + } + if !self.submitted { + self.submitted = true; + cx.emit(TorrentEditDialogEvent::Submit(value)); + } + } + + fn pick_directory(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + self._pick = Some( + cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { + let Ok(Ok(Some(paths))) = picked.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let _ = view.update(cx, |dialog, cx| { + dialog + .input + .update(cx, |input, cx| input.set_value(path.to_string_lossy(), cx)); + dialog.error = None; + cx.notify(); + }); + }), + ); + } +} + +impl Render for TorrentEditDialog { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + window.focus(&self.input.focus_handle(cx), cx); + self.autofocused = true; + } + let title = match self.kind { + TorrentEditKind::Location => "Set location", + TorrentEditKind::Name => "Rename torrent", + }; + let submit_label = match self.kind { + TorrentEditKind::Location => "Move", + TorrentEditKind::Name => "Rename", + }; + let cancel = cx.entity(); + let submit = cx.entity(); + let pick = cx.entity(); + let mut card = div() + .w(px(480.0)) + .p_4() + .gap_3() + .flex() + .flex_col() + .rounded(px(6.0)) + .bg(cx.theme().colors.surface) + .child(Label::new(title)) + .child(self.input.clone()); + if self.kind == TorrentEditKind::Location { + card = card.child( + Button::new("choose-location") + .label("Choose folder…") + .on_click(move |_, _, app| { + pick.update(app, |dialog, cx| dialog.pick_directory(cx)) + }), + ); + } + if let Some(error) = &self.error { + card = card.child(MessageBar::new(error.clone()).intent(MessageIntent::Error)); + } + card = card.child( + div() + .flex() + .gap_2() + .justify_end() + .child(Button::new("torrent-edit-cancel").label("Cancel").on_click( + move |_, _, app| { + cancel.update(app, |_, cx| cx.emit(TorrentEditDialogEvent::Cancel)); + }, + )) + .child( + Button::new("torrent-edit-submit") + .label(submit_label) + .appearance(ButtonAppearance::Accent) + .on_click(move |_, _, app| { + submit.update(app, |dialog, cx| dialog.submit(cx)); + }), + ), + ); + div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .occlude() + .bg(gpui::black().opacity(0.4)) + .capture_key_down(cx.listener(|dialog, event: &KeyDownEvent, _, cx| { + match event.keystroke.key.as_str() { + "escape" => { + cx.emit(TorrentEditDialogEvent::Cancel); + cx.stop_propagation(); + } + "enter" => { + dialog.submit(cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .child(card) + } +} diff --git a/crates/rtbit-fluent/src/shell.rs b/crates/rtbit-fluent/src/shell.rs index 79caaaef..41ca2678 100644 --- a/crates/rtbit-fluent/src/shell.rs +++ b/crates/rtbit-fluent/src/shell.rs @@ -3,16 +3,41 @@ use fluent_app::WindowTitle; use fluent_layout::{SelectableList, SelectableListItem}; use gpui::{ - AnyElement, Context, Entity, IntoElement, Render, Subscription, Window, div, prelude::*, px, + AnyElement, ClipboardItem, Context, Entity, ExternalPaths, IntoElement, Render, Subscription, + Window, div, prelude::*, px, }; use rtbit_ui_core::{Connection, Screen, Snapshot, StatusFilter}; use crate::screens::MainScreen; use crate::screens::connect::{ConnectEvent, ConnectScreen}; +use crate::screens::main_screen::MainEvent; use crate::screens::rss::RssScreen; use crate::screens::settings::SettingsScreen; use crate::widgets::status_bar::StatusBar; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DropKind { + TorrentFiles, + Magnet, + Other, +} + +pub(crate) fn classify_drop(paths: Option<&ExternalPaths>, text: Option<&str>) -> DropKind { + if paths.is_some_and(|paths| { + !paths.paths().is_empty() + && paths.paths().iter().all(|path| { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("torrent")) + }) + }) { + return DropKind::TorrentFiles; + } + if text.is_some_and(|text| text.trim_start().starts_with("magnet:?")) { + return DropKind::Magnet; + } + DropKind::Other +} + /// Screens reachable from the sidebar. Connect and Setup come before login and are not listed. const NAV: [(&str, &str, Screen); 3] = [ ("torrents", "Torrents", Screen::Main), @@ -29,7 +54,10 @@ pub struct Shell { rss: Entity, settings: Entity, main: Entity, + /// Seconds until the next retry while the daemon is lost; `None` when reachable. + lost_retry_secs: Option, _connect_events: Subscription, + _main_events: Subscription, } impl Shell { @@ -50,6 +78,12 @@ impl Shell { shell.on_connect_event(*event, cx); }, ); + let main_events = cx.subscribe( + &main, + |shell: &mut Self, _: Entity, event: &MainEvent, cx: &mut Context| { + shell.on_main_event(*event, cx); + }, + ); Self { screen: Screen::Connect, filter: StatusFilter::All, @@ -58,17 +92,48 @@ impl Shell { rss: cx.new(RssScreen::new), settings, main, + lost_retry_secs: None, _connect_events: connect_events, + _main_events: main_events, } } + /// Follow daemon reachability from the main screen's poll. + fn on_main_event(&mut self, event: MainEvent, cx: &mut Context) { + match event { + MainEvent::DaemonLost { retry_in_secs } => { + self.lost_retry_secs = Some(retry_in_secs); + self.set_connection(Connection::Error, cx); + } + MainEvent::DaemonRecovered => { + self.lost_retry_secs = None; + self.set_connection(Connection::Connected, cx); + } + MainEvent::Unauthorised => { + self.lost_retry_secs = None; + self.screen = Screen::Connect; + self.set_connection(Connection::Disconnected, cx); + } + } + cx.notify(); + } + + /// The lost-connection banner text, while the daemon is unreachable. + #[must_use] + pub fn lost_banner(&self) -> Option { + self.lost_retry_secs + .map(|secs| format!("Lost connection to daemon — retrying in {secs}s")) + } + /// Follow a sign-in attempt: badge and title always; the main screen on success. fn on_connect_event(&mut self, event: ConnectEvent, cx: &mut Context) { + self.lost_retry_secs = None; match event { ConnectEvent::Connecting => self.set_connection(Connection::Connecting, cx), ConnectEvent::Connected => { self.screen = Screen::Main; self.set_connection(Connection::Connected, cx); + self.status.update(cx, |bar, cx| bar.load_settings(cx)); } ConnectEvent::SetupRequired => { self.screen = Screen::Setup; @@ -199,6 +264,30 @@ impl Render for Shell { }); div() + .on_drop(cx.listener(|shell, item: &ExternalPaths, window, cx| { + if classify_drop(Some(item), None) == DropKind::TorrentFiles { + shell + .main + .update(cx, |main, cx| main.open_add_with_paths(item.paths(), cx)); + } else { + shell + .main + .update(cx, |main, cx| main.show_not_torrent_toast(cx)); + } + window.refresh(); + })) + .on_drop(cx.listener(|shell, item: &ClipboardItem, window, cx| { + let text = item.text(); + match classify_drop(None, text.as_deref()) { + DropKind::Magnet => shell.main.update(cx, |main, cx| { + main.open_add_with_magnet(text.as_deref().unwrap_or_default(), cx); + }), + DropKind::Other | DropKind::TorrentFiles => shell + .main + .update(cx, |main, cx| main.show_not_torrent_toast(cx)), + } + window.refresh(); + })) .flex() .size_full() .child( @@ -209,7 +298,20 @@ impl Render for Shell { .child(nav) .child(filters), ) - .child(self.body()) + .child( + div() + .flex() + .flex_col() + .flex_1() + .children(self.lost_banner().map(|text| { + div() + .id("daemon-lost-banner") + .px_3() + .py_1() + .child(fluent_primitives::Label::new(text)) + })) + .child(self.body()), + ) } } @@ -223,6 +325,21 @@ mod tests { use super::*; + #[test] + fn classifies_torrent_magnet_and_other_drops() { + let paths = ExternalPaths( + [std::path::PathBuf::from("one.TORRENT")] + .into_iter() + .collect(), + ); + assert_eq!(classify_drop(Some(&paths), None), DropKind::TorrentFiles); + assert_eq!( + classify_drop(None, Some(" magnet:?xt=urn:btih:abc")), + DropKind::Magnet + ); + assert_eq!(classify_drop(None, Some("hello")), DropKind::Other); + } + /// A daemon whose `/auth/login` answers `login`, with the snapshot routes the main screen polls. fn daemon(runtime: &tokio::runtime::Runtime, login: u16) -> MockServer { const FIXTURES: &str = concat!( @@ -291,6 +408,220 @@ mod tests { drive(cx, |cx| connect.read_with(cx, |s, _| !s.is_pending())); } + /// Sign in, then start the main screen's poll (render does this in the app). + fn signed_in( + cx: &mut TestAppContext, + runtime: &tokio::runtime::Runtime, + ) -> (Entity, Entity, MockServer) { + let server = daemon(runtime, 200); + let (shell, status) = shell(cx); + sign_in(&shell, cx, &server.uri(), "secret"); + let main = shell.read_with(cx, |s, _| s.main.clone()); + main.update(cx, |main, cx| main.start_polling_for_test(cx)); + (shell, status, server) + } + + /// Step the fake clock a second at a time until `done`. + fn tick_until(cx: &mut TestAppContext, mut done: impl FnMut(&mut TestAppContext) -> bool) { + for _ in 0..1500 { + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + if done(cx) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("condition never reached"); + } + + fn failing_routes(runtime: &tokio::runtime::Runtime, server: &MockServer, status: u16) { + runtime.block_on(async { + server.reset().await; + for route in ["/torrents", "/stats"] { + // A 401 triggers one refresh; the daemon refuses that too. + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; + } + Mock::given(method("POST")) + .and(path("/auth/refresh")) + .respond_with(ResponseTemplate::new(401)) + .mount(server) + .await; + }); + } + + #[gpui::test] + fn lost_daemon_shows_banner_then_clears_on_recovery(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (shell, status, server) = signed_in(cx, &runtime); + let main = shell.read_with(cx, |s, _| s.main.clone()); + tick_until(cx, |cx| main.read_with(cx, |m, _| m.torrent_count() > 0)); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + + failing_routes(&runtime, &server, 503); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.lost_banner().is_some()) + }); + let banner = shell.read_with(cx, |s, _| s.lost_banner()).unwrap(); + assert!( + banner.starts_with("Lost connection to daemon — retrying in "), + "{banner}" + ); + assert!(main.read_with(cx, |m, _| m.daemon_lost())); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Main); + + // Actions that need the daemon are refused while it is lost. + let before = runtime.block_on(server.received_requests()).unwrap().len(); + main.update(cx, |m, cx| m.open_add(cx)); + assert!(!main.read_with(cx, |m, _| m.add_open())); + assert_eq!( + runtime.block_on(server.received_requests()).unwrap().len(), + before + ); + + // The first good poll clears the banner. + runtime.block_on(async { + server.reset().await; + for (route, file) in [("/torrents", "torrents.json"), ("/stats", "stats.json")] { + let body = std::fs::read_to_string(format!( + "{}/../rtbit-client/tests/fixtures/{file}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json")) + .mount(&server) + .await; + } + }); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.lost_banner().is_none()) + }); + assert!(!main.read_with(cx, |m, _| m.daemon_lost())); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Connected + ); + } + + #[gpui::test] + fn unauthorised_poll_routes_back_to_connect(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (shell, status, server) = signed_in(cx, &runtime); + let main = shell.read_with(cx, |s, _| s.main.clone()); + tick_until(cx, |cx| main.read_with(cx, |m, _| m.torrent_count() > 0)); + failing_routes(&runtime, &server, 401); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.screen() == Screen::Connect) + }); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Disconnected + ); + } + + /// Screens that load on selection need a bridge; this one points nowhere. + fn install_idle_bridge(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + cx.update(|cx| { + crate::bridge::Bridge::new("http://127.0.0.1:1") + .unwrap() + .install(cx) + }); + } + + #[gpui::test] + fn no_banner_while_reachable(cx: &mut TestAppContext) { + let (shell, _status) = shell(cx); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + } + + #[gpui::test] + fn daemon_lost_event_sets_banner_and_error_badge_then_recovers(cx: &mut TestAppContext) { + let (shell, status) = shell(cx); + shell.update(cx, |s, cx| { + s.on_main_event(MainEvent::DaemonLost { retry_in_secs: 8 }, cx) + }); + assert_eq!( + shell.read_with(cx, |s, _| s.lost_banner()).as_deref(), + Some("Lost connection to daemon — retrying in 8s") + ); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Error + ); + shell.update(cx, |s, cx| s.on_main_event(MainEvent::DaemonRecovered, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Connected + ); + } + + #[gpui::test] + fn unauthorised_event_returns_to_connect(cx: &mut TestAppContext) { + install_idle_bridge(cx); + let (shell, status) = shell(cx); + shell.update(cx, |s, cx| { + s.select_screen("settings", cx); + s.on_main_event(MainEvent::Unauthorised, cx); + }); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Disconnected + ); + } + + #[gpui::test] + fn connect_events_route_screens_and_badge(cx: &mut TestAppContext) { + let (shell, status) = shell(cx); + let badge = |cx: &mut TestAppContext| status.read_with(cx, |s, _| s.connection()); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::Connecting, cx)); + assert_eq!(badge(cx), Connection::Connecting); + shell.update(cx, |s, cx| { + s.on_connect_event(ConnectEvent::SetupRequired, cx) + }); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Setup); + assert_eq!(badge(cx), Connection::Disconnected); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::Connected, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Main); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::SignedOut, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + } + + #[gpui::test] + fn select_filter_keeps_screen_and_ignores_unknown_labels(cx: &mut TestAppContext) { + install_idle_bridge(cx); + let (shell, _status) = shell(cx); + shell.update(cx, |s, cx| s.select_screen("rss", cx)); + let target = StatusFilter::ALL[1]; + shell.update(cx, |s, cx| s.select_filter(target.label(), cx)); + assert_eq!(shell.read_with(cx, |s, _| s.filter), target); + shell.update(cx, |s, cx| s.select_filter("no such filter", cx)); + assert_eq!(shell.read_with(cx, |s, _| s.filter), target); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Rss); + } + + #[gpui::test] + fn nav_ids_are_unique_and_unknown_id_keeps_screen(cx: &mut TestAppContext) { + let ids: Vec<_> = NAV.iter().map(|(id, _, _)| *id).collect(); + let mut dedup = ids.clone(); + dedup.sort_unstable(); + dedup.dedup(); + assert_eq!(ids.len(), dedup.len()); + let (shell, _status) = shell(cx); + shell.update(cx, |s, cx| s.select_screen("nope", cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + } + #[gpui::test] fn starts_on_connect_disconnected(cx: &mut TestAppContext) { let (shell, status) = shell(cx); diff --git a/crates/rtbit-fluent/src/table_prefs.rs b/crates/rtbit-fluent/src/table_prefs.rs new file mode 100644 index 00000000..706e04c6 --- /dev/null +++ b/crates/rtbit-fluent/src/table_prefs.rs @@ -0,0 +1,139 @@ +//! Torrent table preferences kept in a small local JSON file: the sort column +//! and direction, and any column widths that differ from the defaults. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +const FILE_NAME: &str = "table.json"; + +/// Smallest width a stored column may take, so a bad file cannot hide a column. +pub const MIN_COLUMN_WIDTH: f32 = 40.0; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TablePrefs { + /// Sort column id and whether the order is ascending. + pub sort: Option<(String, bool)>, + pub widths: BTreeMap, + pub status: Option, + pub category: Option, + pub tag: Option, + pub tracker: Option, +} + +impl TablePrefs { + /// `/rtbit-fluent/table.json`, or `None` when no home is known. + #[must_use] + pub fn default_path() -> Option { + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) + }) + }?; + Some(base.join("rtbit-fluent").join(FILE_NAME)) + } + + /// Read the file; a missing or unreadable file gives the defaults. + #[must_use] + pub fn load(path: &Path) -> Self { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|value| Self::from_json(&value)) + .unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, serde_json::to_string_pretty(&self.to_json())?) + } + + fn to_json(&self) -> Value { + let sort = self + .sort + .as_ref() + .map(|(column, ascending)| json!({ "column": column, "ascending": ascending })); + json!({ "sort": sort, "widths": self.widths, "status": self.status, + "category": self.category, "tag": self.tag, "tracker": self.tracker }) + } + + fn from_json(value: &Value) -> Self { + let sort = value.get("sort").and_then(|sort| { + let column = sort.get("column")?.as_str()?.to_owned(); + let ascending = sort.get("ascending")?.as_bool()?; + Some((column, ascending)) + }); + let widths = value + .get("widths") + .and_then(Value::as_object) + .map(|map| { + map.iter() + .filter_map(|(id, width)| { + let width = width.as_f64()? as f32; + (width.is_finite() && width >= MIN_COLUMN_WIDTH) + .then(|| (id.clone(), width)) + }) + .collect() + }) + .unwrap_or_default(); + let string = |key| value.get(key).and_then(Value::as_str).map(str::to_owned); + Self { + sort, + widths, + status: string("status"), + category: string("category"), + tag: string("tag"), + tracker: string("tracker"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("rtbit-fluent-{name}-{}", std::process::id())) + } + + #[test] + fn round_trips_through_the_file() { + let dir = temp_path("roundtrip"); + let path = dir.join("nested").join(FILE_NAME); + let mut prefs = TablePrefs { + sort: Some(("speed".to_owned(), false)), + ..Default::default() + }; + prefs.widths.insert("name".to_owned(), 411.5); + prefs.save(&path).unwrap(); + assert_eq!(TablePrefs::load(&path), prefs); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_or_corrupt_files_give_defaults() { + let dir = temp_path("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(FILE_NAME); + assert_eq!(TablePrefs::load(&path), TablePrefs::default()); + std::fs::write(&path, "not json").unwrap(); + assert_eq!(TablePrefs::load(&path), TablePrefs::default()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn implausible_widths_are_dropped() { + let value = json!({ "widths": { "name": 5, "size": 120, "eta": "wide" } }); + let prefs = TablePrefs::from_json(&value); + assert_eq!(prefs.widths.len(), 1); + assert_eq!(prefs.widths["size"], 120.0); + } +} diff --git a/crates/rtbit-fluent/src/widgets/status_bar.rs b/crates/rtbit-fluent/src/widgets/status_bar.rs index 24aa32e7..5c81d35e 100644 --- a/crates/rtbit-fluent/src/widgets/status_bar.rs +++ b/crates/rtbit-fluent/src/widgets/status_bar.rs @@ -1,15 +1,26 @@ //! Bottom status bar: connection badge, torrent total and live speeds. -use fluent_primitives::{ConnectionBadge, ConnectionBadgeState, Label, LabelSize}; -use gpui::{Context, IntoElement, Render, Window, div, prelude::*}; +use fluent_primitives::{ + Button, ButtonAppearance, ConnectionBadge, ConnectionBadgeState, Label, LabelSize, TextInput, +}; +use gpui::{Context, IntoElement, Render, Task, Window, div, prelude::*}; +use rtbit_client::{RateLimits, SettingsSnapshot}; use rtbit_ui_core::{Connection, Snapshot, format}; +use crate::bridge::Bridge; + /// Status bar state. The shell pushes values in; nothing here talks to the daemon. pub struct StatusBar { connection: Connection, torrents: u64, download: String, upload: String, + limits: RateLimits, + alt_enabled: bool, + popover: bool, + download_input: Option>, + upload_input: Option>, + _limit_request: Option>, } impl StatusBar { @@ -20,6 +31,12 @@ impl StatusBar { torrents: 0, download: format::speed(&Default::default()), upload: format::speed(&Default::default()), + limits: RateLimits::default(), + alt_enabled: false, + popover: false, + download_input: None, + upload_input: None, + _limit_request: None, } } @@ -41,6 +58,85 @@ impl StatusBar { self.upload = format::speed(&snapshot.stats.upload_speed); cx.notify(); } + + /// Update the limits displayed in the status bar after a settings poll. + pub fn apply_settings(&mut self, settings: &SettingsSnapshot, cx: &mut Context) { + self.limits = settings.limits.clone(); + self.alt_enabled = settings.alt.enabled; + cx.notify(); + } + + /// Fetch the current session limits when a daemon connection becomes ready. + pub fn load_settings(&mut self, cx: &mut Context) { + if !cx.has_global::() { + return; + } + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let reply = cx.update(|cx| { + cx.global::() + .call(|client| async move { client.lock().await.settings().await }) + }); + if let Ok(Ok(settings)) = reply.await { + entity.update(cx, |bar, cx| bar.apply_settings(&settings, cx)); + } + })); + } + + fn toggle_alt(&mut self, cx: &mut Context) { + let previous = self.alt_enabled; + let next = !previous; + self.alt_enabled = next; + cx.notify(); + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let result = cx.update(|cx| { + cx.global::().call(move |client| async move { + client.lock().await.set_alt_enabled(next).await + }) + }); + let ok = result.await.is_ok_and(|reply| reply.is_ok()); + if !ok { + entity.update(cx, |bar, cx| { + bar.alt_enabled = previous; + cx.notify(); + }); + } + })); + } + + fn apply_limits(&mut self, cx: &mut Context) { + let download_bps = self + .download_input + .as_ref() + .and_then(|input| input.read(cx).text().trim().parse::().ok()); + let upload_bps = self + .upload_input + .as_ref() + .and_then(|input| input.read(cx).text().trim().parse::().ok()); + let next = RateLimits { + download_bps, + upload_bps, + ..self.limits.clone() + }; + let previous = self.limits.clone(); + self.limits = next.clone(); + self.popover = false; + cx.notify(); + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let result = cx.update(|cx| { + cx.global::() + .call(move |client| async move { client.lock().await.set_limits(&next).await }) + }); + if !result.await.is_ok_and(|reply| reply.is_ok()) { + entity.update(cx, |bar, cx| { + bar.limits = previous; + cx.notify(); + }); + } + })); + } } impl Default for StatusBar { @@ -59,7 +155,36 @@ fn badge_state(connection: Connection) -> ConnectionBadgeState { } impl Render for StatusBar { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.download_input.is_none() { + self.download_input = + Some(cx.new(|_| TextInput::new().placeholder("Download B/s (blank = unlimited)"))); + self.upload_input = + Some(cx.new(|_| TextInput::new().placeholder("Upload B/s (blank = unlimited)"))); + } + let me = cx.entity(); + let toggle = me.clone(); + let download = me.clone(); + let upload = me.clone(); + let apply = me.clone(); + let limits = if self.alt_enabled { + format!( + "{} / {}", + format::bytes(self.limits.download_bps.unwrap_or(0) as u64), + format::bytes(self.limits.upload_bps.unwrap_or(0) as u64) + ) + } else { + format!( + "{} / {}", + self.limits + .download_bps + .map_or_else(|| "∞".to_owned(), |v| format::bytes(v as u64)), + self.limits + .upload_bps + .map_or_else(|| "∞".to_owned(), |v| format::bytes(v as u64)) + ) + }; + let popover = self.popover; div() .flex() .items_center() @@ -73,5 +198,150 @@ impl Render for StatusBar { .child(Label::new(format!("{} torrents", self.torrents)).size(LabelSize::Caption)) .child(Label::new(format!("↓ {}", self.download)).size(LabelSize::Caption)) .child(Label::new(format!("↑ {}", self.upload)).size(LabelSize::Caption)) + .child( + Button::new("status-download-limit") + .label(format!( + "DL {}", + limits.split_once(" / ").map_or("∞", |v| v.0) + )) + .appearance(ButtonAppearance::Subtle) + .on_click(move |_, _, cx| { + download.update(cx, |bar, cx| { + bar.popover = true; + cx.notify(); + }); + }), + ) + .child( + Button::new("status-upload-limit") + .label(format!( + "UL {}", + limits.split_once(" / ").map_or("∞", |v| v.1) + )) + .appearance(ButtonAppearance::Subtle) + .on_click(move |_, _, cx| { + upload.update(cx, |bar, cx| { + bar.popover = true; + cx.notify(); + }); + }), + ) + .child( + Button::new("status-alt-speed") + .label(if self.alt_enabled { + "Alt speed: on" + } else { + "Alt speed: off" + }) + .appearance(if self.alt_enabled { + ButtonAppearance::Accent + } else { + ButtonAppearance::Subtle + }) + .on_click(move |_, _, cx| { + toggle.update(cx, |bar, cx| bar.toggle_alt(cx)); + }), + ) + .when(popover, |element| { + element.child( + div() + .flex() + .gap_1() + .child(self.download_input.clone().expect("created above")) + .child(self.upload_input.clone().expect("created above")) + .child(Button::new("status-limit-apply").label("Apply").on_click( + move |_, _, cx| { + apply.update(cx, |bar, cx| bar.apply_limits(cx)); + }, + )), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + use rtbit_client::{SessionStats, TorrentList}; + + use super::*; + + const FIXTURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../rtbit-client/tests/fixtures" + ); + + fn snapshot() -> Snapshot { + let read = |name: &str| std::fs::read_to_string(format!("{FIXTURES}/{name}")).unwrap(); + Snapshot { + torrents: serde_json::from_str::(&read("torrents.json")).unwrap(), + stats: serde_json::from_str::(&read("stats.json")).unwrap(), + } + } + + #[test] + fn badge_state_maps_every_connection() { + assert_eq!( + badge_state(Connection::Disconnected), + ConnectionBadgeState::Disconnected + ); + assert_eq!( + badge_state(Connection::Connecting), + ConnectionBadgeState::Pairing + ); + assert_eq!( + badge_state(Connection::Connected), + ConnectionBadgeState::Paired + ); + assert_eq!(badge_state(Connection::Error), ConnectionBadgeState::Error); + } + + #[test] + fn new_bar_is_disconnected_with_zero_speeds() { + let bar = StatusBar::default(); + assert_eq!(bar.connection(), Connection::Disconnected); + assert_eq!(bar.torrents, 0); + assert_eq!(bar.download, "0 B/s"); + assert_eq!(bar.upload, "0 B/s"); + } + + #[gpui::test] + fn snapshot_sets_count_and_formatted_speeds(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.torrents, 1); + assert_eq!(bar.download, format::speed(&snap.stats.download_speed)); + assert_eq!(bar.upload, format::speed(&snap.stats.upload_speed)); + assert_ne!(bar.download, "0 B/s"); + assert!(bar.download.ends_with("/s")); + }); + } + + #[gpui::test] + fn later_snapshot_replaces_earlier_values(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let mut snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + snap.torrents.total = 7; + snap.stats.download_speed.mbps = 0.0; + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.torrents, 7); + assert_eq!(bar.download, "0 B/s"); + }); + } + + #[gpui::test] + fn set_connection_updates_state_and_keeps_numbers(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.update(cx, |bar, cx| bar.set_connection(Connection::Error, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.connection(), Connection::Error); + assert_eq!(bar.torrents, 1); + }); } } diff --git a/crates/rtbit-ui-core/Cargo.toml b/crates/rtbit-ui-core/Cargo.toml index 2e83cda7..fde28c6e 100644 --- a/crates/rtbit-ui-core/Cargo.toml +++ b/crates/rtbit-ui-core/Cargo.toml @@ -15,4 +15,4 @@ rtbit-client = { path = "../rtbit-client" } tokio = { workspace = true, features = ["rt-multi-thread"] } [target.'cfg(target_os = "linux")'.dependencies] -secret-service = { version = "4", default-features = false, features = ["rt-async-io-crypto-rust"] } +secret-service = { version = "5", default-features = false, features = ["rt-async-io-crypto-rust"] } diff --git a/crates/rtbit-ui-core/src/error.rs b/crates/rtbit-ui-core/src/error.rs index 03448cdf..eada7f64 100644 --- a/crates/rtbit-ui-core/src/error.rs +++ b/crates/rtbit-ui-core/src/error.rs @@ -1,6 +1,6 @@ //! Safe daemon error text. //! -//! The client paints it with `fluent2_egui::surfaces::MessageBar`. This +//! The client paints it with `fluent_layout::MessageBar`. This //! module only maps a client error onto a fixed sentence so a transport failure never //! shows a token, a body or a host detail. A 400 keeps the daemon's //! validation sentence, which is already safe. @@ -13,7 +13,7 @@ pub fn map_error(err: &rtbit_client::Error) -> String { rtbit_client::Error::Daemon { status, message: Some(message), - } if status.as_u16() == 400 && !message.is_empty() => message.clone(), + } if matches!(status.as_u16(), 400 | 409) && !message.is_empty() => message.clone(), rtbit_client::Error::Daemon { status, .. } => { format!("The daemon returned an error ({status}).") } diff --git a/crates/rtbit-ui-core/src/format.rs b/crates/rtbit-ui-core/src/format.rs index 941339bc..94d8694e 100644 --- a/crates/rtbit-ui-core/src/format.rs +++ b/crates/rtbit-ui-core/src/format.rs @@ -35,6 +35,29 @@ pub fn state_label(state: Option) -> String { state.map_or_else(|| "—".to_owned(), |state| state.label().to_owned()) } +/// Prefix of the daemon's error for a stopped torrent whose payload is +/// missing or short; the rest names the first missing path. +pub const MISSING_FILES_ERROR: &str = "payload is missing or incomplete"; + +/// Whether the daemon flagged this torrent's payload as missing. +#[must_use] +pub fn is_missing_files(stats: Option<&rtbit_client::TorrentStats>) -> bool { + stats + .and_then(|stats| stats.error.as_deref()) + .is_some_and(|error| error.starts_with(MISSING_FILES_ERROR)) +} + +/// The state word for a row: "Files missing" when the payload is missing, +/// otherwise [`state_label`]. +#[must_use] +pub fn row_state_label(stats: Option<&rtbit_client::TorrentStats>) -> String { + if is_missing_files(stats) { + "Files missing".to_owned() + } else { + state_label(stats.and_then(|stats| stats.state)) + } +} + /// `progress / total`, or just the size when the total is unknown. #[must_use] pub fn progress(done: u64, total: u64) -> String { @@ -62,5 +85,12 @@ mod tests { ); assert_eq!(state_label(Some(TorrentState::Live)), "live"); assert_eq!(state_label(None), "—"); + let missing = rtbit_client::TorrentStats { + state: Some(TorrentState::Paused), + error: Some("payload is missing or incomplete on read-only storage: a/b.bin".into()), + ..Default::default() + }; + assert_eq!(row_state_label(Some(&missing)), "Files missing"); + assert_eq!(row_state_label(None), "—"); } } diff --git a/crates/rtbit/src/main.rs b/crates/rtbit/src/main.rs index d2a53815..22f3fad9 100644 --- a/crates/rtbit/src/main.rs +++ b/crates/rtbit/src/main.rs @@ -160,7 +160,7 @@ struct Opts { #[arg( long = "shutdown-timeout", value_parser = humantime::parse_duration, - default_value = "30s", + default_value = "20s", env = "RTBIT_SHUTDOWN_TIMEOUT" )] shutdown_timeout: Duration, @@ -333,9 +333,13 @@ struct Opts { socks_url: Option, /// How many torrents can be initializing (rehashing) at the same time - #[arg(long, default_value = "5", env = "RTBIT_CONCURRENT_INIT_LIMIT")] + #[arg(long, default_value = "2", env = "RTBIT_CONCURRENT_INIT_LIMIT")] concurrent_init_limit: usize, + /// Cap the combined disk read rate of torrent checks, in MiB/s (default unlimited) + #[arg(long, env = "RTBIT_RECHECK_READ_MIBPS")] + recheck_read_mibps: Option, + /// Set the process umask to this value. /// /// Default is inherited from your environment (usually 022). @@ -375,6 +379,11 @@ struct Opts { #[arg(long, env = "RTBIT_ALLOWLIST_URL")] allowlist_url: Option, + /// Hosts or IPs that server-side URL fetches (torrent URL adds, RSS, blocklists) may reach + /// even when they resolve to a private, loopback or link-local address. Comma-separated. + #[arg(long, env = "RTBIT_FETCH_ALLOWLIST", value_delimiter = ',')] + fetch_allowlist: Vec, + /// The filename with tracker URLs to always use for each torrent. Newline-delimited. #[arg(long, env = "RTBIT_TRACKERS_FILENAME")] trackers_filename: Option, @@ -589,6 +598,36 @@ enum SubCommand { Auth(AuthOpts), /// Inspect the effective configuration. Config(ConfigOpts), + /// Run read-only diagnostics against saved session state. + Diagnose(DiagnoseOpts), +} + +#[derive(Parser)] +struct DiagnoseOpts { + #[clap(subcommand)] + subcommand: DiagnoseSubcommand, +} + +#[derive(Parser)] +enum DiagnoseSubcommand { + /// Report persisted torrents whose expected payload files are missing or the wrong size. + Missing(DiagnoseMissingOpts), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum DiagnoseOutput { + Table, + Json, +} + +#[derive(Parser)] +struct DiagnoseMissingOpts { + /// JSON persistence directory. Defaults to rtbit's normal session directory. + #[arg(long)] + persistence_location: Option, + /// Report output format. + #[arg(long, value_enum, default_value = "table")] + output: DiagnoseOutput, } #[derive(Parser)] @@ -717,6 +756,10 @@ fn main() -> anyhow::Result<()> { return run_config_command(&opts, config_opts); } + if let SubCommand::Diagnose(diagnose_opts) = &opts.subcommand { + return run_diagnose_command(diagnose_opts); + } + #[cfg(not(target_os = "windows"))] if let Some(umask) = opts.umask { unsafe { libc::umask(umask) }; @@ -792,6 +835,62 @@ fn main() -> anyhow::Result<()> { } } +fn run_diagnose_command(opts: &DiagnoseOpts) -> anyhow::Result<()> { + let DiagnoseSubcommand::Missing(opts) = &opts.subcommand; + let persistence_location = opts + .persistence_location + .clone() + .map(Ok) + .unwrap_or_else(SessionPersistenceConfig::default_json_persistence_folder)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build()?; + let reports = runtime.block_on(librtbit::report_missing_files(&persistence_location))?; + match opts.output { + DiagnoseOutput::Json => println!("{}", serde_json::to_string_pretty(&reports)?), + DiagnoseOutput::Table => print_missing_table(&reports), + } + Ok(()) +} + +fn print_missing_table(reports: &[librtbit::MissingTorrentReport]) { + if reports.is_empty() { + println!("No torrents have missing or wrong-sized payload files."); + return; + } + for report in reports { + println!( + "torrent {} {} ({})\nsave_path: {}\ndiagnosis: {:?}", + report.id, + report.name, + report.info_hash, + report.save_path.display(), + report.diagnosis + ); + println!("status\tparent\texpected\tactual\tpath"); + for file in &report.files { + let (status, actual) = match file.status { + librtbit::MissingFileStatus::Exists => ("exists", file.expected_size.to_string()), + librtbit::MissingFileStatus::WrongSize { actual_size } => { + ("wrong-size", actual_size.to_string()) + } + librtbit::MissingFileStatus::Absent => ("absent", "-".into()), + }; + println!( + "{status}\t{}\t{}\t{actual}\t{}", + if file.parent_dir_exists { + "exists" + } else { + "absent" + }, + file.expected_size, + file.path.display() + ); + } + println!(); + } +} + async fn parse_trackers_file(filename: &str) -> anyhow::Result> { let content = tokio::fs::read_to_string(filename) .await @@ -906,6 +1005,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result } }), concurrent_init_limit: Some(opts.concurrent_init_limit), + recheck_read_mibps: opts.recheck_read_mibps, root_span: None, fastresume: false, fastresume_validation_denom: None, @@ -920,6 +1020,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result }, blocklist_url: opts.blocklist_url.take(), allowlist_url: opts.allowlist_url.take(), + fetch_allowlist: std::mem::take(&mut opts.fetch_allowlist), disable_local_service_discovery: opts.disable_local_peer_discovery, disable_trackers: opts.disable_trackers, trackers, @@ -964,6 +1065,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result token_store, credential_store: Some(credential_store), api_keys, + shutdown_token: Some(cancel.clone()), // We need to install prometheus recorder early before we registered any metrics. #[cfg(feature = "prometheus")] @@ -1046,8 +1148,18 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result let result = http_api_fut.await; // The signal handler cancels the HTTP API, so this also runs on - // SIGINT and SIGTERM. Save torrent state before the process exits. - session.stop().await; + // SIGINT and SIGTERM. Save torrent state before the process exits, + // but never let a slow persistence backend outlive the shutdown + // deadline (the signal handler enforces the same hard bound). + if tokio::time::timeout(opts.shutdown_timeout, session.stop()) + .await + .is_err() + { + warn!( + timeout = ?opts.shutdown_timeout, + "graceful shutdown timed out before all torrent state was saved" + ); + } result } }, @@ -1266,6 +1378,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result SubCommand::Completions(_) => unreachable!(), SubCommand::Auth(_) => unreachable!(), SubCommand::Config(_) => unreachable!(), + SubCommand::Diagnose(_) => unreachable!(), } } @@ -1678,7 +1791,7 @@ mod tests { #[test] fn shutdown_timeout_defaults_to_30s_and_is_configurable() { let opts = parse_server_opts(&[]); - assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(30)); + assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(20)); let opts = parse_server_opts(&["--shutdown-timeout", "2m"]); assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(120)); diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..48015cfc --- /dev/null +++ b/deny.toml @@ -0,0 +1,70 @@ +# Dependency policy for the complete workspace. Keep CI's cargo-deny version in +# .github/workflows/policy.yml in sync with local verification. + +[graph] +all-features = true + +[advisories] +ignore = [ + # Accepted 2026-11-02: transitive dependency; upstream migration required. + { id = "RUSTSEC-2025-0141", reason = "Accepted 2026-11-02: transitive bincode dependency; replace through its upstream consumers." }, + { id = "RUSTSEC-2025-0060", reason = "Accepted 2026-11-02: transitive crypto-hash dependency; replace through its upstream consumer." }, + { id = "RUSTSEC-2024-0436", reason = "Accepted 2026-11-02: transitive paste dependency; replace through its upstream consumers." }, + { id = "RUSTSEC-2024-0370", reason = "Accepted 2026-11-02: transitive proc-macro-error dependency; replace through its upstream consumer." }, + { id = "RUSTSEC-2026-0206", reason = "Accepted 2026-11-02: rustybuzz is supplied by the pinned gpui dependency tree." }, + { id = "RUSTSEC-2026-0192", reason = "Accepted 2026-11-02: ttf-parser is supplied by the pinned gpui dependency tree." }, + { id = "RUSTSEC-2026-0370", reason = "Accepted 2026-11-02: zvariant is transitive through desktop integration crates." }, + { id = "RUSTSEC-2026-0371", reason = "Accepted 2026-11-02: zbus_names is transitive through desktop integration crates." }, + { id = "RUSTSEC-2026-0372", reason = "Accepted 2026-11-02: zvariant_derive is transitive through desktop integration crates." }, + # Accepted 2026-11-02: vulnerable APIs are not called by rustTorrent; update + # the pinned gpui/desktop dependency trees before removing these entries. + { id = "RUSTSEC-2026-0385", reason = "Accepted 2026-11-02: ctutils is transitive through gpui and rustTorrent does not use its mask APIs directly." }, + { id = "RUSTSEC-2026-0293", reason = "Accepted 2026-11-02: ringbuf is transitive through gpui and rustTorrent does not call the affected APIs directly." }, + { id = "RUSTSEC-2026-0367", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0368", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0369", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0429", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, +] + +[licenses] +confidence-threshold = 0.93 +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] +unused-allowed-license = "allow" + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "allow" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [ + "https://github.com/gfx-rs/wgpu", + "https://github.com/thedancingdeveloper/fluent-gpui", + "https://github.com/zed-industries/font-kit", + "https://github.com/zed-industries/oo7", + "https://github.com/zed-industries/xim-rs.git", +] diff --git a/desktop/package.json b/desktop/package.json index 18bcb0c8..a89ee414 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,6 +6,6 @@ "tauri": "tauri" }, "devDependencies": { - "@tauri-apps/cli": "^2.12.3" + "@tauri-apps/cli": "^2.12.4" } } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fbf0954d..542b72b1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "rtbit-desktop" edition = "2024" version = "0.2.0-beta.4" description = "rtbit torrent client" -license = "" +license = "Apache-2.0" repository = "" [lints] diff --git a/docs/QBIT-API-PARITY.md b/docs/QBIT-API-PARITY.md index b7d29477..b691b3f5 100644 --- a/docs/QBIT-API-PARITY.md +++ b/docs/QBIT-API-PARITY.md @@ -8,9 +8,10 @@ and enforced, and where we currently stand. ## Current standing (upstream WebAPI 2.16.2, refreshed 2026-10-17) -**79 of 95 in-scope endpoints routed (~83%)** — 38 full, 41 partial — plus 2 -legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in -WebAPI 2.11. 35 of the 130 upstream endpoints are descoped (since 2026-08-30; + +**86 of 95 in-scope endpoints routed (~91%)** — 42 full, 44 partial — plus 2 legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in WebAPI 2.11. + +35 of the 130 upstream endpoints are descoped (since 2026-08-30; `clientdata/*` was brought back in scope for WI-17) (marked `out_of_scope`, enforced as unrouted): `search/*` (Indexarr covers discovery), `rss/*` (native `/rss` + Indexarr are the RSS story), SSL parameters, and qBittorrent-internal app misc (email, cookies, API keys, @@ -47,6 +48,11 @@ of H4, and tag persistence): - **Preferences**: `app/preferences` reports the seeding-limit fields (`max_ratio_enabled`, `max_ratio`, `max_ratio_act`, `max_seeding_time_enabled`, `max_seeding_time`) as disabled, using qBittorrent's default values. +- **Global share limits and discovery (WI-2884)**: `app/preferences` now reports + the session-wide seed limits and `dht`/`lsd`/`pex`, and `app/setPreferences` + applies `max_ratio*`, `max_seeding_time*`, `max_inactive_seeding_time*` + (global limits, used by torrents without their own) and `dht`/`lsd` (saved + for the next start; `pex` is always on). - **`app/setPreferences`**: unknown and unsupported fields are ignored, and `announce_port` is optional. Read-modify-write clients that post the whole preferences object now succeed. A present but invalid `announce_port` still @@ -82,6 +88,10 @@ of H4, and tag persistence): qBittorrent's default of 3. There is no combined `max_active_torrents` limit (reported as -1). A newly added torrent starts at once and is moved back to the queue by the next pass if the queue is full. +- **Per-file priorities**: `torrents/filePrio` accepts and persists qBittorrent's + `0` (skipped), `1` (normal), `6` (high), and `7` (maximum) levels. The values + round-trip through `torrents/files`; `0` retains the existing include/skip + behavior, while the picker requests pieces from higher-priority files first. - **Errors**: compat handlers return plain-text errors through one helper, with lowercase messages. Serde parse details are no longer echoed to clients. @@ -150,6 +160,8 @@ done: `torrents/stop`/`start`, `sync/maindata` (rid deltas) + `sync/torrentPeers `torrents/export`, `torrents/webseeds`, `torrents/count`, the `transfer/*Limit*` + get/setSpeedLimits + speed-limits-mode family, and `transfer/pauseSession`/`resumeSession`. +`torrents/pieceStates` reports the in-flight state (`1`) in addition to missing (`0`) and complete (`2`) pieces. Tracker tier and scrape `num_downloaded` remain pending because the current tracker registry does not retain that metadata. + Remaining work, roughly by cost: 1. **Needs a new engine method** (the `will be built` items): diff --git a/docs/ci.md b/docs/ci.md index a12ae33c..051610fd 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -30,6 +30,8 @@ See [TESTING.md](TESTING.md#webui-api-v2-compatibility-client-replay-and-differe | `.github/`, `ci/`, `.cargo/`, `Dockerfile*`, `.dockerignore`, `rust-toolchain.toml` | everything, as in the queue | | `crates/librtbit/` (outside the web UI) | `rust`, scoped to `swarmforge`; `desktop` | | `crates/rtbit/` | `rust`, scoped to `rtbit` | +| `crates/rtbit-client/`, `crates/rtbit-ui-core/` | `rust`, plus `rtbit-fluent` nextest (reverse dependencies) | +| `crates/librtbit/src/http_api_types.rs` | `rust`, plus `rtbit-fluent` nextest (API type consumer) | | `crates/librtbit/webui/` | `webui`, `gui-e2e` | | `desktop/src/` (the splash, tested by `e2e/desktop-splash.spec.ts`) | `webui`, `gui-e2e`, `desktop` | | `desktop/src-tauri/`, `packaging/scripts/check-desktop.sh` | `desktop` | @@ -54,6 +56,11 @@ toolchain, cargo config, workflows and task scripts) takes the full workspace. The scope is also widened when the classifier sees a Rust change it cannot attribute to a package, so it never silently runs nothing. +`rtbit-fluent` is a reverse dependency of `rtbit-client` and `rtbit-ui-core`, +and consumes `librtbit` API types from `http_api_types.rs`. Those paths set a +separate fluent flag so the `rust` job runs `cargo nextest -p rtbit-fluent` +without applying the daemon-only `default-tls` feature. + ### Cache and test runner The `rust` job restores a shared `Swatinem/rust-cache` cache keyed on the @@ -135,6 +142,11 @@ secrets. It runs: surfaces before a release; - on manual dispatch. +If a nightly or manually dispatched dry run fails, the workflow opens the +`[CI] Release dry run is failing` tracking issue, or comments with the latest +failed run when that issue is already open. Pull-request failures remain on the +pull request and do not create tracking issues. + It derives the tag from the `crates/rtbit/Cargo.toml` version (the release version `scripts/check-repo-hygiene.sh` enforces everywhere), builds the Linux and Windows binaries, the `.deb`, the `.rpm`, the service-files tarball, the diff --git a/docs/fluent-parity.md b/docs/fluent-parity.md new file mode 100644 index 00000000..6ab1a0a0 --- /dev/null +++ b/docs/fluent-parity.md @@ -0,0 +1,104 @@ +# Fluent client parity checklist + +This checklist compares the retired `rtbit-egui` client at the parent of +`b15018f` (removed by `b15018f`, WI-2770) with `rtbit-fluent` on graft line +`fluent-1`. “Done” means the Fluent client has the user-facing surface and +the corresponding bridge/client behavior; “missing” identifies follow-up +work; “dropped-on-purpose” records a deliberate boundary rather than a gap. + +Sources: [`rtbit-egui` in history](https://github.com/TheDancingDeveloper-org/rustTorrent/tree/b15018f%5E/crates/rtbit-egui), +[`rtbit-fluent`](../crates/rtbit-fluent), and the shared action/data contracts +in [`rtbit-ui-core`](../crates/rtbit-ui-core). + +## Screens and navigation + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Connect screen: daemon URL, username, password, Connect | done | `screens/connect.rs`; bridge login and connection events are wired. | +| First-run setup: setup token, username, password, confirmation, Create account | done | `screens/connect.rs`; setup validation and bridge setup are wired. | +| Session navigation: Settings, RSS, Sign out | done | `shell.rs`; sign-out clears the session and returns to Connect. | +| Connection status indicator and window status | done | `widgets/status_bar.rs` and `shell.rs`. | +| Torrents screen and live polling | done | `screens/main_screen.rs`; snapshot polling, loading, and error states are present. | +| Torrent detail pane | done | `screens/detail.rs`; selected torrent drives the pane. | +| RSS screen | done | `screens/rss.rs`; feeds, items, rules, download, and matched episodes are present. | +| Settings screen | done | `screens/settings.rs`; all six retired tabs remain available. | + +## Torrents screen + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Torrent table columns: name, state, size, down, up, category, tracker | done | `screens/main_screen.rs` data table and snapshot formatting. | +| Status filter: All, Live, Downloading, Paused, Error, Initializing | done | Shared `StatusFilter` list and Fluent selectable filter. | +| Add torrent dialog | done | `screens/add_dialog.rs`. | +| Add by magnet link | done | Magnet validation requires `xt=urn:btih:` and submits through the bridge. | +| Add by `.torrent` file | done | Native file picker path and file bytes are supported. | +| Save path and optional category | done | Add dialog fields and `AddRequest`. | +| Start paused | done | Add dialog switch is included in the request. | +| Start/resume selected torrent | done | Toolbar and context menu dispatch `RowAction::Start`. | +| Pause selected torrent | done | Toolbar and context menu dispatch `RowAction::Pause`. | +| Delete selected torrent, with confirmation | done | Toolbar/context menu and `ConfirmModal` dispatch `RowAction::Delete`. | +| Keyboard shortcuts: add, paste magnet, delete, pause, select all, filter focus, refresh, context menu | done | `screens/main_screen.rs` key context and actions. | +| Empty, loading, filtered-empty, and request-error states | done | Main table rendering and action error bar. | + +## Torrent detail + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| General tab with name, state, size, progress, rates, and metadata | done | `screens/detail.rs` `general` renderer. | +| Files tab with nested folders and files | done | Fluent file tree handles component paths and legacy slash-separated names. | +| Include/exclude file or folder selection | done | Checkbox and folder aggregation post file selections. | +| File priority label: Normal / Do not download / Mixed | done | `priority_label` and tree controls. | +| Pieces tab and piece progress summary | done | Pieces tab and empty/loading states are present. | +| Peers tab with address, state, download, and upload columns | done | `peers_table`. | +| Trackers tab with tracker URL, status, seeders, and leechers | done | `trackers_table`. | +| Add tracker URL and Add action | done | `screens/detail.rs` validates and submits the URL, reloads trackers, and preserves the selection and input on refusal. | +| No-selection, loading, and empty detail states | done | `empty_state_message` and async snapshot handling. | + +## RSS + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Feed list and selected feed items | done | `screens/rss.rs`; feed and item tables are rendered. | +| Item status: Downloaded / Download / No link | done | `item_status` and download action. | +| Download selected item | done | Bridge download action and disabled-state check. | +| Rules list with name, feeds, regex, priority, and enabled state | done | `rule_line` and rule table. | +| Add rule dialog/editor | done | Name, match regex, category, feed selection, enabled, and tracking fields are present. | +| Edit rule and matched episode history | done | Existing rule loads its fields and matched episodes. | +| Delete rule | done | Rule delete action is wired. | +| Rule validation and in-dialog save/cancel/error behavior | done | Client validation and bridge refusal remain in the editor. | +| Rule priority selector: Low / Normal / High | missing | Raise and implement [#287](https://github.com/TheDancingDeveloper-org/rustTorrent/issues/287) on graft line `fluent-1`; the current editor only displays the priority. | + +## Settings fields + +The retired client exposed six tabs. The Fluent implementation retains the +same tab set and field ownership; all rows below are present in the Fluent +draft, rendered form, validation, and section-specific save path unless noted. + +| Tab | Fields and actions | Status | +| --- | --- | --- | +| General | Append `.part` to incomplete files; Save | done | +| Connection | Connect/read-write/keep-alive timeouts; upload slots; magnet timeout; listen IP and port; DHT; local discovery; UPnP; read-only peer exchange; proxy URL; clear proxy; IP filter; saved-vs-running network warning; Use startup flags; Save | done | +| Speed | Download/upload limits; peer limit; connection limit; alternative limits; alternative schedule enabled/start/end/days; Save | done | +| Folders | Download folder; completed folder; required-folder validation; Save | done | +| Seeding | Ratio limit; seed time; Save | done | +| Advanced | Maximum concurrent handshakes; fast-resume validation denominator; Save | done | +| Settings loading, validation errors, busy state, save notices, and restart notice | done | `SettingsScreen` async load/save state and validation messages. | + +## Dialogs and boundaries + +| Retired egui dialog or behavior | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Connect/setup forms and failure messages | done | Connect screen renders both modes and keeps errors visible. | +| Add-torrent modal with Cancel, file picker, validation, and submit | done | `AddDialog` and `validate_add`. | +| Delete-torrent confirmation | done | Main-screen `ConfirmModal`, Escape, and Enter handling. | +| RSS add/edit rule editor with Cancel, Save, feed selection, and matched episodes | done | RSS editor and episode section. | +| RSS delete-rule confirmation | done | Delete action is wired through the RSS screen. | +| OS keychain refresh-token persistence | done | Connect bridge uses `OsKeychain`; only refresh tokens are stored. | +| Native egui viewport/window controls and egui-specific theme installation | dropped-on-purpose | Fluent/GPUI owns the window shell and theme; these are toolkit implementation details, not product behavior. | +| Retired egui test harness and egui-only stand-ins | dropped-on-purpose | The Fluent crate has its own GPUI/unit/integration coverage; no compatibility shim is required. | + +## Gap register + +The only missing product features found in this audit are tracked by the two +new items above. No other egui screen, column, action, settings field, or +dialog is intentionally left without a status. diff --git a/package-lock.json b/package-lock.json index bd1752db..fba66740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,13 @@ "name": "rtbit-desktop", "version": "0.2.0-beta.4", "devDependencies": { - "@tauri-apps/cli": "^2.12.3" + "@tauri-apps/cli": "^2.12.4" } }, "node_modules/@tauri-apps/cli": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.3.tgz", - "integrity": "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz", + "integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -36,23 +36,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.12.3", - "@tauri-apps/cli-darwin-x64": "2.12.3", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", - "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", - "@tauri-apps/cli-linux-arm64-musl": "2.12.3", - "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", - "@tauri-apps/cli-linux-x64-gnu": "2.12.3", - "@tauri-apps/cli-linux-x64-musl": "2.12.3", - "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", - "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", - "@tauri-apps/cli-win32-x64-msvc": "2.12.3" + "@tauri-apps/cli-darwin-arm64": "2.12.4", + "@tauri-apps/cli-darwin-x64": "2.12.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.4", + "@tauri-apps/cli-linux-arm64-musl": "2.12.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-musl": "2.12.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.4", + "@tauri-apps/cli-win32-x64-msvc": "2.12.4" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.3.tgz", - "integrity": "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz", + "integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==", "cpu": [ "arm64" ], @@ -67,9 +67,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.3.tgz", - "integrity": "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz", + "integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.3.tgz", - "integrity": "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz", + "integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==", "cpu": [ "arm" ], @@ -101,13 +101,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.3.tgz", - "integrity": "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz", + "integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -118,13 +121,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.3.tgz", - "integrity": "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz", + "integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -135,13 +141,16 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.3.tgz", - "integrity": "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz", + "integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -152,13 +161,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.3.tgz", - "integrity": "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz", + "integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -169,13 +181,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.3.tgz", - "integrity": "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz", + "integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -186,9 +201,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.3.tgz", - "integrity": "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz", + "integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==", "cpu": [ "arm64" ], @@ -203,9 +218,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.3.tgz", - "integrity": "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz", + "integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==", "cpu": [ "ia32" ], @@ -220,9 +235,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.12.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.3.tgz", - "integrity": "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz", + "integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==", "cpu": [ "x64" ], diff --git a/scripts/refresh-qbit-parity-spec.py b/scripts/refresh-qbit-parity-spec.py index 945fd14f..d53a33ac 100755 --- a/scripts/refresh-qbit-parity-spec.py +++ b/scripts/refresh-qbit-parity-spec.py @@ -30,6 +30,7 @@ Path(__file__).resolve().parent.parent / "crates/librtbit/src/http_api/handlers/qbit_parity_spec.json" ) +DOC_PATH = Path(__file__).resolve().parent.parent / "docs/QBIT-API-PARITY.md" # controller header -> URL scope (must match registerAPIController names in # qBittorrent's webapplication.cpp). @@ -48,6 +49,19 @@ def main() -> int: + check = len(sys.argv) == 2 and sys.argv[1] == "--check" + if check: + spec = json.loads(SPEC_PATH.read_text()) + expected = parity_summary(spec) + document = DOC_PATH.read_text() + marker = "" + actual = document.split(marker, 1)[1].split("\n", 2)[1] if marker in document else "" + if actual != expected: + print("parity summary is stale; run scripts/refresh-qbit-parity-spec.py ", file=sys.stderr) + print(f"expected: {expected}", file=sys.stderr) + print(f"actual: {actual or ''}", file=sys.stderr) + return 1 + return 0 if len(sys.argv) != 2: print(__doc__, file=sys.stderr) return 2 @@ -114,6 +128,7 @@ def main() -> int: } spec["endpoints"] = rows SPEC_PATH.write_text(json.dumps(spec, indent=2) + "\n") + update_document(spec) counts = {} for row in rows: @@ -130,5 +145,28 @@ def main() -> int: return 0 +def parity_summary(spec: dict) -> str: + upstream = [row for row in spec["endpoints"] + if row.get("upstream", True) and row["status"] != "out_of_scope"] + counts = {status: sum(row["status"] == status for row in upstream) + for status in ("full", "partial", "missing", "out_of_scope")} + implemented = counts["full"] + counts["partial"] + percentage = implemented / len(upstream) * 100 + return (f"**{implemented} of {len(upstream)} in-scope endpoints routed " + f"(~{percentage:.0f}%)** — {counts['full']} full, " + f"{counts['partial']} partial — plus 2 legacy aliases " + "(`torrents/pause`, `torrents/resume`) that upstream removed in WebAPI 2.11.") + + +def update_document(spec: dict) -> None: + marker = "" + document = DOC_PATH.read_text() + pattern = re.compile(rf"{re.escape(marker)}\n.*?(?=\n\n|\Z)", re.DOTALL) + replacement = f"{marker}\n{parity_summary(spec)}" + if not pattern.search(document): + raise RuntimeError("parity summary marker is missing from QBIT-API-PARITY.md") + DOC_PATH.write_text(pattern.sub(replacement, document, count=1)) + + if __name__ == "__main__": sys.exit(main())