Merge pull request #99 from michel/develop
release: Linux packages, cancellable login, download speed cap
This commit is contained in:
@@ -24,6 +24,7 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
outputs:
|
outputs:
|
||||||
sha: ${{ steps.commit.outputs.sha }}
|
sha: ${{ steps.commit.outputs.sha }}
|
||||||
|
version: ${{ steps.commit.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
# workflow_dispatch has a branch selector, but a Nightly run always means
|
# workflow_dispatch has a branch selector, but a Nightly run always means
|
||||||
# develop. Resolve it once so every target builds the identical commit
|
# develop. Resolve it once so every target builds the identical commit
|
||||||
@@ -36,13 +37,22 @@ jobs:
|
|||||||
- name: Record source commit
|
- name: Record source commit
|
||||||
id: commit
|
id: commit
|
||||||
shell: bash
|
shell: bash
|
||||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sha=$(git rev-parse HEAD)
|
||||||
|
base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
|
||||||
|
stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M)
|
||||||
|
[[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]]
|
||||||
|
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
binaries:
|
binaries:
|
||||||
name: ${{ matrix.target }}
|
name: ${{ matrix.target }}
|
||||||
needs: source
|
needs: source
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -95,6 +105,12 @@ jobs:
|
|||||||
dry-run: true
|
dry-run: true
|
||||||
dry-run-intended: true
|
dry-run-intended: true
|
||||||
|
|
||||||
|
- name: Build .deb and .rpm
|
||||||
|
if: endsWith(matrix.target, '-musl')
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly
|
||||||
|
|
||||||
- name: Store packaged binary
|
- name: Store packaged binary
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -102,6 +118,8 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
${{ steps.package.outputs.tar || steps.package.outputs.zip }}
|
${{ steps.package.outputs.tar || steps.package.outputs.zip }}
|
||||||
${{ steps.package.outputs.sha256 }}
|
${{ steps.package.outputs.sha256 }}
|
||||||
|
soulseek-rs-nightly-*.deb
|
||||||
|
soulseek-rs-nightly-*.rpm
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -137,6 +155,10 @@ jobs:
|
|||||||
soulseek-rs-nightly-aarch64-apple-darwin.sha256
|
soulseek-rs-nightly-aarch64-apple-darwin.sha256
|
||||||
soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256
|
soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256
|
||||||
soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256
|
soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256
|
||||||
|
soulseek-rs-nightly-amd64.deb
|
||||||
|
soulseek-rs-nightly-arm64.deb
|
||||||
|
soulseek-rs-nightly-x86_64.rpm
|
||||||
|
soulseek-rs-nightly-aarch64.rpm
|
||||||
)
|
)
|
||||||
printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets"
|
printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets"
|
||||||
find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets"
|
find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets"
|
||||||
@@ -219,9 +241,12 @@ jobs:
|
|||||||
|
|
||||||
smoke-test:
|
smoke-test:
|
||||||
name: Install published nightly
|
name: Install published nightly
|
||||||
needs: publish
|
needs: [source, publish]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.source.outputs.version }}
|
||||||
|
DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -233,7 +258,7 @@ jobs:
|
|||||||
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install
|
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install
|
||||||
run: |
|
run: |
|
||||||
sh website/public/install.sh --nightly
|
sh website/public/install.sh --nightly
|
||||||
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version
|
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION"
|
||||||
|
|
||||||
- name: Install with wget and no curl on PATH
|
- name: Install with wget and no curl on PATH
|
||||||
env:
|
env:
|
||||||
@@ -246,4 +271,21 @@ jobs:
|
|||||||
ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool"
|
ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool"
|
||||||
done
|
done
|
||||||
PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly
|
PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly
|
||||||
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version
|
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION"
|
||||||
|
|
||||||
|
- name: Install the .deb
|
||||||
|
run: |
|
||||||
|
curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb"
|
||||||
|
sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb
|
||||||
|
soulseek-rs --version | grep -F "$VERSION"
|
||||||
|
dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
|
||||||
|
dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs
|
||||||
|
|
||||||
|
- name: Install the .rpm
|
||||||
|
run: |
|
||||||
|
docker run --rm fedora:latest sh -ec "
|
||||||
|
dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm'
|
||||||
|
soulseek-rs --version | grep -F '$VERSION'
|
||||||
|
rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
|
||||||
|
rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs
|
||||||
|
"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Packaging
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'packaging/**'
|
||||||
|
- 'scripts/package-linux.sh'
|
||||||
|
- 'soulseek-rs/src/cli.rs'
|
||||||
|
- 'soulseek-rs/src/run.rs'
|
||||||
|
- 'soulseek-rs/src/commands/**'
|
||||||
|
- '.github/workflows/packaging.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: packaging-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
VERSION: 0.0.0+git202601010000.0000000
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
packages:
|
||||||
|
name: .deb and .rpm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Install musl toolchain
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||||
|
|
||||||
|
- name: Build the packages
|
||||||
|
env:
|
||||||
|
SOULSEEK_RS_VERSION: ${{ env.VERSION }}
|
||||||
|
TARGET: x86_64-unknown-linux-musl
|
||||||
|
run: |
|
||||||
|
cargo build --locked --release --target "$TARGET" --package soulseek-rs
|
||||||
|
sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly
|
||||||
|
|
||||||
|
- name: Install the .deb
|
||||||
|
run: |
|
||||||
|
sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb
|
||||||
|
soulseek-rs --version | grep -F "$VERSION"
|
||||||
|
dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
|
||||||
|
dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs
|
||||||
|
|
||||||
|
- name: Install the .rpm
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec "
|
||||||
|
dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm
|
||||||
|
soulseek-rs --version | grep -F '$VERSION'
|
||||||
|
rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz
|
||||||
|
rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs
|
||||||
|
"
|
||||||
|
|
||||||
|
flake:
|
||||||
|
name: Nix flake
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
|
||||||
|
- name: Build and run
|
||||||
|
run: |
|
||||||
|
nix build --print-build-logs
|
||||||
|
./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$'
|
||||||
|
test -s result/share/man/man1/soulseek-rs.1.gz
|
||||||
|
test -s result/share/fish/vendor_completions.d/soulseek-rs.fish
|
||||||
@@ -200,6 +200,16 @@ jobs:
|
|||||||
ref: refs/tags/${{ needs.release.outputs.tag }}
|
ref: refs/tags/${{ needs.release.outputs.tag }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and upload .deb and .rpm
|
||||||
|
if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.release.outputs.tag }}
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
run: |
|
||||||
|
sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist
|
||||||
|
gh release upload "$TAG" dist/* --clobber
|
||||||
|
|
||||||
# The tap regenerates its formula from whatever `gh release view` calls latest,
|
# The tap regenerates its formula from whatever `gh release view` calls latest,
|
||||||
# so it only needs a nudge once every archive and .sha256 is on the release —
|
# so it only needs a nudge once every archive and .sha256 is on the release —
|
||||||
# hence `needs: binaries` rather than firing alongside it. The tap also polls
|
# hence `needs: binaries` rather than firing alongside it. The tap also polls
|
||||||
|
|||||||
Generated
+24
-7
@@ -476,9 +476,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.2"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -486,9 +486,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.2"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -508,14 +508,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -524,6 +524,16 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_mangen"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"roff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color-eyre"
|
name = "color-eyre"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -2251,6 +2261,12 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roff"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -2550,6 +2566,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
"clap_mangen",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"directories",
|
"directories",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ resolver = "2"
|
|||||||
version = "19.0.0"
|
version = "19.0.0"
|
||||||
authors = ["Michel de Graaf"]
|
authors = ["Michel de Graaf"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.91"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/michel/soulseek-rs"
|
repository = "https://github.com/michel/soulseek-rs"
|
||||||
homepage = "https://re-invention.nl/soulseek-rs/"
|
homepage = "https://re-invention.nl/soulseek-rs/"
|
||||||
|
|||||||
@@ -107,17 +107,66 @@ numbered release. A failed download, checksum, or startup check leaves an
|
|||||||
existing binary untouched. Windows archives for both channels are on the
|
existing binary untouched. Windows archives for both channels are on the
|
||||||
[releases page](https://github.com/michel/soulseek-rs/releases).
|
[releases page](https://github.com/michel/soulseek-rs/releases).
|
||||||
|
|
||||||
The other stable routes need no installer script:
|
Or install it with a package manager. Stable follows the latest release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install michel/tap/soulseek-rs # macOS and Linux
|
# Debian, Ubuntu, Mint (on ARM: arm64 for amd64)
|
||||||
cargo install soulseek-rs # or from crates.io
|
curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb
|
||||||
|
sudo apt install ./soulseek-rs-amd64.deb
|
||||||
|
|
||||||
|
# Fedora, RHEL (on ARM: aarch64 for x86_64)
|
||||||
|
sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
nix run github:michel/soulseek-rs/master
|
||||||
|
|
||||||
|
# Homebrew, on macOS and Linux
|
||||||
|
brew install michel/tap/soulseek-rs
|
||||||
|
|
||||||
|
# cargo-binstall fetches the release binary and compiles nothing
|
||||||
|
cargo binstall soulseek-rs
|
||||||
|
|
||||||
|
# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one
|
||||||
|
cargo install --locked soulseek-rs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix,
|
||||||
|
Homebrew and cargo build it from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb
|
||||||
|
sudo apt install ./soulseek-rs-nightly-amd64.deb
|
||||||
|
|
||||||
|
sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm
|
||||||
|
|
||||||
|
nix run github:michel/soulseek-rs/develop
|
||||||
|
|
||||||
|
brew install --HEAD michel/tap/soulseek-rs
|
||||||
|
|
||||||
|
cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs
|
||||||
|
```
|
||||||
|
|
||||||
|
A `.deb` or `.rpm` does not update itself. Install the newer file over it. A
|
||||||
|
nightly sorts above the release it follows, so going back to stable is a
|
||||||
|
downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf
|
||||||
|
goes ahead. Both carry the man page and completions for bash, zsh and fish.
|
||||||
|
A release download prints the plain number, `19.0.0`. Any Nix build, and a
|
||||||
|
nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the
|
||||||
|
commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports
|
||||||
|
`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints
|
||||||
|
`19.0.0` too.
|
||||||
|
|
||||||
From source, `cargo build --release` leaves the binary at
|
From source, `cargo build --release` leaves the binary at
|
||||||
`target/release/soulseek-rs`. To build your own client on the protocol library,
|
`target/release/soulseek-rs`. To build your own client on the protocol library,
|
||||||
run `cargo add soulseek-rs-lib`.
|
run `cargo add soulseek-rs-lib`.
|
||||||
|
|
||||||
|
To package it for a distribution: a musl build is static, a glibc build links
|
||||||
|
only libc, libm and libgcc_s,
|
||||||
|
`soulseek-rs completions print <shell>` and `soulseek-rs man` write the
|
||||||
|
completion scripts and the man page to stdout, and a build with
|
||||||
|
`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm
|
||||||
|
recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Run it with no subcommand for the interactive TUI:
|
Run it with no subcommand for the interactive TUI:
|
||||||
@@ -332,6 +381,8 @@ soulseek-rs config path|list|get|set
|
|||||||
soulseek-rs portmap # test automatic port mapping
|
soulseek-rs portmap # test automatic port mapping
|
||||||
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
|
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
|
||||||
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
|
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
|
||||||
|
soulseek-rs completions print <SHELL> # the script on stdout, for packaging
|
||||||
|
soulseek-rs man # the man page on stdout
|
||||||
```
|
```
|
||||||
|
|
||||||
A script usually starts with `whoami`, which answers "are these credentials
|
A script usually starts with `whoami`, which answers "are these credentials
|
||||||
@@ -343,9 +394,9 @@ download folder. `--no-daemon` tests this machine's own login instead.
|
|||||||
`shares list` reads the config file either way, while `shares status` and
|
`shares list` reads the config file either way, while `shares status` and
|
||||||
`shares reindex` ask the session and report what peers can see.
|
`shares reindex` ask the session and report what peers can see.
|
||||||
|
|
||||||
Twenty need no credentials: `config path|list|get|set`,
|
Twenty-two need no credentials: `config path|list|get|set`,
|
||||||
`shares list|add|remove`, `wish add|remove|list`, `portmap`,
|
`shares list|add|remove`, `wish add|remove|list`, `portmap`,
|
||||||
`skills install|uninstall|list`, `completions install|uninstall`,
|
`skills install|uninstall|list`, `completions install|uninstall|print`, `man`,
|
||||||
`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and
|
`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and
|
||||||
`daemon stop` talk to a daemon that is already logged in, so a machine with no
|
`daemon stop` talk to a daemon that is already logged in, so a machine with no
|
||||||
account of its own can still ask whether one is running. `shares status` and
|
account of its own can still ask whether one is running. `shares status` and
|
||||||
|
|||||||
+14
-3
@@ -28,6 +28,11 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run
|
|||||||
| `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | |
|
| `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | |
|
||||||
| `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | |
|
| `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | |
|
||||||
|
|
||||||
|
- packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`,
|
||||||
|
`soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the
|
||||||
|
bash, zsh and fish completions. The names carry no version, so
|
||||||
|
`releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working,
|
||||||
|
|
||||||
- rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so
|
- rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so
|
||||||
`brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives,
|
`brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives,
|
||||||
- merges `master` back into `develop`, so the branch the work happens on carries the
|
- merges `master` back into `develop`, so the branch the work happens on carries the
|
||||||
@@ -44,9 +49,15 @@ conflict.
|
|||||||
|
|
||||||
`.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every
|
`.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every
|
||||||
day. It resolves the commit once, builds the same six targets as a numbered
|
day. It resolves the commit once, builds the same six targets as a numbered
|
||||||
release, verifies all twelve archives and checksum files as one set, then
|
release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`,
|
||||||
replaces the mutable `nightly` prerelease and tag. The published release is
|
verifies all sixteen files as one set, then replaces the mutable `nightly`
|
||||||
smoke-tested through both the curl and wget installer paths.
|
prerelease and tag. Every nightly binary reports `X.Y.Z+git<commit time, UTC>.<sha>`
|
||||||
|
instead of the crate version. The published release is smoke-tested through the
|
||||||
|
curl and wget installer paths, and by installing the `.deb` and the `.rpm`.
|
||||||
|
|
||||||
|
Never enable GitHub's Immutable Releases on this repository: nightly replaces
|
||||||
|
its assets and moves its tag, and a tag name burnt by that setting cannot be
|
||||||
|
reused.
|
||||||
|
|
||||||
Use **Actions → Nightly → Run workflow** to force a build. The workflow always
|
Use **Actions → Nightly → Run workflow** to force a build. The workflow always
|
||||||
checks out `develop`, regardless of the ref selected in the dispatch form. The
|
checks out `develop`, regardless of the ref selected in the dispatch form. The
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1789785513,
|
||||||
|
"narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
description = "Soulseek client for the terminal, with a scriptable CLI and daemon";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
inherit (nixpkgs) lib;
|
||||||
|
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = lib.genAttrs systems (system: {
|
||||||
|
default = nixpkgs.legacyPackages.${system}.callPackage (
|
||||||
|
{ rustPlatform, installShellFiles }:
|
||||||
|
rustPlatform.buildRustPackage {
|
||||||
|
pname = "soulseek-rs";
|
||||||
|
inherit version;
|
||||||
|
src = self;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
cargoBuildFlags = [
|
||||||
|
"--package"
|
||||||
|
"soulseek-rs"
|
||||||
|
];
|
||||||
|
doCheck = false;
|
||||||
|
nativeBuildInputs = [ installShellFiles ];
|
||||||
|
env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}";
|
||||||
|
postInstall = ''
|
||||||
|
installShellCompletion --cmd soulseek-rs \
|
||||||
|
--bash <($out/bin/soulseek-rs completions print bash) \
|
||||||
|
--zsh <($out/bin/soulseek-rs completions print zsh) \
|
||||||
|
--fish <($out/bin/soulseek-rs completions print fish)
|
||||||
|
$out/bin/soulseek-rs man > soulseek-rs.1
|
||||||
|
installManPage soulseek-rs.1
|
||||||
|
'';
|
||||||
|
meta = {
|
||||||
|
description = "Soulseek client for the terminal, with a scriptable CLI and daemon";
|
||||||
|
homepage = "https://re-invention.nl/soulseek-rs/";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "soulseek-rs";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) { };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: soulseek-rs
|
||||||
|
arch: ${PKG_ARCH}
|
||||||
|
platform: linux
|
||||||
|
version: ${PKG_VERSION}
|
||||||
|
version_schema: none
|
||||||
|
release: "1"
|
||||||
|
section: net
|
||||||
|
priority: optional
|
||||||
|
maintainer: Michel de Graaf <michel@re-invention.nl>
|
||||||
|
description: |
|
||||||
|
Soulseek client for the terminal
|
||||||
|
A TUI, a scriptable command line and a daemon for the Soulseek
|
||||||
|
peer-to-peer network.
|
||||||
|
vendor: Michel de Graaf
|
||||||
|
homepage: https://re-invention.nl/soulseek-rs/
|
||||||
|
license: MIT
|
||||||
|
suggests:
|
||||||
|
- gnome-keyring
|
||||||
|
contents:
|
||||||
|
- src: target/nfpm/soulseek-rs
|
||||||
|
dst: /usr/bin/soulseek-rs
|
||||||
|
file_info:
|
||||||
|
mode: 0755
|
||||||
|
- src: target/nfpm/soulseek-rs.1.gz
|
||||||
|
dst: /usr/share/man/man1/soulseek-rs.1.gz
|
||||||
|
- src: target/nfpm/soulseek-rs.bash
|
||||||
|
dst: /usr/share/bash-completion/completions/soulseek-rs
|
||||||
|
- src: target/nfpm/soulseek-rs.fish
|
||||||
|
dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish
|
||||||
|
- src: target/nfpm/_soulseek-rs
|
||||||
|
dst: /usr/share/zsh/vendor-completions/_soulseek-rs
|
||||||
|
packager: deb
|
||||||
|
- src: target/nfpm/_soulseek-rs
|
||||||
|
dst: /usr/share/zsh/site-functions/_soulseek-rs
|
||||||
|
packager: rpm
|
||||||
|
- dst: /usr/share/doc/soulseek-rs
|
||||||
|
type: dir
|
||||||
|
packager: rpm
|
||||||
|
- dst: /usr/share/licenses/soulseek-rs
|
||||||
|
type: dir
|
||||||
|
packager: rpm
|
||||||
|
- src: README.md
|
||||||
|
dst: /usr/share/doc/soulseek-rs/README.md
|
||||||
|
packager: deb
|
||||||
|
- src: README.md
|
||||||
|
dst: /usr/share/doc/soulseek-rs/README.md
|
||||||
|
packager: rpm
|
||||||
|
type: doc
|
||||||
|
- src: LICENSE
|
||||||
|
dst: /usr/share/doc/soulseek-rs/copyright
|
||||||
|
packager: deb
|
||||||
|
- src: LICENSE
|
||||||
|
dst: /usr/share/licenses/soulseek-rs/LICENSE
|
||||||
|
packager: rpm
|
||||||
|
type: license
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
[ $# -ge 4 ] || {
|
||||||
|
echo "usage: $0 <binary> <version> <target-triple> <out-dir> [<package-basename>]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
NFPM_VERSION=2.47.0
|
||||||
|
|
||||||
|
binary=$1
|
||||||
|
PKG_VERSION=$2
|
||||||
|
target=$3
|
||||||
|
out=$4
|
||||||
|
package=${5:-soulseek-rs}
|
||||||
|
|
||||||
|
case "$target" in
|
||||||
|
x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;;
|
||||||
|
aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;;
|
||||||
|
*) echo "no package architecture for $target" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$(uname -s)-$(uname -m)" in
|
||||||
|
Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;;
|
||||||
|
Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;;
|
||||||
|
*) echo "no pinned nfpm build for this machine" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
stage=$root/target/nfpm
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$stage" "$out"
|
||||||
|
out=$(cd "$out" && pwd)
|
||||||
|
|
||||||
|
cp "$binary" "$stage/soulseek-rs"
|
||||||
|
"$binary" completions print bash > "$stage/soulseek-rs.bash"
|
||||||
|
"$binary" completions print zsh > "$stage/_soulseek-rs"
|
||||||
|
"$binary" completions print fish > "$stage/soulseek-rs.fish"
|
||||||
|
"$binary" man > "$stage/soulseek-rs.1"
|
||||||
|
gzip -9n "$stage/soulseek-rs.1"
|
||||||
|
|
||||||
|
archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz
|
||||||
|
curl -fsSL -o "$work/$archive" \
|
||||||
|
"https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive"
|
||||||
|
echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null
|
||||||
|
tar -xzf "$work/$archive" -C "$work" nfpm
|
||||||
|
|
||||||
|
export PKG_ARCH PKG_VERSION
|
||||||
|
cd "$root"
|
||||||
|
"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb"
|
||||||
|
"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm"
|
||||||
@@ -3,6 +3,7 @@ name = "soulseek-rs-lib"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::actor::{Actor, ActorHandle, ConnectionState};
|
use crate::actor::{Actor, ActorHandle, ConnectionState};
|
||||||
use crate::client::ClientOperation;
|
use crate::client::{CancelHandle, ClientOperation};
|
||||||
use crate::dispatcher::MessageDispatcher;
|
use crate::dispatcher::MessageDispatcher;
|
||||||
use crate::message::server::AdminMessageHandler;
|
use crate::message::server::AdminMessageHandler;
|
||||||
use crate::message::server::CantConnectToPeerHandler;
|
use crate::message::server::CantConnectToPeerHandler;
|
||||||
@@ -288,6 +288,7 @@ pub struct ServerActor {
|
|||||||
shared_folder_count: u32,
|
shared_folder_count: u32,
|
||||||
shared_file_count: u32,
|
shared_file_count: u32,
|
||||||
session: SessionWatch,
|
session: SessionWatch,
|
||||||
|
cancel: CancelHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The messages a client sends right after a successful login: its shared-file
|
/// The messages a client sends right after a successful login: its shared-file
|
||||||
@@ -340,6 +341,7 @@ impl ServerActor {
|
|||||||
shared_folder_count,
|
shared_folder_count,
|
||||||
shared_file_count,
|
shared_file_count,
|
||||||
session: SessionWatch::default(),
|
session: SessionWatch::default(),
|
||||||
|
cancel: CancelHandle::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +350,10 @@ impl ServerActor {
|
|||||||
self.session = session;
|
self.session = session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_cancel(&mut self, cancel: CancelHandle) {
|
||||||
|
self.cancel = cancel;
|
||||||
|
}
|
||||||
|
|
||||||
fn initiate_connection(&mut self) -> bool {
|
fn initiate_connection(&mut self) -> bool {
|
||||||
let stream = (self.address.get_host(), self.address.get_port())
|
let stream = (self.address.get_host(), self.address.get_port())
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
@@ -563,6 +569,10 @@ impl ServerActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, message: Message) {
|
fn send_message(&mut self, message: Message) {
|
||||||
|
let cancel = self.cancel.clone();
|
||||||
|
let Some(_live) = cancel.live() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(stream) = self.stream.as_mut() else {
|
let Some(stream) = self.stream.as_mut() else {
|
||||||
error!("[server] Cannot send message: stream is None");
|
error!("[server] Cannot send message: stream is None");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -188,6 +188,44 @@ fn a_login_with_nobody_listening_is_refused_at_once() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_actor_never_writes_the_login_it_queued() {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let client = TcpStream::connect(addr).unwrap();
|
||||||
|
let (mut server_side, _) = listener.accept().unwrap();
|
||||||
|
client.set_nonblocking(true).unwrap();
|
||||||
|
|
||||||
|
let cancel = CancelHandle::new();
|
||||||
|
let mut actor = parked_actor(addr.port());
|
||||||
|
actor.set_cancel(cancel.clone());
|
||||||
|
actor.stream = Some(client);
|
||||||
|
actor.connection_state = ConnectionState::Connecting {
|
||||||
|
since: Instant::now(),
|
||||||
|
};
|
||||||
|
let (response, _verdict) = std::sync::mpsc::channel();
|
||||||
|
actor.handle_login(
|
||||||
|
"u".into(),
|
||||||
|
"p".into(),
|
||||||
|
ClientVersion::default(),
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
assert_eq!(actor.queued_messages.len(), 1);
|
||||||
|
|
||||||
|
cancel.cancel();
|
||||||
|
actor.tick();
|
||||||
|
assert!(matches!(actor.connection_state, ConnectionState::Connected));
|
||||||
|
actor.on_stop();
|
||||||
|
|
||||||
|
server_side
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
let mut written = Vec::new();
|
||||||
|
assert_eq!(server_side.read_to_end(&mut written).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_successful_login_marks_the_session_live_again() {
|
fn a_successful_login_marks_the_session_live_again() {
|
||||||
let mut actor = parked_actor(1);
|
let mut actor = parked_actor(1);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CancelHandle(Arc<Mutex<bool>>);
|
||||||
|
|
||||||
|
impl CancelHandle {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self(Arc::new(Mutex::new(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
*self.0.lock().unwrap_or_else(PoisonError::into_inner) = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_cancelled(&self) -> bool {
|
||||||
|
*self.0.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn live(&self) -> Option<MutexGuard<'_, bool>> {
|
||||||
|
let guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
(!*guard).then_some(guard)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ use super::{
|
|||||||
TcpStream, error, info, mpsc, thread, trace, warn,
|
TcpStream, error, info, mpsc, thread, trace, warn,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::mpsc::RecvTimeoutError;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const LISTENER_RELEASE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
const LOGIN_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
/// Ceiling on the wait for a login verdict. Generous enough for a slow server
|
/// Ceiling on the wait for a login verdict. Generous enough for a slow server
|
||||||
/// and a retrying connect, short enough that an unattended caller ends.
|
/// and a retrying connect, short enough that an unattended caller ends.
|
||||||
@@ -87,6 +92,10 @@ impl Client {
|
|||||||
ctx.shares = shares;
|
ctx.shares = shares;
|
||||||
ctx.shared_directories.clone_from(&self.shared_directories);
|
ctx.shared_directories.clone_from(&self.shared_directories);
|
||||||
|
|
||||||
|
if self.cancel.is_cancelled() {
|
||||||
|
return Err(SoulseekRs::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
// Bind before logging in: the port we advertise has to be the port we
|
// Bind before logging in: the port we advertise has to be the port we
|
||||||
// hold, and a bind that fails outright is the caller's to see rather
|
// hold, and a bind that fails outright is the caller's to see rather
|
||||||
// than a panic on a thread nobody is watching.
|
// than a panic on a thread nobody is watching.
|
||||||
@@ -108,6 +117,7 @@ impl Client {
|
|||||||
shared_file_count,
|
shared_file_count,
|
||||||
);
|
);
|
||||||
server_actor.set_session_watch(self.session.clone());
|
server_actor.set_session_watch(self.session.clone());
|
||||||
|
server_actor.set_cancel(self.cancel.clone());
|
||||||
|
|
||||||
self.server_handle = Some(ctx.actor_system.spawn_with_handle(
|
self.server_handle = Some(ctx.actor_system.spawn_with_handle(
|
||||||
server_actor,
|
server_actor,
|
||||||
@@ -121,6 +131,8 @@ impl Client {
|
|||||||
let context = self.context.clone();
|
let context = self.context.clone();
|
||||||
let own_username = self.username.clone();
|
let own_username = self.username.clone();
|
||||||
let stopped = self.listener_stopped.clone();
|
let stopped = self.listener_stopped.clone();
|
||||||
|
let released = Arc::new(AtomicBool::new(false));
|
||||||
|
self.listener_released = released.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
Listen::serve(
|
Listen::serve(
|
||||||
@@ -130,6 +142,8 @@ impl Client {
|
|||||||
own_username,
|
own_username,
|
||||||
stopped,
|
stopped,
|
||||||
);
|
);
|
||||||
|
drop(listener);
|
||||||
|
released.store(true, Ordering::Relaxed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +165,8 @@ impl Client {
|
|||||||
/// collide with our own ghost.
|
/// collide with our own ghost.
|
||||||
///
|
///
|
||||||
/// Called automatically when the client is dropped. The peer listener's
|
/// Called automatically when the client is dropped. The peer listener's
|
||||||
/// bound port, if one was opened, is released with the process.
|
/// bound port, if one was opened, is free again when this returns, unless
|
||||||
|
/// the listener cannot be woken within two seconds.
|
||||||
pub fn disconnect(&mut self) {
|
pub fn disconnect(&mut self) {
|
||||||
if let Some(handle) = self.server_handle.take() {
|
if let Some(handle) = self.server_handle.take() {
|
||||||
let _ = handle.stop();
|
let _ = handle.stop();
|
||||||
@@ -173,8 +188,15 @@ impl Client {
|
|||||||
// this the port stays bound for the life of the process, and the next
|
// this the port stays bound for the life of the process, and the next
|
||||||
// client falls back to an ephemeral port nobody was told about.
|
// client falls back to an ephemeral port nobody was told about.
|
||||||
self.listener_stopped.store(true, Ordering::Relaxed);
|
self.listener_stopped.store(true, Ordering::Relaxed);
|
||||||
if let Some(port) = self.bound_port.take() {
|
if let Some(port) = self.bound_port.take()
|
||||||
let _ = TcpStream::connect(("127.0.0.1", port));
|
&& TcpStream::connect(("127.0.0.1", port)).is_ok()
|
||||||
|
{
|
||||||
|
let deadline = Instant::now() + LISTENER_RELEASE_TIMEOUT;
|
||||||
|
while !self.listener_released.load(Ordering::Relaxed)
|
||||||
|
&& Instant::now() < deadline
|
||||||
|
{
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +209,9 @@ impl Client {
|
|||||||
/// control.
|
/// control.
|
||||||
pub fn login(&self) -> Result<bool> {
|
pub fn login(&self) -> Result<bool> {
|
||||||
info!("Logging in as {}", self.username);
|
info!("Logging in as {}", self.username);
|
||||||
if let Some(handle) = &self.server_handle {
|
if let Some(handle) = &self.server_handle
|
||||||
|
&& !self.cancel.is_cancelled()
|
||||||
|
{
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let _ = handle.send(ServerMessage::Login {
|
let _ = handle.send(ServerMessage::Login {
|
||||||
username: self.username.clone(),
|
username: self.username.clone(),
|
||||||
@@ -196,9 +220,19 @@ impl Client {
|
|||||||
response: tx,
|
response: tx,
|
||||||
});
|
});
|
||||||
|
|
||||||
match rx.recv_timeout(LOGIN_RESPONSE_TIMEOUT) {
|
let deadline = Instant::now() + LOGIN_RESPONSE_TIMEOUT;
|
||||||
Ok(result) => result,
|
loop {
|
||||||
Err(_) => Err(SoulseekRs::Timeout),
|
match rx.recv_timeout(LOGIN_POLL_INTERVAL) {
|
||||||
|
Ok(result) => return result,
|
||||||
|
Err(RecvTimeoutError::Timeout)
|
||||||
|
if self.cancel.is_cancelled() =>
|
||||||
|
{
|
||||||
|
return Err(SoulseekRs::NotConnected);
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Timeout)
|
||||||
|
if Instant::now() < deadline => {}
|
||||||
|
Err(_) => return Err(SoulseekRs::Timeout),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(SoulseekRs::NotConnected)
|
Err(SoulseekRs::NotConnected)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ impl ClientContext {
|
|||||||
private_messages: Vec::new(),
|
private_messages: Vec::new(),
|
||||||
pending_connect_tokens: HashMap::new(),
|
pending_connect_tokens: HashMap::new(),
|
||||||
max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)),
|
max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)),
|
||||||
|
download_speed_limit: Arc::default(),
|
||||||
shares: Arc::new(Shares::empty()),
|
shares: Arc::new(Shares::empty()),
|
||||||
shared_directories: Vec::new(),
|
shared_directories: Vec::new(),
|
||||||
peer_addresses: HashMap::new(),
|
peer_addresses: HashMap::new(),
|
||||||
@@ -100,10 +101,12 @@ impl ClientContext {
|
|||||||
// the whole board again on the next join.
|
// the whole board again on the next join.
|
||||||
self.room_tickers.remove(room);
|
self.room_tickers.remove(room);
|
||||||
}
|
}
|
||||||
|
// Only a roster the server has sent in full is kept current: the
|
||||||
|
// joiner hears of their own arrival before the member list.
|
||||||
RoomEvent::UserJoined { room, username } => {
|
RoomEvent::UserJoined { room, username } => {
|
||||||
let members =
|
if let Some(members) = self.room_members.get_mut(room)
|
||||||
self.room_members.entry(room.clone()).or_default();
|
&& let Err(at) = members.binary_search(username)
|
||||||
if let Err(at) = members.binary_search(username) {
|
{
|
||||||
members.insert(at, username.clone());
|
members.insert(at, username.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ impl Client {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_download_speed_limit(&self, bytes_per_second: u64) {
|
||||||
|
if let Ok(ctx) = self.context.read_safe() {
|
||||||
|
ctx.download_speed_limit.set(bytes_per_second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn pause_download(&self, username: &str, filename: &str) -> bool {
|
pub fn pause_download(&self, username: &str, filename: &str) -> bool {
|
||||||
match self.context.write_safe() {
|
match self.context.write_safe() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::types::{
|
|||||||
SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus,
|
SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus,
|
||||||
};
|
};
|
||||||
use crate::utils::logger;
|
use crate::utils::logger;
|
||||||
|
use crate::utils::rate_limit::RateLimit;
|
||||||
use crate::{
|
use crate::{
|
||||||
Transfer,
|
Transfer,
|
||||||
actor::{ActorSystem, peer_registry::PeerRegistry},
|
actor::{ActorSystem, peer_registry::PeerRegistry},
|
||||||
@@ -486,6 +487,7 @@ pub struct ClientContext {
|
|||||||
/// a token we sent in a ConnectToPeer to the peer we expect back.
|
/// a token we sent in a ConnectToPeer to the peer we expect back.
|
||||||
pending_connect_tokens: HashMap<u32, (String, Instant)>,
|
pending_connect_tokens: HashMap<u32, (String, Instant)>,
|
||||||
max_peers: Arc<AtomicUsize>,
|
max_peers: Arc<AtomicUsize>,
|
||||||
|
pub(crate) download_speed_limit: Arc<RateLimit>,
|
||||||
/// Files we share with peers (read-only after connect).
|
/// Files we share with peers (read-only after connect).
|
||||||
pub shares: Arc<Shares>,
|
pub shares: Arc<Shares>,
|
||||||
/// The directories the current share index was built from.
|
/// The directories the current share index was built from.
|
||||||
@@ -646,6 +648,8 @@ pub struct Client {
|
|||||||
/// Tells the peer listener to stop, so a disconnected client releases the
|
/// Tells the peer listener to stop, so a disconnected client releases the
|
||||||
/// port it bound.
|
/// port it bound.
|
||||||
listener_stopped: Arc<AtomicBool>,
|
listener_stopped: Arc<AtomicBool>,
|
||||||
|
listener_released: Arc<AtomicBool>,
|
||||||
|
cancel: CancelHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Client {
|
impl Drop for Client {
|
||||||
@@ -680,6 +684,8 @@ impl Client {
|
|||||||
server_handle: None,
|
server_handle: None,
|
||||||
session: SessionWatch::default(),
|
session: SessionWatch::default(),
|
||||||
listener_stopped: Arc::new(AtomicBool::new(false)),
|
listener_stopped: Arc::new(AtomicBool::new(false)),
|
||||||
|
listener_released: Arc::new(AtomicBool::new(false)),
|
||||||
|
cancel: CancelHandle::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +726,11 @@ impl Client {
|
|||||||
self.session.loss()
|
self.session.loss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn cancel_handle(&self) -> CancelHandle {
|
||||||
|
self.cancel.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// The directories whose files are currently shared with other peers.
|
/// The directories whose files are currently shared with other peers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn shared_directories(&self) -> Vec<String> {
|
pub fn shared_directories(&self) -> Vec<String> {
|
||||||
@@ -845,6 +856,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod cancel;
|
||||||
mod children;
|
mod children;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod context;
|
mod context;
|
||||||
@@ -857,5 +869,7 @@ mod social;
|
|||||||
mod upload_queue;
|
mod upload_queue;
|
||||||
mod uploads;
|
mod uploads;
|
||||||
|
|
||||||
|
pub use cancel::CancelHandle;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -150,8 +150,11 @@ impl Client {
|
|||||||
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
|
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
|
||||||
pub fn watch_user(&self, username: &str) -> Result<()> {
|
pub fn watch_user(&self, username: &str) -> Result<()> {
|
||||||
use crate::message::server::MessageFactory;
|
use crate::message::server::MessageFactory;
|
||||||
|
// Held across the send: the reply is applied under this lock, so it
|
||||||
|
// cannot land first and have its "does not exist" undone by the add.
|
||||||
|
let mut context = self.context.write_safe()?;
|
||||||
self.send_server_message(MessageFactory::build_watch_user(username))?;
|
self.send_server_message(MessageFactory::build_watch_user(username))?;
|
||||||
self.context.write_safe()?.add_watched_user(username);
|
context.add_watched_user(username);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
|
|||||||
assert!(context.room_members("lobby").is_empty());
|
assert!(context.room_members("lobby").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_join_announced_before_the_member_list_does_not_start_a_roster() {
|
||||||
|
// soulfind tells a room about a joiner, the joiner included, before it
|
||||||
|
// sends them the member list. A roster started from that echo reads as a
|
||||||
|
// room holding nobody else.
|
||||||
|
let mut context = ClientContext::new();
|
||||||
|
context.apply_room_event(RoomEvent::UserJoined {
|
||||||
|
room: "lobby".to_string(),
|
||||||
|
username: "me".to_string(),
|
||||||
|
});
|
||||||
|
assert!(context.room_members("lobby").is_empty());
|
||||||
|
|
||||||
|
context.apply_room_event(RoomEvent::Joined {
|
||||||
|
room: "lobby".to_string(),
|
||||||
|
users: vec!["me".to_string(), "alice".to_string()],
|
||||||
|
});
|
||||||
|
assert_eq!(context.room_members("lobby"), ["alice", "me"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_user_joining_twice_is_listed_once() {
|
fn a_user_joining_twice_is_listed_once() {
|
||||||
let mut context = ClientContext::new();
|
let mut context = ClientContext::new();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub mod prelude {
|
|||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use actor::server_actor::{PeerAddress, UserMessage};
|
pub use actor::server_actor::{PeerAddress, UserMessage};
|
||||||
pub use client::{Client, ClientSettings};
|
pub use client::{CancelHandle, Client, ClientSettings};
|
||||||
pub use error::{Result, SoulseekRs};
|
pub use error::{Result, SoulseekRs};
|
||||||
pub use message::peer::{SharedDirectory, SharedFileEntry};
|
pub use message::peer::{SharedDirectory, SharedFileEntry};
|
||||||
pub use peer::{ConnectionType, ParseConnectionTypeError};
|
pub use peer::{ConnectionType, ParseConnectionTypeError};
|
||||||
|
|||||||
@@ -363,6 +363,11 @@ impl DownloadPeer {
|
|||||||
stream
|
stream
|
||||||
.set_read_timeout(Some(CANCEL_POLL))
|
.set_read_timeout(Some(CANCEL_POLL))
|
||||||
.map_err(DownloadError::ConnectionFailed)?;
|
.map_err(DownloadError::ConnectionFailed)?;
|
||||||
|
let speed_limit = client_context
|
||||||
|
.read()
|
||||||
|
.map_err(|_| DownloadError::LockPoisoned)?
|
||||||
|
.download_speed_limit
|
||||||
|
.clone();
|
||||||
let mut last_data = Instant::now();
|
let mut last_data = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -384,7 +389,10 @@ impl DownloadPeer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
match stream.read(&mut read_buffer) {
|
let allowance = speed_limit.take(READ_BUFFER_SIZE);
|
||||||
|
let read = stream.read(&mut read_buffer[..allowance]);
|
||||||
|
speed_limit.give_back(allowance - read.as_ref().map_or(0, |&n| n));
|
||||||
|
match read {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
trace!(
|
trace!(
|
||||||
"[download_peer:{}] connection closed by peer",
|
"[download_peer:{}] connection closed by peer",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub(crate) mod keepalive;
|
|||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod md5;
|
pub mod md5;
|
||||||
pub mod path;
|
pub mod path;
|
||||||
|
pub(crate) mod rate_limit;
|
||||||
pub(crate) mod semaphore;
|
pub(crate) mod semaphore;
|
||||||
pub mod zlib;
|
pub mod zlib;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const TICK: Duration = Duration::from_millis(100);
|
||||||
|
const TICKS_PER_SECOND: u64 = 1000 / TICK.as_millis() as u64;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RateLimit {
|
||||||
|
bytes_per_second: AtomicU64,
|
||||||
|
bucket: Mutex<Bucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Bucket {
|
||||||
|
tokens: u64,
|
||||||
|
refilled: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
pub fn set(&self, bytes_per_second: u64) {
|
||||||
|
self.bytes_per_second
|
||||||
|
.store(bytes_per_second, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take(&self, want: usize) -> usize {
|
||||||
|
loop {
|
||||||
|
let rate = self.bytes_per_second.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 {
|
||||||
|
return want;
|
||||||
|
}
|
||||||
|
let mut bucket =
|
||||||
|
self.bucket.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let now = Instant::now();
|
||||||
|
let since = bucket.refilled.map_or(TICK, |at| now - at);
|
||||||
|
if since >= TICK {
|
||||||
|
bucket.tokens = (rate / TICKS_PER_SECOND).max(1);
|
||||||
|
bucket.refilled = Some(now);
|
||||||
|
}
|
||||||
|
if bucket.tokens > 0 {
|
||||||
|
let grant = bucket.tokens.min(want as u64);
|
||||||
|
bucket.tokens -= grant;
|
||||||
|
return grant as usize;
|
||||||
|
}
|
||||||
|
drop(bucket);
|
||||||
|
std::thread::sleep(TICK.saturating_sub(since));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn give_back(&self, unused: usize) {
|
||||||
|
if unused == 0 || self.bytes_per_second.load(Ordering::Relaxed) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.bucket
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.tokens += unused as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_limit_grants_everything_at_once() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
let started = Instant::now();
|
||||||
|
assert_eq!(limit.take(1 << 20), 1 << 20);
|
||||||
|
assert!(started.elapsed() < TICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_limit_paces_what_is_taken() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(20_000);
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut taken = 0;
|
||||||
|
while taken < 6_000 {
|
||||||
|
let grant = limit.take(4096);
|
||||||
|
assert!((1..=4096).contains(&grant), "granted {grant}");
|
||||||
|
taken += grant;
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
assert!(
|
||||||
|
(2 * TICK..10 * TICK).contains(&elapsed),
|
||||||
|
"{taken} bytes at 20 kB/s took {elapsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_takers_share_one_budget() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(20_000);
|
||||||
|
let started = Instant::now();
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for _ in 0..3 {
|
||||||
|
s.spawn(|| {
|
||||||
|
let mut taken = 0;
|
||||||
|
while taken < 2_000 {
|
||||||
|
taken += limit.take(4096);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
started.elapsed() >= 2 * TICK,
|
||||||
|
"6 kB at 20 kB/s across three takers took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unused_allowance_goes_back_to_the_budget() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(20_000);
|
||||||
|
let started = Instant::now();
|
||||||
|
let grant = limit.take(4096);
|
||||||
|
limit.give_back(grant);
|
||||||
|
assert_eq!(limit.take(4096), grant);
|
||||||
|
assert!(started.elapsed() < TICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifting_a_limit_frees_a_take_that_is_waiting() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(100);
|
||||||
|
assert_eq!(limit.take(1000), 10);
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
let waiting = s.spawn(|| limit.take(1000));
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(waiting.join().unwrap(), 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
-79
@@ -76,10 +76,16 @@ impl TestServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a local soulfind on an ephemeral port with a throwaway database.
|
/// Spawn a local soulfind on an ephemeral port with a throwaway database.
|
||||||
|
///
|
||||||
|
/// soulfind exits if its port is taken between `free_port()` handing it
|
||||||
|
/// out and soulfind binding it, so a child that dies is retried on a
|
||||||
|
/// fresh port rather than read as a missing server.
|
||||||
fn spawn(gate: std::sync::MutexGuard<'static, ()>) -> Option<Self> {
|
fn spawn(gate: std::sync::MutexGuard<'static, ()>) -> Option<Self> {
|
||||||
let bin = soulfind_binary()?;
|
let bin = soulfind_binary()?;
|
||||||
|
for _ in 0..3 {
|
||||||
let port = free_port()?;
|
let port = free_port()?;
|
||||||
let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db"));
|
let db =
|
||||||
|
std::env::temp_dir().join(format!("soulfind-e2e-{port}.db"));
|
||||||
let _ = std::fs::remove_file(&db);
|
let _ = std::fs::remove_file(&db);
|
||||||
|
|
||||||
let mut child = Command::new(&bin)
|
let mut child = Command::new(&bin)
|
||||||
@@ -92,22 +98,29 @@ impl TestServer {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
if wait_until_listening("127.0.0.1", port, Duration::from_secs(5))
|
// A child that has exited is never taken as up: soulfind exits
|
||||||
.is_none()
|
// when its port is gone, and whoever took it may answer instead.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while Instant::now() < deadline
|
||||||
|
&& child.try_wait().ok().flatten().is_none()
|
||||||
{
|
{
|
||||||
// Server never came up (e.g. a toolchain/SQLite issue); skip.
|
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||||
let _ = child.kill();
|
return Some(Self {
|
||||||
let _ = child.wait();
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port,
|
port,
|
||||||
child: Some(child),
|
child: Some(child),
|
||||||
db: Some(db),
|
db: Some(db),
|
||||||
_gate: gate,
|
_gate: gate,
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
// Never came up (e.g. a toolchain/SQLite issue) or died.
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = std::fs::remove_file(&db);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings(&self, username: &str, password: &str) -> ClientSettings {
|
fn settings(&self, username: &str, password: &str) -> ClientSettings {
|
||||||
@@ -327,8 +340,15 @@ fn a_chat_room_message_is_delivered_between_users() {
|
|||||||
alice.join_room(room).expect("alice joins room");
|
alice.join_room(room).expect("alice joins room");
|
||||||
bob.join_room(room).expect("bob joins room");
|
bob.join_room(room).expect("bob joins room");
|
||||||
|
|
||||||
// Give both joins time to register on the server before speaking.
|
// Each is in once the server has sent them the member list; a line said
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
// before bob is in never reaches him.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while alice.room_members(room).is_empty()
|
||||||
|
|| bob.room_members(room).is_empty()
|
||||||
|
{
|
||||||
|
assert!(Instant::now() < deadline, "both should get into the room");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
let _ = alice.take_room_events();
|
let _ = alice.take_room_events();
|
||||||
let _ = bob.take_room_events();
|
let _ = bob.take_room_events();
|
||||||
|
|
||||||
@@ -526,7 +546,14 @@ fn open_control_and_await_queue(
|
|||||||
let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?;
|
let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?;
|
||||||
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||||
p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?;
|
p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?;
|
||||||
|
// The client answers a user-info request through the peer it registered
|
||||||
|
// for this connection, so the answer means a download queues on it
|
||||||
|
// rather than dialling a peer the server has never heard of.
|
||||||
|
p.write_all(
|
||||||
|
&soulseek_rs::message::peer::build_user_info_request().get_buffer(),
|
||||||
|
)?;
|
||||||
p.flush()?;
|
p.flush()?;
|
||||||
|
expect_code(&mut p, 16, Duration::from_secs(10))?;
|
||||||
let _ = cfg.ready.send(());
|
let _ = cfg.ready.send(());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -860,10 +887,6 @@ fn file_downloads_over_p_and_f(
|
|||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
|
|
||||||
// The listener registers an incoming peer under its plain username; give
|
|
||||||
// that registration a moment to complete before queuing the download.
|
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
.download(
|
.download(
|
||||||
filename.to_string(),
|
filename.to_string(),
|
||||||
@@ -913,6 +936,82 @@ fn a_download_completes_when_the_f_connection_beats_token_registration() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_download_speed_limit_paces_a_transfer_until_it_is_lifted() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
let listen_port = free_port().expect("free listen port");
|
||||||
|
let mut client = Client::with_settings(server.listening_settings(
|
||||||
|
"e2e_cappedpeer_dl",
|
||||||
|
"pw",
|
||||||
|
listen_port,
|
||||||
|
));
|
||||||
|
client.connect().expect("connect");
|
||||||
|
assert!(client.login().expect("login"));
|
||||||
|
client.set_download_speed_limit(64 * 1024);
|
||||||
|
|
||||||
|
let filename = "capped.mp3";
|
||||||
|
let content: Vec<u8> =
|
||||||
|
(0..1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||||
|
let download_dir = unique_download_dir();
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||||
|
let cfg = MockUpload {
|
||||||
|
listen_addr: format!("127.0.0.1:{listen_port}"),
|
||||||
|
peer_username: "e2e_cappedpeer".to_string(),
|
||||||
|
filename: filename.to_string(),
|
||||||
|
content: content.clone(),
|
||||||
|
token: 434_343,
|
||||||
|
ready: ready_tx,
|
||||||
|
token_delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
let uploader = std::thread::spawn(move || {
|
||||||
|
if let Err(e) = run_mock_uploader(&cfg) {
|
||||||
|
eprintln!("[mock uploader] {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("mock uploader P connection");
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let (_download, status_rx) = client
|
||||||
|
.download(
|
||||||
|
filename.to_string(),
|
||||||
|
"e2e_cappedpeer".to_string(),
|
||||||
|
content.len() as u64,
|
||||||
|
download_dir.display().to_string(),
|
||||||
|
)
|
||||||
|
.expect("start download");
|
||||||
|
let in_progress = || {
|
||||||
|
client
|
||||||
|
.get_all_downloads()
|
||||||
|
.iter()
|
||||||
|
.any(|d| matches!(d.status, DownloadStatus::InProgress { .. }))
|
||||||
|
};
|
||||||
|
assert!(wait_for(in_progress), "the download should start");
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
assert!(
|
||||||
|
in_progress(),
|
||||||
|
"1 MiB at 64 KiB/s must still be arriving a second in"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.set_download_speed_limit(0);
|
||||||
|
assert!(
|
||||||
|
wait_for_completion(&client, &status_rx, Duration::from_secs(20)),
|
||||||
|
"the download should reach Completed"
|
||||||
|
);
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
let _ = uploader.join();
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(8),
|
||||||
|
"capped for the whole file this takes 16s; lifted it took {elapsed:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(download_dir.join(filename)).expect("downloaded file"),
|
||||||
|
content
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&download_dir);
|
||||||
|
}
|
||||||
|
|
||||||
fn cancel_mid_transfer(peer_username: &str, stall_after: Option<usize>) {
|
fn cancel_mid_transfer(peer_username: &str, stall_after: Option<usize>) {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
|
|
||||||
@@ -943,7 +1042,6 @@ fn cancel_mid_transfer(peer_username: &str, stall_after: Option<usize>) {
|
|||||||
ready_rx
|
ready_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
.download(
|
.download(
|
||||||
@@ -1043,7 +1141,6 @@ fn a_cancelled_queued_download_declines_the_peers_offer() {
|
|||||||
ready_rx
|
ready_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let download_dir = unique_download_dir();
|
let download_dir = unique_download_dir();
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
@@ -1117,7 +1214,6 @@ fn refusal_fails_the_download_quickly(
|
|||||||
ready_rx
|
ready_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let download_dir = unique_download_dir();
|
let download_dir = unique_download_dir();
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
@@ -1217,7 +1313,6 @@ fn an_interrupted_download_resumes_from_its_partial_file() {
|
|||||||
ready_rx
|
ready_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
.download(
|
.download(
|
||||||
@@ -2936,9 +3031,9 @@ fn a_joined_room_reports_its_members_statistics() {
|
|||||||
assert!(sharer.login().expect("sharer login"));
|
assert!(sharer.login().expect("sharer login"));
|
||||||
sharer.join_room(room).expect("sharer joins room");
|
sharer.join_room(room).expect("sharer joins room");
|
||||||
|
|
||||||
// Let the server register the sharer's membership and share counts, so
|
// The sharer's membership and share counts have to land first, so the
|
||||||
// the observer's own join reply describes a room that already has them.
|
// observer's own join reply describes a room that already has them.
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
fence(&sharer, "e2e_stats_observer");
|
||||||
|
|
||||||
let mut observer =
|
let mut observer =
|
||||||
Client::with_settings(server.settings("e2e_stats_observer", "pw"));
|
Client::with_settings(server.settings("e2e_stats_observer", "pw"));
|
||||||
@@ -3019,9 +3114,9 @@ fn watching_a_user_returns_their_status_and_share_counts() {
|
|||||||
watcher.connect().expect("watcher connect");
|
watcher.connect().expect("watcher connect");
|
||||||
assert!(watcher.login().expect("watcher login"));
|
assert!(watcher.login().expect("watcher login"));
|
||||||
|
|
||||||
// Let the subject's share counts reach the server before watching, so
|
// The subject's share counts have to reach the server before watching,
|
||||||
// the reply describes a user it already knows the statistics for.
|
// so the reply describes a user it already knows the statistics for.
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
fence(&subject, "e2e_watch_watcher");
|
||||||
|
|
||||||
watcher
|
watcher
|
||||||
.watch_user("e2e_watch_subject")
|
.watch_user("e2e_watch_subject")
|
||||||
@@ -3352,7 +3447,6 @@ fn download_progress_reports_the_rate_actually_received() {
|
|||||||
ready_rx
|
ready_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("mock uploader P connection");
|
.expect("mock uploader P connection");
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
|
||||||
|
|
||||||
let (_download, status_rx) = client
|
let (_download, status_rx) = client
|
||||||
.download(
|
.download(
|
||||||
@@ -3796,6 +3890,18 @@ fn await_room_event<T>(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait until the server has handled everything `client` has sent, including
|
||||||
|
/// requests it never answers: soulfind takes a connection's messages in order,
|
||||||
|
/// so the answer to a question about `other` asked now comes after them.
|
||||||
|
fn fence(client: &Client, other: &str) {
|
||||||
|
client.request_user_info(other).expect("ask about a user");
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while client.user_info(other).is_none() {
|
||||||
|
assert!(Instant::now() < deadline, "the server should answer");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_ticker_set_in_a_room_reaches_the_other_members() {
|
fn a_ticker_set_in_a_room_reaches_the_other_members() {
|
||||||
use soulseek_rs::types::RoomEvent;
|
use soulseek_rs::types::RoomEvent;
|
||||||
@@ -3812,7 +3918,12 @@ fn a_ticker_set_in_a_room_reaches_the_other_members() {
|
|||||||
|
|
||||||
alice.join_room(room).expect("alice joins");
|
alice.join_room(room).expect("alice joins");
|
||||||
bob.join_room(room).expect("bob joins");
|
bob.join_room(room).expect("bob joins");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
// A ticker reaches only who is in the room when it is set.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while bob.room_members(room).is_empty() {
|
||||||
|
assert!(Instant::now() < deadline, "bob should get into the room");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
let _ = bob.take_room_events();
|
let _ = bob.take_room_events();
|
||||||
|
|
||||||
alice
|
alice
|
||||||
@@ -3856,11 +3967,15 @@ fn a_client_joining_later_receives_the_whole_ticker_board() {
|
|||||||
alice.connect().expect("alice connect");
|
alice.connect().expect("alice connect");
|
||||||
assert!(alice.login().expect("alice login"));
|
assert!(alice.login().expect("alice login"));
|
||||||
alice.join_room(room).expect("alice joins");
|
alice.join_room(room).expect("alice joins");
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
|
||||||
alice
|
alice
|
||||||
.set_room_ticker(room, "standing message")
|
.set_room_ticker(room, "standing message")
|
||||||
.expect("set ticker");
|
.expect("set ticker");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
// The room, alice included, is told once the board holds it.
|
||||||
|
await_room_event(&alice, Duration::from_secs(5), |event| {
|
||||||
|
matches!(event, RoomEvent::TickerAdded { room: r, .. } if r == room)
|
||||||
|
.then_some(())
|
||||||
|
})
|
||||||
|
.expect("alice should see her ticker go up");
|
||||||
|
|
||||||
// Carol joins afterwards and must be handed the board that already exists.
|
// Carol joins afterwards and must be handed the board that already exists.
|
||||||
let mut carol =
|
let mut carol =
|
||||||
@@ -3901,7 +4016,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() {
|
|||||||
|
|
||||||
watcher.join_global_room().expect("join global room");
|
watcher.join_global_room().expect("join global room");
|
||||||
talker.join_room(room).expect("talker joins a room");
|
talker.join_room(room).expect("talker joins a room");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
fence(&watcher, "e2e_global_talk");
|
||||||
let _ = watcher.take_room_events();
|
let _ = watcher.take_room_events();
|
||||||
|
|
||||||
let body = "spoken where nobody is watching";
|
let body = "spoken where nobody is watching";
|
||||||
@@ -3925,7 +4040,7 @@ fn the_global_room_feed_carries_a_room_we_never_joined() {
|
|||||||
|
|
||||||
// And leaving the feed stops it: the next message must not arrive.
|
// And leaving the feed stops it: the next message must not arrive.
|
||||||
watcher.leave_global_room().expect("leave global room");
|
watcher.leave_global_room().expect("leave global room");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
fence(&watcher, "e2e_global_talk");
|
||||||
let _ = watcher.take_room_events();
|
let _ = watcher.take_room_events();
|
||||||
talker
|
talker
|
||||||
.say_in_room(room, "after leaving")
|
.say_in_room(room, "after leaving")
|
||||||
@@ -3991,7 +4106,16 @@ fn a_room_search_is_answered_by_the_rooms_members() {
|
|||||||
);
|
);
|
||||||
sharer.join_room(room).expect("sharer joins");
|
sharer.join_room(room).expect("sharer joins");
|
||||||
searcher.join_room(room).expect("searcher joins");
|
searcher.join_room(room).expect("searcher joins");
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
// A room search goes to whoever is in the room when it is sent.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while !searcher
|
||||||
|
.room_members(room)
|
||||||
|
.iter()
|
||||||
|
.any(|member| member == "e2e_rs_sharer")
|
||||||
|
{
|
||||||
|
assert!(Instant::now() < deadline, "both should get into the room");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
let query = "relic";
|
let query = "relic";
|
||||||
searcher.search_room(room, query).expect("room search");
|
searcher.search_room(room, query).expect("room search");
|
||||||
@@ -4028,7 +4152,7 @@ fn shared_interests_make_two_users_similar() {
|
|||||||
alice
|
alice
|
||||||
.add_dislike("e2e_interest_muzak")
|
.add_dislike("e2e_interest_muzak")
|
||||||
.expect("alice dislikes something");
|
.expect("alice dislikes something");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
fence(&bob, "e2e_like_alice");
|
||||||
|
|
||||||
// Who else likes this item (code 112).
|
// Who else likes this item (code 112).
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
@@ -4122,7 +4246,7 @@ fn recommendations_are_returned_for_our_interests() {
|
|||||||
alice.add_interest(shared).expect("alice likes shared");
|
alice.add_interest(shared).expect("alice likes shared");
|
||||||
bob.add_interest(shared).expect("bob likes shared");
|
bob.add_interest(shared).expect("bob likes shared");
|
||||||
bob.add_interest(other).expect("bob likes other");
|
bob.add_interest(other).expect("bob likes other");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
fence(&bob, "e2e_rec_alice");
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(8);
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
let mut recommended = Vec::new();
|
let mut recommended = Vec::new();
|
||||||
@@ -4256,7 +4380,23 @@ fn a_changed_password_is_what_the_next_login_needs() {
|
|||||||
client
|
client
|
||||||
.change_password("second-pw")
|
.change_password("second-pw")
|
||||||
.expect("change password");
|
.expect("change password");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
// soulfind stores the new password once it has hashed it, and forgets the
|
||||||
|
// change if our session closes first. The session stays until a login
|
||||||
|
// with the new password shows it landed; a refused one leaves it alone.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
let mut fresh =
|
||||||
|
Client::with_settings(server.settings(user, "second-pw"));
|
||||||
|
fresh.connect().expect("connect with the new password");
|
||||||
|
if matches!(fresh.login(), Ok(true)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"the new password must be accepted"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
drop(client);
|
drop(client);
|
||||||
|
|
||||||
let mut stale = Client::with_settings(server.settings(user, "first-pw"));
|
let mut stale = Client::with_settings(server.settings(user, "first-pw"));
|
||||||
@@ -4265,14 +4405,6 @@ fn a_changed_password_is_what_the_next_login_needs() {
|
|||||||
!matches!(stale.login(), Ok(true)),
|
!matches!(stale.login(), Ok(true)),
|
||||||
"the old password must stop working"
|
"the old password must stop working"
|
||||||
);
|
);
|
||||||
drop(stale);
|
|
||||||
|
|
||||||
let mut fresh = Client::with_settings(server.settings(user, "second-pw"));
|
|
||||||
fresh.connect().expect("connect with the new password");
|
|
||||||
assert!(
|
|
||||||
fresh.login().expect("login with the new password"),
|
|
||||||
"the new password must be accepted"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4436,6 +4568,11 @@ fn phrases_the_server_excludes_are_kept_for_our_replies() {
|
|||||||
// network (code 160). Nicotine+ applies them to the files it offers in a
|
// network (code 160). Nicotine+ applies them to the files it offers in a
|
||||||
// search reply, not to the searches it sends, and so do we — this pins
|
// search reply, not to the searches it sends, and so do we — this pins
|
||||||
// that the announced list arrives and is kept for that use.
|
// that the announced list arrives and is kept for that use.
|
||||||
|
// It binds listeners, so it waits for the server tests: a port handed
|
||||||
|
// to a soulfind that is still starting is otherwise up for grabs.
|
||||||
|
let _gate = SERVER_GATE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let mut phrases = Message::new();
|
let mut phrases = Message::new();
|
||||||
phrases
|
phrases
|
||||||
.write_int32(160)
|
.write_int32(160)
|
||||||
@@ -4604,7 +4741,8 @@ fn a_private_room_is_owned_granted_and_revoked() {
|
|||||||
owner
|
owner
|
||||||
.join_private_room(room)
|
.join_private_room(room)
|
||||||
.expect("owner creates the room");
|
.expect("owner creates the room");
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
// An invite reaches only a user already accepting invitations.
|
||||||
|
fence(&guest, "e2e_priv_owner");
|
||||||
|
|
||||||
// The guest is invited, and hears about it (code 139).
|
// The guest is invited, and hears about it (code 139).
|
||||||
owner
|
owner
|
||||||
@@ -4723,7 +4861,7 @@ fn a_private_room_someone_else_owns_cannot_be_taken() {
|
|||||||
owner
|
owner
|
||||||
.join_private_room(room)
|
.join_private_room(room)
|
||||||
.expect("owner creates the room");
|
.expect("owner creates the room");
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
fence(&owner, "e2e_taken_outsider");
|
||||||
|
|
||||||
// An outsider asking for the same name must be refused (code 1003), not
|
// An outsider asking for the same name must be refused (code 1003), not
|
||||||
// handed the room.
|
// handed the room.
|
||||||
@@ -4776,7 +4914,7 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() {
|
|||||||
sender
|
sender
|
||||||
.send_private_message("e2e_ack_recipient", body)
|
.send_private_message("e2e_ack_recipient", body)
|
||||||
.expect("send to an offline user");
|
.expect("send to an offline user");
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
fence(&sender, "e2e_ack_recipient");
|
||||||
|
|
||||||
let received_once = |label: &str| -> bool {
|
let received_once = |label: &str| -> bool {
|
||||||
let mut client =
|
let mut client =
|
||||||
@@ -4794,7 +4932,9 @@ fn an_acknowledged_offline_message_is_not_delivered_twice() {
|
|||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
// The acknowledgement goes out ahead of the message it answers, so
|
||||||
|
// once the server has handled what followed, it has the ack too.
|
||||||
|
fence(&client, "e2e_ack_sender");
|
||||||
got
|
got
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5348,7 +5488,7 @@ fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() {
|
|||||||
owner
|
owner
|
||||||
.join_private_room(room)
|
.join_private_room(room)
|
||||||
.expect("owner creates the room");
|
.expect("owner creates the room");
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
fence(&guest, "e2e_leave_owner");
|
||||||
owner
|
owner
|
||||||
.add_room_member(room, "e2e_leave_guest")
|
.add_room_member(room, "e2e_leave_guest")
|
||||||
.expect("owner invites the guest");
|
.expect("owner invites the guest");
|
||||||
@@ -5454,14 +5594,15 @@ fn privileges_can_be_handed_to_another_user() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_disconnected_client_gives_its_listener_port_back() {
|
fn a_dropped_client_has_released_its_listener_port_when_drop_returns() {
|
||||||
// A client that goes out of scope must release the port it bound: the
|
let _gate = SERVER_GATE
|
||||||
// next one has to be able to advertise the port it was configured with,
|
.lock()
|
||||||
// not fall back to an ephemeral one nobody was told about. No server is
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
// needed — binding the listener happens in connect(), before the dial.
|
|
||||||
let port = free_port().expect("a port to hold");
|
let port = free_port().expect("a port to hold");
|
||||||
let settings = |port: u16| ClientSettings {
|
|
||||||
username: "e2e_port_release".to_string(),
|
for round in 0..25 {
|
||||||
|
let mut client = Client::with_settings(ClientSettings {
|
||||||
|
username: "e2e_port_release_now".to_string(),
|
||||||
password: "pw".to_string(),
|
password: "pw".to_string(),
|
||||||
server_address: PeerAddress::new("127.0.0.1".to_string(), 1),
|
server_address: PeerAddress::new("127.0.0.1".to_string(), 1),
|
||||||
enable_listen: true,
|
enable_listen: true,
|
||||||
@@ -5469,30 +5610,85 @@ fn a_disconnected_client_gives_its_listener_port_back() {
|
|||||||
shared_directories: Vec::new(),
|
shared_directories: Vec::new(),
|
||||||
accept_children: false,
|
accept_children: false,
|
||||||
version: ClientVersion::default(),
|
version: ClientVersion::default(),
|
||||||
};
|
});
|
||||||
|
client.connect().expect("bind the listener");
|
||||||
|
assert_eq!(client.listen_port(), Some(port), "round {round}");
|
||||||
|
drop(client);
|
||||||
|
|
||||||
let mut first = Client::with_settings(settings(port));
|
assert!(
|
||||||
first.connect().expect("bind the listener");
|
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(),
|
||||||
assert_eq!(first.listen_port(), Some(port));
|
"round {round}: the port is still held once drop has returned"
|
||||||
drop(first);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The listener thread notices the stop the moment it is woken, which the
|
#[test]
|
||||||
// disconnect does by dialling it; give it a beat to unwind.
|
fn a_cancelled_client_never_binds_its_port() {
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
let _gate = SERVER_GATE
|
||||||
let mut second_port = None;
|
.lock()
|
||||||
while Instant::now() < deadline && second_port != Some(port) {
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let mut second = Client::with_settings(settings(port));
|
let port = free_port().expect("a port to hold");
|
||||||
second.connect().expect("bind again");
|
let mut client = Client::with_settings(ClientSettings {
|
||||||
second_port = second.listen_port();
|
username: "e2e_cancelled_bind".to_string(),
|
||||||
if second_port == Some(port) {
|
password: "pw".to_string(),
|
||||||
break;
|
server_address: PeerAddress::new("127.0.0.1".to_string(), 1),
|
||||||
}
|
enable_listen: true,
|
||||||
drop(second);
|
listen_port: port,
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
shared_directories: Vec::new(),
|
||||||
}
|
accept_children: false,
|
||||||
|
version: ClientVersion::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
client.cancel_handle().cancel();
|
||||||
|
|
||||||
|
assert!(client.connect().is_err());
|
||||||
|
assert_eq!(client.listen_port(), None);
|
||||||
|
assert!(std::net::TcpListener::bind(("0.0.0.0", port)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancel_ends_a_login_waiting_for_its_verdict_and_nothing_follows_it() {
|
||||||
|
let server =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("a silent server");
|
||||||
|
let mut client = Client::with_settings(ClientSettings {
|
||||||
|
username: "e2e_cancelled_login".to_string(),
|
||||||
|
password: "pw".to_string(),
|
||||||
|
server_address: PeerAddress::new(
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
server.local_addr().expect("server address").port(),
|
||||||
|
),
|
||||||
|
enable_listen: false,
|
||||||
|
listen_port: 0,
|
||||||
|
shared_directories: Vec::new(),
|
||||||
|
accept_children: false,
|
||||||
|
version: ClientVersion::default(),
|
||||||
|
});
|
||||||
|
client.connect().expect("start the session");
|
||||||
|
let (mut conn, _) = server.accept().expect("the client dials the server");
|
||||||
|
conn.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.expect("read timeout");
|
||||||
|
let cancel = client.cancel_handle();
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let login = scope.spawn(|| client.login());
|
||||||
|
let frame =
|
||||||
|
read_framed(&mut conn).expect("the login reaches the server");
|
||||||
|
assert_eq!(frame.get_message_code(), 1);
|
||||||
|
|
||||||
|
let cancelled_at = Instant::now();
|
||||||
|
cancel.cancel();
|
||||||
|
assert!(login.join().expect("the login thread").is_err());
|
||||||
|
assert!(cancelled_at.elapsed() < Duration::from_secs(5));
|
||||||
|
});
|
||||||
|
client
|
||||||
|
.connect_peer("e2e_nobody")
|
||||||
|
.expect("the actor takes a request");
|
||||||
|
drop(client);
|
||||||
|
|
||||||
|
let mut rest = Vec::new();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
second_port,
|
conn.read_to_end(&mut rest)
|
||||||
Some(port),
|
.expect("the server sees the session end"),
|
||||||
"the port should be free again once the first client is dropped"
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,18 +379,21 @@ fn a_live_download_pauses_and_resumes_on_the_same_connection() {
|
|||||||
matches!(status, DownloadStatus::Paused { .. })
|
matches!(status, DownloadStatus::Paused { .. })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Give a read already in flight one timeout cycle to observe the pause.
|
// The pause is checked before every read, so once it lands only the read
|
||||||
std::thread::sleep(Duration::from_millis(1_200));
|
// already in flight can finish: one 64 KiB buffer at most. Queue more
|
||||||
|
// than that, which a transfer ignoring the pause would take all of.
|
||||||
let stable_len = fs::metadata(&part_path).expect("paused partial").len();
|
let stable_len = fs::metadata(&part_path).expect("paused partial").len();
|
||||||
let queued_while_paused = 8 * 1024;
|
let in_flight = 64 * 1024;
|
||||||
|
let queued_while_paused = in_flight + 8 * 1024;
|
||||||
peer.write_all(&content[first_chunk..first_chunk + queued_while_paused])
|
peer.write_all(&content[first_chunk..first_chunk + queued_while_paused])
|
||||||
.expect("queue bytes while paused");
|
.expect("queue bytes while paused");
|
||||||
peer.flush().expect("flush bytes while paused");
|
peer.flush().expect("flush bytes while paused");
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
assert_eq!(
|
let paused_len = fs::metadata(&part_path).expect("paused partial").len();
|
||||||
fs::metadata(&part_path).expect("paused partial").len(),
|
assert!(
|
||||||
stable_len,
|
paused_len <= stable_len + in_flight as u64,
|
||||||
"a paused transfer must stop consuming its socket"
|
"a paused transfer must stop consuming its socket: {paused_len} \
|
||||||
|
bytes after pausing at {stable_len}"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(subject.client.resume_download(&control.username, filename));
|
assert!(subject.client.resume_download(&control.username, filename));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "soulseek-rs"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
@@ -29,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" }
|
|||||||
|
|
||||||
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
|
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
|
||||||
clap_complete = "4.6.7"
|
clap_complete = "4.6.7"
|
||||||
|
clap_mangen = "0.3.3"
|
||||||
ratatui = "0.30.2"
|
ratatui = "0.30.2"
|
||||||
unicode-width = "0.2"
|
unicode-width = "0.2"
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`,
|
|||||||
completion script where bash, zsh, or fish looks for it, or take it back
|
completion script where bash, zsh, or fish looks for it, or take it back
|
||||||
again: `shell`, `path`, `action`. For a human at a prompt, not for you.
|
again: `shell`, `path`, `action`. For a human at a prompt, not for you.
|
||||||
|
|
||||||
|
**`completions print <shell>`** and **`man`** — the completion script or the
|
||||||
|
man page on stdout, raw rather than records. For whoever builds a package.
|
||||||
|
|
||||||
## Idioms
|
## Idioms
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+15
-1
@@ -34,11 +34,16 @@ Exit codes:
|
|||||||
0 success 2 usage/config 3 connect or login 4 no results
|
0 success 2 usage/config 3 connect or login 4 no results
|
||||||
5 timed out 6 transfer failed 7 session lost 1 unexpected error";
|
5 timed out 6 transfer failed 7 session lost 1 unexpected error";
|
||||||
|
|
||||||
|
pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") {
|
||||||
|
Some(version) => version,
|
||||||
|
None => env!("CARGO_PKG_VERSION"),
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug, Default)]
|
#[derive(Parser, Debug, Default)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "soulseek-rs",
|
name = "soulseek-rs",
|
||||||
author,
|
author,
|
||||||
version,
|
version = VERSION,
|
||||||
about = "Soulseek client in Rust 🦀",
|
about = "Soulseek client in Rust 🦀",
|
||||||
long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \
|
long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \
|
||||||
interactive TUI, or use a subcommand for a scriptable \
|
interactive TUI, or use a subcommand for a scriptable \
|
||||||
@@ -244,6 +249,9 @@ pub enum Commands {
|
|||||||
/// Tab completion for bash, zsh, and fish
|
/// Tab completion for bash, zsh, and fish
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Completions(CompletionsCommand),
|
Completions(CompletionsCommand),
|
||||||
|
|
||||||
|
/// Print the man page to stdout, for packaging
|
||||||
|
Man,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug, Default)]
|
#[derive(Args, Debug, Default)]
|
||||||
@@ -310,6 +318,12 @@ pub enum CompletionsCommand {
|
|||||||
|
|
||||||
/// Remove the completion script again
|
/// Remove the completion script again
|
||||||
Uninstall(CompletionsArgs),
|
Uninstall(CompletionsArgs),
|
||||||
|
|
||||||
|
/// Print the completion script to stdout, for packaging
|
||||||
|
Print {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: Shell,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
use super::{unwritable, xdg};
|
use super::{unwritable, xdg};
|
||||||
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
|
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
|
||||||
use crate::output::{CliError, CliResult, CompletionRecord, Out};
|
use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out};
|
||||||
use clap::CommandFactory;
|
use clap::CommandFactory;
|
||||||
use directories::UserDirs;
|
use directories::UserDirs;
|
||||||
use std::io::Write;
|
use std::io::{ErrorKind, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// The command being completed, and the file name the scripts are written as.
|
/// The command being completed, and the file name the scripts are written as.
|
||||||
@@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult {
|
|||||||
match command {
|
match command {
|
||||||
CompletionsCommand::Install(args) => install(out, &targets(args)?),
|
CompletionsCommand::Install(args) => install(out, &targets(args)?),
|
||||||
CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
|
CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
|
||||||
|
CompletionsCommand::Print { shell } => print(&script(*shell)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn man() -> CliResult {
|
||||||
|
let mut page = Vec::new();
|
||||||
|
clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust"))
|
||||||
|
.render(&mut page)
|
||||||
|
.expect("a Vec accepts every write");
|
||||||
|
print(&page)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print(bytes: &[u8]) -> CliResult {
|
||||||
|
match std::io::stdout().write_all(bytes) {
|
||||||
|
Err(error) if error.kind() != ErrorKind::BrokenPipe => {
|
||||||
|
Err(CliError::new(
|
||||||
|
Exit::Failure,
|
||||||
|
format!("cannot write stdout: {error}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult {
|
|||||||
| Commands::Config(_)
|
| Commands::Config(_)
|
||||||
| Commands::Shares(_)
|
| Commands::Shares(_)
|
||||||
| Commands::Skills(_)
|
| Commands::Skills(_)
|
||||||
| Commands::Completions(_) => {
|
| Commands::Completions(_)
|
||||||
|
| Commands::Man => {
|
||||||
Err(CliError::usage("handled without a session; see main::run"))
|
Err(CliError::usage("handled without a session; see main::run"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ impl Daemon {
|
|||||||
fn identify(&self) -> AuthResult {
|
fn identify(&self) -> AuthResult {
|
||||||
AuthResult {
|
AuthResult {
|
||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
daemon_version: crate::cli::VERSION.to_string(),
|
||||||
username: self.session.username(),
|
username: self.session.username(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,7 +532,7 @@ impl Daemon {
|
|||||||
DaemonStatus {
|
DaemonStatus {
|
||||||
username: self.session.username(),
|
username: self.session.username(),
|
||||||
server: self.server.clone(),
|
server: self.server.clone(),
|
||||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
daemon_version: crate::cli::VERSION.to_string(),
|
||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
listen_port: self.session.listen_port(),
|
listen_port: self.session.listen_port(),
|
||||||
shared_folders: folders,
|
shared_folders: folders,
|
||||||
|
|||||||
@@ -418,7 +418,18 @@ mod tests {
|
|||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut buf = [0u8; 64];
|
let mut buf = [0u8; 64];
|
||||||
// Answer many requests (initial map + cleanup unmap + any renews).
|
// Answer many requests (initial map + cleanup unmap + any renews).
|
||||||
while let Ok((n, from)) = socket.recv_from(&mut buf) {
|
loop {
|
||||||
|
let (n, from) = match socket.recv_from(&mut buf) {
|
||||||
|
Ok(received) => received,
|
||||||
|
// Windows reports a reply that met an already-closed
|
||||||
|
// client socket on the next receive; the gateway is fine.
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == std::io::ErrorKind::ConnectionReset =>
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
let request = buf[..n].to_vec();
|
let request = buf[..n].to_vec();
|
||||||
let internal = u16::from_be_bytes([request[4], request[5]]);
|
let internal = u16::from_be_bytes([request[4], request[5]]);
|
||||||
let lifetime = u32::from_be_bytes([
|
let lifetime = u32::from_be_bytes([
|
||||||
@@ -465,20 +476,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Dropping the mapper must remove the mapping (a lifetime-0 request).
|
// Dropping the mapper must remove the mapping (a lifetime-0 request).
|
||||||
|
// A map the gateway was slow to answer is sent again, and that resend
|
||||||
|
// can arrive first.
|
||||||
drop(mapper);
|
drop(mapper);
|
||||||
let unmap_req = requests
|
let unmapped = std::iter::from_fn(|| {
|
||||||
.recv_timeout(StdDuration::from_secs(3))
|
requests.recv_timeout(StdDuration::from_secs(3)).ok()
|
||||||
.expect("dropping the mapper should send an unmap request");
|
})
|
||||||
assert_eq!(
|
.any(|request| {
|
||||||
u32::from_be_bytes([
|
u32::from_be_bytes([
|
||||||
unmap_req[8],
|
request[8],
|
||||||
unmap_req[9],
|
request[9],
|
||||||
unmap_req[10],
|
request[10],
|
||||||
unmap_req[11]
|
request[11],
|
||||||
]),
|
]) == 0
|
||||||
0,
|
});
|
||||||
"cleanup should request lifetime 0"
|
assert!(unmapped, "cleanup should request lifetime 0");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A minimal in-process UPnP IGD: an HTTP server that answers every SOAP
|
/// A minimal in-process UPnP IGD: an HTTP server that answers every SOAP
|
||||||
|
|||||||
+17
-7
@@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match &cli.command {
|
||||||
|
Some(Commands::Completions(command)) => {
|
||||||
|
return crate::commands::completions::run(out, command);
|
||||||
|
}
|
||||||
|
Some(Commands::Man) => return crate::commands::completions::man(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
let config_path = config_path(&cli);
|
let config_path = config_path(&cli);
|
||||||
let file_config = match &config_path {
|
let file_config = match &config_path {
|
||||||
Some(path) => crate::persist::config::FileConfig::load(path)
|
Some(path) => crate::persist::config::FileConfig::load(path)
|
||||||
@@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
resolved.username.as_deref(),
|
resolved.username.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Commands::Completions(ref command) => {
|
|
||||||
return crate::commands::completions::run(out, command);
|
|
||||||
}
|
|
||||||
Commands::Config(ConfigCommand::Path) => {
|
Commands::Config(ConfigCommand::Path) => {
|
||||||
return crate::commands::settings::config_path(out, &store);
|
return crate::commands::settings::config_path(out, &store);
|
||||||
}
|
}
|
||||||
@@ -502,14 +507,17 @@ fn run_default_tui(
|
|||||||
// Best-effort: make ourselves reachable behind a home router so
|
// Best-effort: make ourselves reachable behind a home router so
|
||||||
// firewalled peers can connect back. Mapped only once the listener is
|
// firewalled peers can connect back. Mapped only once the listener is
|
||||||
// up, and for the port it really bound. Kept alive for the session.
|
// up, and for the port it really bound. Kept alive for the session.
|
||||||
let _port_mapper = outcome
|
let bound_port = outcome.client.listen_port();
|
||||||
.client
|
let _port_mapper =
|
||||||
.listen_port()
|
bound_port.map(crate::port_mapping::PortMapper::spawn);
|
||||||
.map(crate::port_mapping::PortMapper::spawn);
|
|
||||||
|
|
||||||
let store = crate::persist::paths::state_dir()
|
let store = crate::persist::paths::state_dir()
|
||||||
.map(crate::persist::state::StateStore::new);
|
.map(crate::persist::state::StateStore::new);
|
||||||
|
|
||||||
|
let listener_fallback = bound_port
|
||||||
|
.filter(|&bound| listen_port != 0 && bound != listen_port)
|
||||||
|
.map(|bound| (listen_port, bound));
|
||||||
|
|
||||||
let exit = launch_main_tui(
|
let exit = launch_main_tui(
|
||||||
terminal,
|
terminal,
|
||||||
Arc::new(outcome.client),
|
Arc::new(outcome.client),
|
||||||
@@ -517,6 +525,7 @@ fn run_default_tui(
|
|||||||
Duration::from_secs(resolved.search_timeout),
|
Duration::from_secs(resolved.search_timeout),
|
||||||
store,
|
store,
|
||||||
config_path.clone(),
|
config_path.clone(),
|
||||||
|
listener_fallback,
|
||||||
)
|
)
|
||||||
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))?;
|
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))?;
|
||||||
|
|
||||||
@@ -592,6 +601,7 @@ fn run_attached_tui(
|
|||||||
Duration::from_secs(resolved.search_timeout),
|
Duration::from_secs(resolved.search_timeout),
|
||||||
None,
|
None,
|
||||||
config_path,
|
config_path,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
|
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::cli::VERSION;
|
||||||
use crate::models::DownloadEntry;
|
use crate::models::DownloadEntry;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, accent_style, dimmed_style, error_style,
|
BYTES_PER_MB, accent_style, dimmed_style, error_style,
|
||||||
@@ -12,8 +13,7 @@ use ratatui::{
|
|||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use std::fmt::Write as _;
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
||||||
|
|
||||||
/// Bytes transferred, bytes expected and combined speed across the downloads
|
/// Bytes transferred, bytes expected and combined speed across the downloads
|
||||||
/// that are actually running — or `None` when none are, so the caller can hide
|
/// that are actually running — or `None` when none are, so the caller can hide
|
||||||
@@ -54,6 +54,7 @@ pub fn render_download_stats(
|
|||||||
downloads: &[DownloadEntry],
|
downloads: &[DownloadEntry],
|
||||||
active_count: usize,
|
active_count: usize,
|
||||||
daemon: Option<&str>,
|
daemon: Option<&str>,
|
||||||
|
listener_fallback: Option<(u16, u16)>,
|
||||||
) {
|
) {
|
||||||
let completed = downloads
|
let completed = downloads
|
||||||
.iter()
|
.iter()
|
||||||
@@ -79,10 +80,14 @@ pub fn render_download_stats(
|
|||||||
|
|
||||||
let active = active_progress(downloads);
|
let active = active_progress(downloads);
|
||||||
|
|
||||||
let title = match daemon {
|
let mut title = match daemon {
|
||||||
Some(endpoint) => format!("Status · daemon {endpoint}"),
|
Some(endpoint) => format!("Status · daemon {endpoint}"),
|
||||||
None => "Status".to_string(),
|
None => "Status".to_string(),
|
||||||
};
|
};
|
||||||
|
if let Some((configured, bound)) = listener_fallback {
|
||||||
|
let _ =
|
||||||
|
write!(title, " · port {configured} in use, listening on {bound}");
|
||||||
|
}
|
||||||
let block = pane_block(false).title(plain_title(&title, false));
|
let block = pane_block(false).title(plain_title(&title, false));
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
let inner_area = block.inner(area);
|
||||||
|
|||||||
+375
-89
@@ -6,21 +6,26 @@
|
|||||||
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
||||||
|
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask,
|
GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char,
|
||||||
pane_block, primary_style, title_style, warning_style,
|
info_style, mask, pane_block, primary_style, title_style, warning_style,
|
||||||
};
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll,
|
||||||
|
},
|
||||||
layout::{Constraint, Flex, Layout, Rect},
|
layout::{Constraint, Flex, Layout, Rect},
|
||||||
style::Modifier,
|
style::Modifier,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Clear, Paragraph},
|
widgets::{Clear, Paragraph},
|
||||||
};
|
};
|
||||||
use soulseek_rs::{Client, ClientSettings};
|
use soulseek_rs::{CancelHandle, Client, ClientSettings, SoulseekRs};
|
||||||
use std::sync::mpsc::{Receiver, channel};
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
use std::time::Duration;
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const RESTART_FLOOR: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LoginField {
|
pub enum LoginField {
|
||||||
@@ -36,15 +41,17 @@ pub enum LoginPhase {
|
|||||||
Connecting,
|
Connecting,
|
||||||
/// The last attempt failed; the message is shown until the next key.
|
/// The last attempt failed; the message is shown until the next key.
|
||||||
Failed(String),
|
Failed(String),
|
||||||
|
Rejected(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the caller should do after feeding a key to the form.
|
/// What the caller should do after feeding a key to the form.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LoginAction {
|
pub enum LoginAction {
|
||||||
None,
|
None,
|
||||||
/// Both fields are filled and the user pressed Enter.
|
/// Both fields are filled and the user pressed Enter or r.
|
||||||
Submit,
|
Submit,
|
||||||
/// User pressed Esc — abort the whole program.
|
Stop,
|
||||||
|
/// User pressed Esc, q or Ctrl-C — abort the whole program.
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +79,33 @@ impl LoginForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
|
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
|
||||||
|
if key.code == KeyCode::Char('c')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
{
|
||||||
|
return LoginAction::Cancel;
|
||||||
|
}
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
LoginPhase::Connecting => return LoginAction::None,
|
LoginPhase::Connecting => {
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit,
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.phase = LoginPhase::Editing;
|
||||||
|
LoginAction::Stop
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') => LoginAction::Cancel,
|
||||||
|
_ => LoginAction::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
LoginPhase::Failed(_) => {
|
LoginPhase::Failed(_) => {
|
||||||
|
self.phase = LoginPhase::Editing;
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('r') => self.submit(),
|
||||||
|
KeyCode::Esc => LoginAction::Cancel,
|
||||||
|
_ => LoginAction::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
LoginPhase::Rejected(_) => {
|
||||||
// Any key acknowledges the error; the password was wrong (or
|
// Any key acknowledges the error; the password was wrong (or
|
||||||
// the name is taken), so make the user retype it.
|
// the name is taken), so make the user retype it.
|
||||||
self.phase = LoginPhase::Editing;
|
self.phase = LoginPhase::Editing;
|
||||||
@@ -97,20 +128,7 @@ impl LoginForm {
|
|||||||
};
|
};
|
||||||
LoginAction::None
|
LoginAction::None
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => self.submit(),
|
||||||
if !self.username.is_empty() && !self.password.is_empty() {
|
|
||||||
self.phase = LoginPhase::Connecting;
|
|
||||||
LoginAction::Submit
|
|
||||||
} else {
|
|
||||||
// Move to the first empty field instead of submitting.
|
|
||||||
self.focused = if self.username.is_empty() {
|
|
||||||
LoginField::Username
|
|
||||||
} else {
|
|
||||||
LoginField::Password
|
|
||||||
};
|
|
||||||
LoginAction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
self.focused_field_mut().pop();
|
self.focused_field_mut().pop();
|
||||||
LoginAction::None
|
LoginAction::None
|
||||||
@@ -123,6 +141,20 @@ impl LoginForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn submit(&mut self) -> LoginAction {
|
||||||
|
if !self.username.is_empty() && !self.password.is_empty() {
|
||||||
|
self.phase = LoginPhase::Connecting;
|
||||||
|
return LoginAction::Submit;
|
||||||
|
}
|
||||||
|
// Move to the first empty field instead of submitting.
|
||||||
|
self.focused = if self.username.is_empty() {
|
||||||
|
LoginField::Username
|
||||||
|
} else {
|
||||||
|
LoginField::Password
|
||||||
|
};
|
||||||
|
LoginAction::None
|
||||||
|
}
|
||||||
|
|
||||||
const fn focused_field_mut(&mut self) -> &mut String {
|
const fn focused_field_mut(&mut self) -> &mut String {
|
||||||
match self.focused {
|
match self.focused {
|
||||||
LoginField::Username => &mut self.username,
|
LoginField::Username => &mut self.username,
|
||||||
@@ -142,7 +174,7 @@ pub struct LoginOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the login screen until a login succeeds (`Some`) or the user
|
/// Drive the login screen until a login succeeds (`Some`) or the user
|
||||||
/// cancels with Esc (`None`). When both credentials are already known an
|
/// quits (`None`). When both credentials are already known an
|
||||||
/// attempt starts immediately and the form is only shown on failure.
|
/// attempt starts immediately and the form is only shown on failure.
|
||||||
pub fn run_login_flow(
|
pub fn run_login_flow(
|
||||||
terminal: &mut DefaultTerminal,
|
terminal: &mut DefaultTerminal,
|
||||||
@@ -150,27 +182,44 @@ pub fn run_login_flow(
|
|||||||
initial_username: Option<String>,
|
initial_username: Option<String>,
|
||||||
initial_password: Option<String>,
|
initial_password: Option<String>,
|
||||||
) -> Result<Option<LoginOutcome>> {
|
) -> Result<Option<LoginOutcome>> {
|
||||||
let mut form = LoginForm::new(initial_username);
|
let start = |form: &LoginForm, previous: Option<Attempt<Client>>| {
|
||||||
let mut entered_via_form = false;
|
let mut client = Client::with_settings(make_settings(
|
||||||
let mut attempt: Option<Receiver<Result<Client, String>>> =
|
|
||||||
match initial_password {
|
|
||||||
Some(password) if !form.username.is_empty() => {
|
|
||||||
form.password = password;
|
|
||||||
form.phase = LoginPhase::Connecting;
|
|
||||||
Some(spawn_attempt(make_settings(
|
|
||||||
form.username.clone(),
|
form.username.clone(),
|
||||||
form.password.clone(),
|
form.password.clone(),
|
||||||
)))
|
));
|
||||||
|
let cancel = client.cancel_handle();
|
||||||
|
Attempt::spawn(previous, cancel, move || {
|
||||||
|
let outcome = client
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| {
|
||||||
|
LoginPhase::Failed(format!("Failed to connect: {e}"))
|
||||||
|
})
|
||||||
|
.and_then(|()| verdict(client.login()));
|
||||||
|
outcome.map(|()| client)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut form = LoginForm::new(initial_username.clone());
|
||||||
|
let mut attempt = match &initial_password {
|
||||||
|
Some(password) if !form.username.is_empty() => {
|
||||||
|
form.password.clone_from(password);
|
||||||
|
form.phase = LoginPhase::Connecting;
|
||||||
|
Some(start(&form, None))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let mut started = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| render(frame, &form))?;
|
if let Some(current) = &attempt {
|
||||||
|
let connecting = form.phase == LoginPhase::Connecting;
|
||||||
if let Some(rx) = &attempt {
|
match current.result.try_recv() {
|
||||||
match rx.try_recv() {
|
Ok(_) | Err(std::sync::mpsc::TryRecvError::Disconnected)
|
||||||
|
if !connecting => {}
|
||||||
Ok(Ok(client)) => {
|
Ok(Ok(client)) => {
|
||||||
|
let entered_via_form = initial_username.as_deref()
|
||||||
|
!= Some(form.username.as_str())
|
||||||
|
|| initial_password.as_deref()
|
||||||
|
!= Some(form.password.as_str());
|
||||||
return Ok(Some(LoginOutcome {
|
return Ok(Some(LoginOutcome {
|
||||||
client,
|
client,
|
||||||
username: form.username,
|
username: form.username,
|
||||||
@@ -178,31 +227,36 @@ pub fn run_login_flow(
|
|||||||
entered_via_form,
|
entered_via_form,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(Err(message)) => {
|
Ok(Err(failure)) => form.phase = failure,
|
||||||
form.phase = LoginPhase::Failed(message);
|
|
||||||
attempt = None;
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
form.phase =
|
form.phase =
|
||||||
LoginPhase::Failed("Connection thread died".into());
|
LoginPhase::Failed("Connection thread died".into());
|
||||||
attempt = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spinner =
|
||||||
|
get_spinner_char(started.elapsed().subsec_millis() as usize / 100);
|
||||||
|
terminal.draw(|frame| render(frame, &form, spinner))?;
|
||||||
|
|
||||||
if poll(Duration::from_millis(100))?
|
if poll(Duration::from_millis(100))?
|
||||||
&& let Event::Key(key) = event::read()?
|
&& let Event::Key(key) = event::read()?
|
||||||
&& key.kind == KeyEventKind::Press
|
&& key.kind == KeyEventKind::Press
|
||||||
{
|
{
|
||||||
|
let restarting = form.phase == LoginPhase::Connecting;
|
||||||
match form.handle_key(key) {
|
match form.handle_key(key) {
|
||||||
LoginAction::Cancel => return Ok(None),
|
LoginAction::Cancel => return Ok(None),
|
||||||
|
LoginAction::Submit
|
||||||
|
if restarting && started.elapsed() < RESTART_FLOOR => {}
|
||||||
LoginAction::Submit => {
|
LoginAction::Submit => {
|
||||||
entered_via_form = true;
|
attempt = Some(start(&form, attempt.take()));
|
||||||
attempt = Some(spawn_attempt(make_settings(
|
started = Instant::now();
|
||||||
form.username.clone(),
|
}
|
||||||
form.password.clone(),
|
LoginAction::Stop => {
|
||||||
)));
|
if let Some(current) = &attempt {
|
||||||
|
current.cancel.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LoginAction::None => {}
|
LoginAction::None => {}
|
||||||
}
|
}
|
||||||
@@ -210,28 +264,58 @@ pub fn run_login_flow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect and log in on a background thread so the UI stays responsive.
|
/// One login attempt, run on a background thread so the UI stays responsive.
|
||||||
fn spawn_attempt(settings: ClientSettings) -> Receiver<Result<Client, String>> {
|
struct Attempt<T> {
|
||||||
let (tx, rx) = channel();
|
cancel: CancelHandle,
|
||||||
std::thread::spawn(move || {
|
thread: JoinHandle<()>,
|
||||||
let mut client = Client::with_settings(settings);
|
result: Receiver<Result<T, LoginPhase>>,
|
||||||
let result = client
|
|
||||||
.connect()
|
|
||||||
.map_err(|e| format!("Failed to connect: {e}"))
|
|
||||||
.and_then(|()| match client.login() {
|
|
||||||
Ok(true) => Ok(()),
|
|
||||||
Ok(false) => Err(
|
|
||||||
"Login rejected: wrong password, or the username is taken"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
Err(e) => Err(format!("Login failed: {e}")),
|
|
||||||
});
|
|
||||||
let _ = tx.send(result.map(|()| client));
|
|
||||||
});
|
|
||||||
rx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(frame: &mut Frame, form: &LoginForm) {
|
impl<T: Send + 'static> Attempt<T> {
|
||||||
|
fn spawn(
|
||||||
|
previous: Option<Self>,
|
||||||
|
cancel: CancelHandle,
|
||||||
|
work: impl FnOnce() -> Result<T, LoginPhase> + Send + 'static,
|
||||||
|
) -> Self {
|
||||||
|
if let Some(previous) = &previous {
|
||||||
|
previous.cancel.cancel();
|
||||||
|
}
|
||||||
|
let (tx, result) = channel();
|
||||||
|
let own = cancel.clone();
|
||||||
|
let thread = std::thread::spawn(move || {
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
let _ = previous.thread.join();
|
||||||
|
}
|
||||||
|
if own.is_cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let outcome = work();
|
||||||
|
if !own.is_cancelled() {
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
cancel,
|
||||||
|
thread,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verdict(login: soulseek_rs::Result<bool>) -> Result<(), LoginPhase> {
|
||||||
|
match login {
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) | Err(SoulseekRs::AuthenticationFailed) => {
|
||||||
|
Err(LoginPhase::Rejected(
|
||||||
|
"Login rejected: wrong password, or the username is taken"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) {
|
||||||
let area = centered(frame.area(), 52, 12);
|
let area = centered(frame.area(), 52, 12);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
@@ -290,21 +374,32 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
let status = match &form.phase {
|
let status = match &form.phase {
|
||||||
LoginPhase::Editing => Paragraph::new(""),
|
LoginPhase::Editing => Paragraph::new(""),
|
||||||
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
||||||
"Connecting…",
|
format!("{spinner} Connecting…"),
|
||||||
warning_style(),
|
warning_style(),
|
||||||
))),
|
))),
|
||||||
LoginPhase::Failed(message) => Paragraph::new(Line::from(
|
LoginPhase::Failed(message) | LoginPhase::Rejected(message) => {
|
||||||
Span::styled(message.clone(), error_style()),
|
Paragraph::new(Line::from(Span::styled(
|
||||||
))
|
message.clone(),
|
||||||
.wrap(ratatui::widgets::Wrap { trim: true }),
|
error_style(),
|
||||||
|
)))
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||||
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(status, rows[3]);
|
frame.render_widget(status, rows[3]);
|
||||||
|
|
||||||
|
let keys = match form.phase {
|
||||||
|
LoginPhase::Connecting => {
|
||||||
|
"Enter/r: restart · Esc: stop · q/Ctrl-C: quit"
|
||||||
|
}
|
||||||
|
LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit",
|
||||||
|
LoginPhase::Editing | LoginPhase::Rejected(_) => {
|
||||||
|
"Tab: switch · Enter: log in · Esc: quit"
|
||||||
|
}
|
||||||
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(
|
Paragraph::new(format!(
|
||||||
"New usernames are registered automatically.\n\
|
"New usernames are registered automatically.\n{keys}"
|
||||||
Tab: switch · Enter: log in · Esc: quit",
|
))
|
||||||
)
|
|
||||||
.style(dimmed_style()),
|
.style(dimmed_style()),
|
||||||
rows[5],
|
rows[5],
|
||||||
);
|
);
|
||||||
@@ -323,7 +418,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use ratatui::crossterm::event::KeyModifiers;
|
|
||||||
|
|
||||||
fn key(code: KeyCode) -> KeyEvent {
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
KeyEvent::new(code, KeyModifiers::NONE)
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
@@ -335,6 +429,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connecting() -> LoginForm {
|
||||||
|
let mut form = LoginForm::new(Some("alice".into()));
|
||||||
|
type_str(&mut form, "pw");
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
|
||||||
|
form
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefilled_username_focuses_password() {
|
fn prefilled_username_focuses_password() {
|
||||||
let form = LoginForm::new(Some("alice".into()));
|
let form = LoginForm::new(Some("alice".into()));
|
||||||
@@ -402,11 +503,93 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_after_failure_returns_to_editing_and_clears_password() {
|
fn ctrl_c_quits_from_every_phase_without_typing() {
|
||||||
let mut form = LoginForm::new(Some("alice".into()));
|
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||||
type_str(&mut form, "wrong");
|
for phase in [
|
||||||
form.handle_key(key(KeyCode::Enter));
|
LoginPhase::Editing,
|
||||||
form.phase = LoginPhase::Failed("INVALIDPASS".into());
|
LoginPhase::Connecting,
|
||||||
|
LoginPhase::Failed("unreachable".into()),
|
||||||
|
LoginPhase::Rejected("INVALIDPASS".into()),
|
||||||
|
] {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
form.phase = phase.clone();
|
||||||
|
assert_eq!(
|
||||||
|
form.handle_key(ctrl_c),
|
||||||
|
LoginAction::Cancel,
|
||||||
|
"{phase:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(form.username, "", "{phase:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_is_ignored_while_connecting() {
|
||||||
|
let mut form = connecting();
|
||||||
|
let action = form.handle_key(key(KeyCode::Char('x')));
|
||||||
|
assert_eq!(action, LoginAction::None);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_while_connecting_stops_and_keeps_the_credentials() {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_or_r_while_connecting_restarts() {
|
||||||
|
for code in [KeyCode::Enter, KeyCode::Char('r')] {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q_while_connecting_quits() {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(
|
||||||
|
form.handle_key(key(KeyCode::Char('q'))),
|
||||||
|
LoginAction::Cancel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() {
|
||||||
|
for code in [KeyCode::Enter, KeyCode::Char('r')] {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_key_after_a_failed_connection_edits_with_the_password_kept() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_after_a_failed_connection_quits() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_after_rejection_returns_to_editing_and_clears_password() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Rejected("INVALIDPASS".into());
|
||||||
let action = form.handle_key(key(KeyCode::Char('x')));
|
let action = form.handle_key(key(KeyCode::Char('x')));
|
||||||
assert_eq!(action, LoginAction::None);
|
assert_eq!(action, LoginAction::None);
|
||||||
assert_eq!(form.phase, LoginPhase::Editing);
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
@@ -414,13 +597,116 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_ignored_while_connecting() {
|
fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() {
|
||||||
let mut form = LoginForm::new(Some("alice".into()));
|
assert_eq!(verdict(Ok(true)), Ok(()));
|
||||||
type_str(&mut form, "pw");
|
for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] {
|
||||||
form.handle_key(key(KeyCode::Enter));
|
assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_))));
|
||||||
assert_eq!(form.phase, LoginPhase::Connecting);
|
}
|
||||||
let action = form.handle_key(key(KeyCode::Char('x')));
|
for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)]
|
||||||
assert_eq!(action, LoginAction::None);
|
{
|
||||||
assert_eq!(form.password, "pw");
|
assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Held(&'static str, std::sync::mpsc::Sender<String>);
|
||||||
|
|
||||||
|
impl Drop for Held {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.1.send(format!("released {}", self.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_handle() -> CancelHandle {
|
||||||
|
Client::new("attempt", "pw").cancel_handle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(seen: &Receiver<String>) -> String {
|
||||||
|
seen.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("an attempt event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_restart_builds_nothing_until_the_attempt_it_replaces_has_let_go() {
|
||||||
|
let (events, seen) = channel();
|
||||||
|
let first_events = events.clone();
|
||||||
|
let first = Attempt::spawn(None, cancel_handle(), move || {
|
||||||
|
let _ = first_events.send("started first".to_string());
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
Ok(Held("first", first_events))
|
||||||
|
});
|
||||||
|
assert_eq!(next(&seen), "started first");
|
||||||
|
|
||||||
|
let _second = Attempt::spawn(Some(first), cancel_handle(), move || {
|
||||||
|
let _ = events.send("started second".to_string());
|
||||||
|
Err(LoginPhase::Failed("second".into()))
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(next(&seen), "released first");
|
||||||
|
assert_eq!(next(&seen), "started second");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_attempt_is_let_go_before_its_replacement_starts() {
|
||||||
|
let (events, seen) = channel();
|
||||||
|
let first_events = events.clone();
|
||||||
|
let first = Attempt::spawn(None, cancel_handle(), move || {
|
||||||
|
Ok(Held("first", first_events))
|
||||||
|
});
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while !first.thread.is_finished() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
assert!(first.thread.is_finished());
|
||||||
|
|
||||||
|
let _second = Attempt::spawn(Some(first), cancel_handle(), move || {
|
||||||
|
let _ = events.send("started second".to_string());
|
||||||
|
Err(LoginPhase::Failed("second".into()))
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(next(&seen), "released first");
|
||||||
|
assert_eq!(next(&seen), "started second");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_attempt_superseded_before_it_began_never_runs() {
|
||||||
|
let (events, seen) = channel();
|
||||||
|
let (release, gate) = channel::<()>();
|
||||||
|
let first_events = events.clone();
|
||||||
|
let first = Attempt::spawn(None, cancel_handle(), move || {
|
||||||
|
let _ = first_events.send("started first".to_string());
|
||||||
|
let _ = gate.recv();
|
||||||
|
Err::<Held, _>(LoginPhase::Failed("first".into()))
|
||||||
|
});
|
||||||
|
assert_eq!(next(&seen), "started first");
|
||||||
|
|
||||||
|
let second_events = events.clone();
|
||||||
|
let second = Attempt::spawn(Some(first), cancel_handle(), move || {
|
||||||
|
let _ = second_events.send("started second".to_string());
|
||||||
|
Err(LoginPhase::Failed("second".into()))
|
||||||
|
});
|
||||||
|
let _third = Attempt::spawn(Some(second), cancel_handle(), move || {
|
||||||
|
let _ = events.send("started third".to_string());
|
||||||
|
Err(LoginPhase::Failed("third".into()))
|
||||||
|
});
|
||||||
|
release.send(()).expect("release the first attempt");
|
||||||
|
|
||||||
|
assert_eq!(next(&seen), "started third");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_lands_after_a_stop_is_let_go_on_the_attempts_thread() {
|
||||||
|
let (events, seen) = channel();
|
||||||
|
let (release, gate) = channel::<()>();
|
||||||
|
let attempt = Attempt::spawn(None, cancel_handle(), move || {
|
||||||
|
let _ = events.send("started late".to_string());
|
||||||
|
let _ = gate.recv();
|
||||||
|
Ok(Held("late", events))
|
||||||
|
});
|
||||||
|
assert_eq!(next(&seen), "started late");
|
||||||
|
|
||||||
|
attempt.cancel.cancel();
|
||||||
|
release.send(()).expect("release the attempt");
|
||||||
|
|
||||||
|
assert_eq!(next(&seen), "released late");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub struct MainTui {
|
|||||||
saved_snapshot: Snapshot,
|
saved_snapshot: Snapshot,
|
||||||
/// Which divider the left mouse button is holding, if any.
|
/// Which divider the left mouse button is holding, if any.
|
||||||
resize_drag: Option<input::ResizeDrag>,
|
resize_drag: Option<input::ResizeDrag>,
|
||||||
|
listener_fallback: Option<(u16, u16)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
@@ -53,6 +54,7 @@ impl MainTui {
|
|||||||
config_path,
|
config_path,
|
||||||
saved_snapshot: Snapshot::default(),
|
saved_snapshot: Snapshot::default(),
|
||||||
resize_drag: None,
|
resize_drag: None,
|
||||||
|
listener_fallback: None,
|
||||||
};
|
};
|
||||||
tui.restore_persisted_state();
|
tui.restore_persisted_state();
|
||||||
tui
|
tui
|
||||||
@@ -257,9 +259,11 @@ pub fn launch_main_tui(
|
|||||||
search_timeout: Duration,
|
search_timeout: Duration,
|
||||||
store: Option<StateStore>,
|
store: Option<StateStore>,
|
||||||
config_path: Option<std::path::PathBuf>,
|
config_path: Option<std::path::PathBuf>,
|
||||||
|
listener_fallback: Option<(u16, u16)>,
|
||||||
) -> Result<TuiExit> {
|
) -> Result<TuiExit> {
|
||||||
let tui =
|
let mut tui =
|
||||||
MainTui::new(client, download_dir, search_timeout, store, config_path);
|
MainTui::new(client, download_dir, search_timeout, store, config_path);
|
||||||
|
tui.listener_fallback = listener_fallback;
|
||||||
tui.run(terminal)
|
tui.run(terminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ impl MainTui {
|
|||||||
&self.state.downloads,
|
&self.state.downloads,
|
||||||
self.state.active_downloads_count,
|
self.state.active_downloads_count,
|
||||||
daemon.as_deref(),
|
daemon.as_deref(),
|
||||||
|
self.listener_fallback,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.render_content(frame, main_chunks[1]);
|
self.render_content(frame, main_chunks[1]);
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ fn the_results_pane_has_the_whole_width_to_itself() {
|
|||||||
assert!(searches < downloads && downloads < info, "{row}");
|
assert!(searches < downloads && downloads < info, "{row}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listener_that_fell_back_says_so_in_the_status_bar() {
|
||||||
|
let mut tui = furnished_tui();
|
||||||
|
tui.listener_fallback = Some((2234, 50123));
|
||||||
|
|
||||||
|
let screen = screen_of(&mut tui);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
screen.contains("port 2234 in use, listening on 50123"),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_and_shift_tab_walk_the_panes_in_legend_order() {
|
fn tab_and_shift_tab_walk_the_panes_in_legend_order() {
|
||||||
let mut tui = furnished_tui();
|
let mut tui = furnished_tui();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::name_scroll::{column_width, end_offset, scroll_text};
|
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||||
|
use crate::cli::VERSION;
|
||||||
use crate::models::FileDisplayData;
|
use crate::models::FileDisplayData;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||||
@@ -13,8 +14,6 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
||||||
|
|
||||||
// The file name and its folder share whatever width the fixed columns leave,
|
// The file name and its folder share whatever width the fixed columns leave,
|
||||||
// the name getting the larger part: it is what a search is after, and a
|
// the name getting the larger part: it is what a search is after, and a
|
||||||
// folder's tail is a scroll away.
|
// folder's tail is a scroll away.
|
||||||
|
|||||||
@@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() {
|
|||||||
assert_eq!(action(&again), "absent");
|
assert_eq!(action(&again), "absent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_package_build_reads_completions_and_the_man_page_from_stdout() {
|
||||||
|
let dir = Scratch::new("package-build");
|
||||||
|
let config = dir.path().join("config.toml");
|
||||||
|
std::fs::write(&config, "not = [toml").expect("a config file");
|
||||||
|
let broken = config.display().to_string();
|
||||||
|
|
||||||
|
for (shell, marker) in [
|
||||||
|
("bash", "complete -F"),
|
||||||
|
("zsh", "#compdef soulseek-rs"),
|
||||||
|
("fish", "complete -c soulseek-rs"),
|
||||||
|
] {
|
||||||
|
let output = run(&["--config", &broken, "completions", "print", shell]);
|
||||||
|
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||||
|
assert!(stdout(&output).contains(marker), "{shell} script");
|
||||||
|
}
|
||||||
|
|
||||||
|
let man = run(&["--config", &broken, "man"]);
|
||||||
|
assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man));
|
||||||
|
assert!(stdout(&man).contains(".TH soulseek-rs 1"));
|
||||||
|
}
|
||||||
|
|
||||||
// --- wishlist, the half that only touches the config file ------------------
|
// --- wishlist, the half that only touches the config file ------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -884,21 +906,28 @@ fn split_session_flags(args: &[&str]) -> (Vec<String>, Vec<String>) {
|
|||||||
///
|
///
|
||||||
/// `--no-config` is dropped for the obvious reason, and `--quiet` with it:
|
/// `--no-config` is dropped for the obvious reason, and `--quiet` with it:
|
||||||
/// these commands report the schedule the *server* chose on stderr, which is
|
/// these commands report the schedule the *server* chose on stderr, which is
|
||||||
/// the only place a test can see it.
|
/// the only place a test can see it. Session flags go to the daemon, as in
|
||||||
|
/// [`cli`].
|
||||||
fn cli_with_config(
|
fn cli_with_config(
|
||||||
server: &TestServer,
|
server: &TestServer,
|
||||||
user: &str,
|
user: &str,
|
||||||
config: &Path,
|
config: &Path,
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
) -> Output {
|
) -> Output {
|
||||||
|
let (session_flags, command_args) = match mode() {
|
||||||
|
Mode::Local => {
|
||||||
|
(Vec::new(), args.iter().map(|a| (*a).to_string()).collect())
|
||||||
|
}
|
||||||
|
Mode::Daemon => split_session_flags(args),
|
||||||
|
};
|
||||||
let mut all: Vec<String> = server
|
let mut all: Vec<String> = server
|
||||||
.args(user)
|
.args_with(user, &session_flags)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|arg| arg != "--no-config" && arg != "--quiet")
|
.filter(|arg| arg != "--no-config" && arg != "--quiet")
|
||||||
.collect();
|
.collect();
|
||||||
all.push("--config".to_string());
|
all.push("--config".to_string());
|
||||||
all.push(config.to_str().expect("utf-8 path").to_string());
|
all.push(config.to_str().expect("utf-8 path").to_string());
|
||||||
all.extend(args.iter().map(|a| (*a).to_string()));
|
all.extend(command_args);
|
||||||
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
|
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||||
run(&refs)
|
run(&refs)
|
||||||
}
|
}
|
||||||
@@ -1888,8 +1917,19 @@ fn room_listen_streams_what_is_said_in_the_room() {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.expect("the binary should run");
|
.expect("the binary should run");
|
||||||
|
|
||||||
// Give the listener time to join before saying anything.
|
// The room announces the listener once the server has it in; a line
|
||||||
std::thread::sleep(Duration::from_secs(3));
|
// said before that never reaches it.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while !speaker.take_room_events().iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
soulseek_rs::types::RoomEvent::UserJoined { room, username }
|
||||||
|
if room == "cli_e2e_lobby_b" && username == "cli_e2e_room_reader"
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
assert!(Instant::now() < deadline, "the listener never joined");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
speaker
|
speaker
|
||||||
.say_in_room("cli_e2e_lobby_b", "streamed line")
|
.say_in_room("cli_e2e_lobby_b", "streamed line")
|
||||||
.expect("say");
|
.expect("say");
|
||||||
@@ -1958,6 +1998,11 @@ fn a_private_message_reaches_its_recipient() {
|
|||||||
fn message_read_streams_incoming_private_messages() {
|
fn message_read_streams_incoming_private_messages() {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
let sender = server.client("cli_e2e_pm_sender", Vec::new());
|
let sender = server.client("cli_e2e_pm_sender", Vec::new());
|
||||||
|
// soulfind drops a message to a name it has never seen but keeps one for
|
||||||
|
// a known user until they log in, so however long the reader takes to
|
||||||
|
// get online, the line below reaches it. Registered before the reader's
|
||||||
|
// own session exists: a later login as them would take its place.
|
||||||
|
drop(server.client("cli_e2e_pm_reader", Vec::new()));
|
||||||
settle();
|
settle();
|
||||||
|
|
||||||
let mut args = server.args("cli_e2e_pm_reader");
|
let mut args = server.args("cli_e2e_pm_reader");
|
||||||
@@ -2418,7 +2463,12 @@ fn room_users_lists_who_is_in_the_room() {
|
|||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
let resident = server.client("cli_e2e_resident", Vec::new());
|
let resident = server.client("cli_e2e_resident", Vec::new());
|
||||||
resident.join_room("cli_e2e_roster").expect("join");
|
resident.join_room("cli_e2e_roster").expect("join");
|
||||||
settle();
|
// The member list reaches the resident once the server has them in.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while resident.room_members("cli_e2e_roster").is_empty() {
|
||||||
|
assert!(Instant::now() < deadline, "the resident never got in");
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
let output = cli(
|
let output = cli(
|
||||||
&server,
|
&server,
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ pub struct Soulfind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Soulfind {
|
impl Soulfind {
|
||||||
|
/// soulfind exits if its port is taken between `free_port()` handing it
|
||||||
|
/// out and soulfind binding it, so a start that fails gets a fresh port.
|
||||||
pub fn start() -> Option<Self> {
|
pub fn start() -> Option<Self> {
|
||||||
|
(0..3).find_map(|_| {
|
||||||
let port = free_port()?;
|
let port = free_port()?;
|
||||||
let db = std::env::temp_dir().join(format!("soulfind-{port}.db"));
|
let db = std::env::temp_dir().join(format!("soulfind-{port}.db"));
|
||||||
let _ = std::fs::remove_file(&db);
|
let _ = std::fs::remove_file(&db);
|
||||||
let child = Self::spawn(port, &db)?;
|
let child = Self::spawn(port, &db)?;
|
||||||
Some(Self { child, db, port })
|
Some(Self { child, db, port })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn(port: u16, db: &Path) -> Option<Child> {
|
fn spawn(port: u16, db: &Path) -> Option<Child> {
|
||||||
@@ -78,8 +82,8 @@ impl Soulfind {
|
|||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
// Whoever answers on the port has to be this child: one that
|
// A child that has exited is never taken as up: soulfind exits
|
||||||
// lost the port and died would otherwise pass as up.
|
// when its port is gone, and whoever took it may answer instead.
|
||||||
if child.try_wait().ok().flatten().is_some() {
|
if child.try_wait().ok().flatten().is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh`
|
|||||||
|
|
||||||
export const NIGHTLY_INSTALL_CMD =
|
export const NIGHTLY_INSTALL_CMD =
|
||||||
'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly'
|
'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly'
|
||||||
|
|
||||||
|
export const STABLE_DOWNLOAD = `${LINKS.releases}/latest/download`
|
||||||
|
|
||||||
|
export const NIGHTLY_DOWNLOAD = `${LINKS.releases}/download/nightly`
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { ExtLink } from '@/components/ui/ext-link'
|
||||||
|
import { Code } from '@/components/ui/inline-code'
|
||||||
|
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
||||||
|
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
||||||
|
import { LINKS, NIGHTLY_DOWNLOAD, STABLE_DOWNLOAD } from '@/lib/links'
|
||||||
|
|
||||||
|
interface InstallRoute {
|
||||||
|
id: string
|
||||||
|
os: OsName
|
||||||
|
best: string
|
||||||
|
lines: readonly TermLine[]
|
||||||
|
alt: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// aislop-ignore-file code-quality/duplicate-block -- data tables: the INSTALLS and LINUX_ROUTES records repeat the field names their interfaces require. The rule matches punctuation, not logic.
|
||||||
|
const INSTALLS: readonly InstallRoute[] = [
|
||||||
|
{
|
||||||
|
id: 'macos',
|
||||||
|
os: 'macOS',
|
||||||
|
best: 'Homebrew',
|
||||||
|
lines: [
|
||||||
|
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs' },
|
||||||
|
],
|
||||||
|
alt: (
|
||||||
|
<>
|
||||||
|
A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a
|
||||||
|
toolchain, <Code>cargo install --locked soulseek-rs</Code> builds from crates.io instead.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux',
|
||||||
|
os: 'Linux',
|
||||||
|
best: 'your package manager',
|
||||||
|
lines: [
|
||||||
|
{ t: 'cm', text: '# Debian and Ubuntu; the others are below' },
|
||||||
|
{ t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` },
|
||||||
|
{ t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs' },
|
||||||
|
],
|
||||||
|
alt: (
|
||||||
|
<>
|
||||||
|
Fedora, Nix, Homebrew and cargo are{' '}
|
||||||
|
<a href="#linux-packages" className="text-link hover:text-link-hover">
|
||||||
|
listed below
|
||||||
|
</a>
|
||||||
|
, each with a nightly. Without any of them, the{' '}
|
||||||
|
<ExtLink href={LINKS.releases}>releases page</ExtLink> has static musl archives for x86-64
|
||||||
|
and arm64 that run on any distribution.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows',
|
||||||
|
os: 'Windows',
|
||||||
|
best: 'cargo or a prebuilt .exe',
|
||||||
|
lines: [
|
||||||
|
{ t: 'cmd', text: 'cargo install --locked soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs.exe' },
|
||||||
|
],
|
||||||
|
alt: (
|
||||||
|
<>
|
||||||
|
Cargo needs the MSVC build tools. To skip both, take the{' '}
|
||||||
|
<code>pc-windows-msvc</code> zip from the{' '}
|
||||||
|
<ExtLink href={LINKS.releases}>releases page</ExtLink>, unpack it, and put{' '}
|
||||||
|
<code>soulseek-rs.exe</code> on your PATH.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface LinuxRoute {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stable: readonly TermLine[]
|
||||||
|
nightly: readonly TermLine[]
|
||||||
|
note: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINUX_ROUTES: readonly LinuxRoute[] = [
|
||||||
|
{
|
||||||
|
id: 'deb',
|
||||||
|
name: 'Debian, Ubuntu, Mint',
|
||||||
|
stable: [
|
||||||
|
{ t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` },
|
||||||
|
{ t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' },
|
||||||
|
],
|
||||||
|
nightly: [
|
||||||
|
{ t: 'cmd', text: `curl -fsSLO ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-amd64.deb` },
|
||||||
|
{ t: 'cmd', text: 'sudo apt install ./soulseek-rs-nightly-amd64.deb' },
|
||||||
|
],
|
||||||
|
note: (
|
||||||
|
<>
|
||||||
|
On ARM, <code>arm64</code> takes the place of <code>amd64</code>. There is no apt
|
||||||
|
repository behind this, so an update means installing the newer file over the old one.
|
||||||
|
A nightly sorts above the release it follows, so going back to stable is a downgrade,
|
||||||
|
and apt asks before doing it.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpm',
|
||||||
|
name: 'Fedora, RHEL',
|
||||||
|
stable: [{ t: 'cmd', text: `sudo dnf install ${STABLE_DOWNLOAD}/soulseek-rs-x86_64.rpm` }],
|
||||||
|
nightly: [
|
||||||
|
{ t: 'cmd', text: `sudo dnf install ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-x86_64.rpm` },
|
||||||
|
],
|
||||||
|
note: (
|
||||||
|
<>
|
||||||
|
On ARM, <code>aarch64</code> takes the place of <code>x86_64</code>. Run the same
|
||||||
|
command again to update. dnf fetches the file each time, so a newer nightly at the same
|
||||||
|
address is picked up. An older file downgrades without asking.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nix',
|
||||||
|
name: 'Nix',
|
||||||
|
stable: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/master' }],
|
||||||
|
nightly: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/develop' }],
|
||||||
|
note: (
|
||||||
|
<>
|
||||||
|
Both build from source. With no branch at all, Nix takes the repository’s
|
||||||
|
default one, and here that is <code>develop</code>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brew',
|
||||||
|
name: 'Homebrew',
|
||||||
|
stable: [{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }],
|
||||||
|
nightly: [{ t: 'cmd', text: 'brew install --HEAD michel/tap/soulseek-rs' }],
|
||||||
|
note: (
|
||||||
|
<>
|
||||||
|
Homebrew installs under <code>/home/linuxbrew</code>, outside the image that a system
|
||||||
|
update replaces on Bazzite and Silverblue. <code>--HEAD</code> compiles{' '}
|
||||||
|
<code>develop</code> with Homebrew’s own Rust.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cargo',
|
||||||
|
name: 'cargo',
|
||||||
|
stable: [{ t: 'cmd', text: 'cargo install --locked soulseek-rs' }],
|
||||||
|
nightly: [
|
||||||
|
{
|
||||||
|
t: 'cmd',
|
||||||
|
text: 'cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: (
|
||||||
|
<>
|
||||||
|
Needs Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one, so take
|
||||||
|
the toolchain from <ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>.{' '}
|
||||||
|
<code>cargo binstall soulseek-rs</code> fetches the release binary and compiles nothing.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const InstallCards = () => (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
{INSTALLS.map((route) => (
|
||||||
|
<div
|
||||||
|
key={route.id}
|
||||||
|
id={route.id}
|
||||||
|
className="flex scroll-mt-20 flex-col gap-3.5 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<OsIcon name={route.os} />
|
||||||
|
<h3 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||||
|
{route.os}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||||
|
{route.best}
|
||||||
|
</span>
|
||||||
|
<Terminal lines={route.lines} wrap />
|
||||||
|
<p className="text-[12.5px] leading-5 text-secondary [&_code]:font-mono [&_code]:text-primary">
|
||||||
|
{route.alt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const LinuxRoutes = () => (
|
||||||
|
<div id="linux-packages" className="flex scroll-mt-20 flex-col gap-5">
|
||||||
|
<h3 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||||
|
Linux, by distribution
|
||||||
|
</h3>
|
||||||
|
<p className="text-secondary">
|
||||||
|
Stable is the latest release, or for Nix the <Code>master</Code> branch it was cut
|
||||||
|
from. Nightly is the tip of <Code>develop</Code>, rebuilt every day. A release download
|
||||||
|
prints the plain number, <Code>19.0.0</Code>. Any Nix build, and a nightly download,
|
||||||
|
reads <Code>19.0.0+git202609141144.deb846b</Code>: the last release, the
|
||||||
|
commit’s time in UTC, and the commit. A Homebrew <Code>--HEAD</Code> build
|
||||||
|
reports <Code>HEAD-deb846b</Code>, and a <Code>cargo install</Code> from git sets no
|
||||||
|
version, so it prints <Code>19.0.0</Code> too. The .deb and .rpm
|
||||||
|
carry the man page and completions for bash, zsh and fish.
|
||||||
|
</p>
|
||||||
|
{LINUX_ROUTES.map((route) => (
|
||||||
|
<div key={route.id} className="flex flex-col gap-2.5">
|
||||||
|
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||||
|
{route.name}
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
<Terminal label="stable" lines={route.stable} wrap />
|
||||||
|
<Terminal label="nightly" lines={route.nightly} wrap />
|
||||||
|
</div>
|
||||||
|
<p className="text-[12.5px] leading-5 text-secondary [&_code]:font-mono [&_code]:text-primary">
|
||||||
|
{route.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -3,10 +3,10 @@ import { FeatureCard } from '@/components/ui/feature-card'
|
|||||||
import { ExtLink } from '@/components/ui/ext-link'
|
import { ExtLink } from '@/components/ui/ext-link'
|
||||||
import { Code } from '@/components/ui/inline-code'
|
import { Code } from '@/components/ui/inline-code'
|
||||||
import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
||||||
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
|
||||||
import { Callout } from '@/components/ui/panel'
|
import { Callout } from '@/components/ui/panel'
|
||||||
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
||||||
import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links'
|
import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links'
|
||||||
|
import { InstallCards, LinuxRoutes } from '@/pages/install-routes'
|
||||||
|
|
||||||
const INSTALL_SCRIPT_LINES: readonly TermLine[] = [
|
const INSTALL_SCRIPT_LINES: readonly TermLine[] = [
|
||||||
{ t: 'cm', text: '# latest stable release' },
|
{ t: 'cm', text: '# latest stable release' },
|
||||||
@@ -36,66 +36,6 @@ const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({
|
|||||||
text,
|
text,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
interface InstallRoute {
|
|
||||||
id: string
|
|
||||||
os: OsName
|
|
||||||
best: string
|
|
||||||
lines: readonly TermLine[]
|
|
||||||
alt: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// aislop-ignore-file code-quality/duplicate-block -- three findings here, all data tables: the INSTALLS records repeat the field names InstallRoute requires, and the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic.
|
|
||||||
const INSTALLS: readonly InstallRoute[] = [
|
|
||||||
{
|
|
||||||
id: 'macos',
|
|
||||||
os: 'macOS',
|
|
||||||
best: 'Homebrew',
|
|
||||||
lines: [
|
|
||||||
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs' },
|
|
||||||
],
|
|
||||||
alt: (
|
|
||||||
<>
|
|
||||||
A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a
|
|
||||||
toolchain, <Code>cargo install soulseek-rs</Code> builds from crates.io instead.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'linux',
|
|
||||||
os: 'Linux',
|
|
||||||
best: 'cargo',
|
|
||||||
lines: [
|
|
||||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs' },
|
|
||||||
],
|
|
||||||
alt: (
|
|
||||||
<>
|
|
||||||
Homebrew on Linux works the same as on macOS. Without either, the{' '}
|
|
||||||
<ExtLink href={LINKS.releases}>releases page</ExtLink> has static musl archives for x86-64 and
|
|
||||||
arm64 that run on any distribution.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'windows',
|
|
||||||
os: 'Windows',
|
|
||||||
best: 'cargo or a prebuilt .exe',
|
|
||||||
lines: [
|
|
||||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs.exe' },
|
|
||||||
],
|
|
||||||
alt: (
|
|
||||||
<>
|
|
||||||
Cargo needs the MSVC build tools. To skip both, take the{' '}
|
|
||||||
<code>pc-windows-msvc</code> zip from the{' '}
|
|
||||||
<ExtLink href={LINKS.releases}>releases page</ExtLink>, unpack it, and put{' '}
|
|
||||||
<code>soulseek-rs.exe</code> on your PATH.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface PlatformNote {
|
interface PlatformNote {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -291,32 +231,12 @@ const Steps = () => (
|
|||||||
place.
|
place.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-secondary">
|
<p className="text-secondary">
|
||||||
Or pick a route per platform. Cargo needs a Rust toolchain from{' '}
|
Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '}
|
||||||
<ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>; the other routes don’t.
|
<ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>. Nix and Homebrew{' '}
|
||||||
|
<Code>--HEAD</Code> also build from source, but bring their own Rust.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<InstallCards />
|
||||||
{INSTALLS.map((route) => (
|
<LinuxRoutes />
|
||||||
<div
|
|
||||||
key={route.id}
|
|
||||||
id={route.id}
|
|
||||||
className="flex scroll-mt-20 flex-col gap-3.5 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<OsIcon name={route.os} />
|
|
||||||
<h3 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
|
||||||
{route.os}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
|
||||||
{route.best}
|
|
||||||
</span>
|
|
||||||
<Terminal lines={route.lines} wrap />
|
|
||||||
<p className="text-[12.5px] leading-5 text-secondary [&_code]:font-mono [&_code]:text-primary">
|
|
||||||
{route.alt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-muted">
|
<p className="text-[13px] text-muted">
|
||||||
Every route puts the same binary on your PATH. Run <Code>soulseek-rs</Code> in
|
Every route puts the same binary on your PATH. Run <Code>soulseek-rs</Code> in
|
||||||
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
||||||
@@ -522,11 +442,13 @@ const Config = () => (
|
|||||||
</Cols>
|
</Cols>
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
|
// aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic.
|
||||||
const Uninstall = () => (
|
const Uninstall = () => (
|
||||||
<Section band>
|
<Section band>
|
||||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||||
Three things exist on disk: the binary, the config, and the state, plus the completion
|
Three things exist on disk: the binary, the config, and the state, plus the completion
|
||||||
scripts and the agent skill if you asked for those. Nothing else, unless you point{' '}
|
scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page
|
||||||
|
and its own completions, and takes them back when removed. Nothing else, unless you point{' '}
|
||||||
<Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
<Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<Cols start>
|
<Cols start>
|
||||||
@@ -539,6 +461,8 @@ const Uninstall = () => (
|
|||||||
{ t: 'cm', text: '# whichever way you installed it' },
|
{ t: 'cm', text: '# whichever way you installed it' },
|
||||||
{ t: 'cmd', text: 'brew uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'brew uninstall soulseek-rs' },
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'sudo apt remove soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'sudo dnf remove soulseek-rs' },
|
||||||
{ t: 'cm', text: '# script install without brew: the binary itself' },
|
{ t: 'cm', text: '# script install without brew: the binary itself' },
|
||||||
{ t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' },
|
{ t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' },
|
||||||
{ t: 'cm', text: '# brew only: drop the tap it came from' },
|
{ t: 'cm', text: '# brew only: drop the tap it came from' },
|
||||||
@@ -581,7 +505,7 @@ const Uninstall = () => (
|
|||||||
export const Install = () => (
|
export const Install = () => (
|
||||||
<>
|
<>
|
||||||
<PageHead eyebrow="install" title="Three ways in.">
|
<PageHead eyebrow="install" title="Three ways in.">
|
||||||
One command with the install script, Homebrew or cargo. Build from source if
|
One command with the install script, your package manager, Homebrew or cargo. Build from source if
|
||||||
you’d rather. Or depend on the library and write your own client.
|
you’d rather. Or depend on the library and write your own client.
|
||||||
</PageHead>
|
</PageHead>
|
||||||
<Steps />
|
<Steps />
|
||||||
|
|||||||
Reference in New Issue
Block a user