mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 17:55:43 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8800805f | |||
| 6b42d878c2 | |||
| 8fd73a145e | |||
| 5f76277aaf | |||
| 1cfda45334 | |||
| 8cbc1c8768 | |||
| eca4efb1ad | |||
| 33613e8679 | |||
| d8a6440998 | |||
| 164f9f32a1 | |||
| 74eb2f84cf | |||
| 9ac3dc27a3 | |||
| 752c86c89b | |||
| 2858b4c757 | |||
| d3345d34e1 | |||
| 3624db280c | |||
| d4dcaf38b7 | |||
| c3afc414a3 | |||
| 1ddfaec338 | |||
| bbe5445933 | |||
| f5e228a589 | |||
| f65d98bbd0 | |||
| 006f941bae | |||
| 2606502293 | |||
| bbfc6e9eb1 | |||
| d329a5ad91 | |||
| 0b620b46f3 | |||
| 86c9c49be2 | |||
| efe9ffa3c2 | |||
| 6440d8ad40 | |||
| ddde763133 | |||
| 69a818d621 | |||
| ec3bd05547 | |||
| 248b4ac460 | |||
| 41b525ea18 | |||
| 3f4f344620 | |||
| 4814c8e92b | |||
| c87d4a5ec5 | |||
| 050932dc27 | |||
| 4777f1e3c3 | |||
| 725f5e40ec | |||
| b564e92532 | |||
| 1d629873b5 | |||
| 09ad412047 | |||
| f3cf70ab20 | |||
| a4a9949513 | |||
| 9bf02f2ee2 | |||
| 300e225a3a | |||
| 9d1e8d994a | |||
| 7477a26378 | |||
| e4a698eb7f | |||
| 6b9919c5a1 |
@@ -0,0 +1,2 @@
|
|||||||
|
scripts/aria2/firelink.patch text eol=lf
|
||||||
|
scripts/aria2/build.sh text eol=lf
|
||||||
+110
-7
@@ -9,6 +9,18 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
rust-security:
|
||||||
|
name: Rust advisory audit
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Install locked cargo-audit
|
||||||
|
run: cargo install cargo-audit --version 0.22.2 --locked
|
||||||
|
- name: Reject vulnerable resolved dependencies
|
||||||
|
working-directory: src-tauri
|
||||||
|
run: cargo audit
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend checks
|
name: Frontend checks
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@@ -18,7 +30,7 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22.12
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: node --test scripts/*.node-test.js
|
- run: node --test scripts/*.node-test.js
|
||||||
@@ -27,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
name: Desktop checks (${{ matrix.target }})
|
name: Desktop checks (${{ matrix.target }})
|
||||||
timeout-minutes: 30
|
timeout-minutes: 45
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -45,11 +57,15 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22.12
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
- name: Cache Rust dependencies and build targets
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri -> target
|
||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
@@ -114,16 +130,103 @@ jobs:
|
|||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
|
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
|
||||||
- name: Provision locked engines
|
- name: Install Aria2 source build dependencies (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||||
|
- name: Install Aria2 source build dependencies (Windows)
|
||||||
|
id: aria2-msys
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
install: >-
|
||||||
|
base-devel autoconf automake libtool gettext-devel pkgconf
|
||||||
|
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||||
|
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||||
|
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||||
|
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||||
|
- name: Fingerprint engine toolchain
|
||||||
|
id: engine-toolchain
|
||||||
if: runner.os != 'macOS'
|
if: runner.os != 'macOS'
|
||||||
|
env:
|
||||||
|
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||||
|
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||||
|
run: node scripts/engine-toolchain-fingerprint.js
|
||||||
|
- name: Restore verified engine payload cache
|
||||||
|
id: engine-cache
|
||||||
|
if: runner.os != 'macOS'
|
||||||
|
# v4.2.0 pinned to an immutable commit; this cache is an optimization,
|
||||||
|
# and a miss always falls back to source provisioning below.
|
||||||
|
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||||
|
# The target, toolchain fingerprint, lockfiles, provisioning code,
|
||||||
|
# payload validators, and runner package lists all invalidate the key.
|
||||||
|
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||||
|
- name: Validate restored engine payload
|
||||||
|
id: engine-cache-validation
|
||||||
|
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
|
||||||
|
run: |
|
||||||
|
node scripts/stage-engines.js
|
||||||
|
node scripts/verify-binaries.js --staged
|
||||||
|
- name: Restore verified Aria2 build cache
|
||||||
|
id: aria2-cache
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||||
|
steps.engine-cache-validation.outcome != 'success')
|
||||||
|
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||||
|
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||||
|
- name: Provision locked engines
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||||
|
steps.engine-cache-validation.outcome != 'success')
|
||||||
|
env:
|
||||||
|
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||||
|
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
|
||||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||||
- name: Stage and verify engines
|
- name: Stage and verify engines
|
||||||
|
env:
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||||
run: |
|
run: |
|
||||||
node scripts/stage-engines.js --target ${{ matrix.target }}
|
node scripts/stage-engines.js --target ${{ matrix.target }}
|
||||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||||
- name: Run Torrent process smoke
|
- name: Run Torrent process smoke
|
||||||
run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths
|
env:
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||||
|
run: node scripts/smoke-torrent.js --failure-paths
|
||||||
- name: Run Aria2 resolver smoke
|
- name: Run Aria2 resolver smoke
|
||||||
run: node scripts/smoke-aria2-resolver.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
env:
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||||
|
run: node scripts/smoke-aria2-resolver.js
|
||||||
- name: Run Aria2 normal-transfer smoke
|
- name: Run Aria2 normal-transfer smoke
|
||||||
run: node scripts/smoke-aria2-transfers.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
env:
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||||
|
run: node scripts/smoke-aria2-transfers.js
|
||||||
|
- name: Save verified Aria2 build cache
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
github.ref == 'refs/heads/main' &&
|
||||||
|
steps.aria2-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||||
|
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||||
|
- name: Save verified engine payload cache
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
github.ref == 'refs/heads/main' &&
|
||||||
|
steps.engine-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||||
|
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22.12
|
||||||
cache: npm
|
cache: npm
|
||||||
- name: Verify tagged release version
|
- name: Verify tagged release version
|
||||||
if: github.event_name == 'push' || inputs.publish_release
|
if: github.event_name == 'push' || inputs.publish_release
|
||||||
@@ -100,17 +100,74 @@ jobs:
|
|||||||
desktop-file-utils \
|
desktop-file-utils \
|
||||||
xdg-utils
|
xdg-utils
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Provision locked engines
|
- name: Install Aria2 source build dependencies (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||||
|
- name: Install Aria2 source build dependencies (Windows)
|
||||||
|
id: aria2-msys
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
install: >-
|
||||||
|
base-devel autoconf automake libtool gettext-devel pkgconf
|
||||||
|
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||||
|
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||||
|
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||||
|
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||||
|
- name: Fingerprint engine toolchain
|
||||||
|
id: engine-toolchain
|
||||||
if: runner.os != 'macOS'
|
if: runner.os != 'macOS'
|
||||||
|
env:
|
||||||
|
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||||
|
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||||
|
run: node scripts/engine-toolchain-fingerprint.js
|
||||||
|
- name: Restore verified engine payload cache
|
||||||
|
id: engine-cache
|
||||||
|
if: runner.os != 'macOS'
|
||||||
|
# v4.2.0 pinned to an immutable commit; release jobs never write cache
|
||||||
|
# entries, so only trusted CI pushes can populate the shared payload.
|
||||||
|
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||||
|
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||||
|
- name: Validate restored engine payload
|
||||||
|
id: engine-cache-validation
|
||||||
|
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
|
||||||
|
run: |
|
||||||
|
node scripts/stage-engines.js
|
||||||
|
node scripts/verify-binaries.js --staged
|
||||||
|
- name: Restore verified Aria2 build cache
|
||||||
|
id: aria2-cache
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||||
|
steps.engine-cache-validation.outcome != 'success')
|
||||||
|
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||||
|
with:
|
||||||
|
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||||
|
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||||
|
- name: Provision locked engines
|
||||||
|
if: >-
|
||||||
|
runner.os != 'macOS' &&
|
||||||
|
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||||
|
steps.engine-cache-validation.outcome != 'success')
|
||||||
|
env:
|
||||||
|
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||||
|
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
|
||||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||||
- name: Build package
|
- name: Build package
|
||||||
if: runner.os != 'Linux'
|
if: runner.os != 'Linux'
|
||||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||||
env:
|
env:
|
||||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||||
- name: Build Linux native packages
|
- name: Build Linux native packages
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||||
env:
|
env:
|
||||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||||
- name: Verify and preserve Linux native packages
|
- name: Verify and preserve Linux native packages
|
||||||
@@ -321,8 +378,12 @@ jobs:
|
|||||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
cd release-assets
|
cd release-assets
|
||||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
checksum_tmp="$RUNNER_TEMP/Firelink-SHA256SUMS"
|
||||||
|
trap 'rm -f "$checksum_tmp"' EXIT
|
||||||
|
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > "$checksum_tmp"
|
||||||
|
mv "$checksum_tmp" SHA256SUMS
|
||||||
- uses: softprops/action-gh-release@v3
|
- uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: release-assets/**
|
files: release-assets/**
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ lerna-debug.log*
|
|||||||
target/
|
target/
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
src-tauri/engine-dist/
|
|
||||||
src-tauri/provisioned-engines/
|
src-tauri/provisioned-engines/
|
||||||
|
|
||||||
# Locally provisioned native engines
|
# Locally provisioned native engines
|
||||||
|
|||||||
+30
-26
@@ -5,52 +5,56 @@ All notable changes to Firelink will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.4.0] - 2026-08-27
|
## [1.4.2] - 2026-09-08
|
||||||
|
|
||||||
This release adds built-in Torrent downloads and a dedicated Properties window, while making regular downloads, browser handoffs, and cross-platform packages more dependable.
|
This is the stable follow-up to the 1.4.0 pre-release and includes all work since 1.3.1. It is a major release focused on built-in Torrents, clearer transfer controls, safer recovery, and better browser and VPN support.
|
||||||
|
|
||||||
### New features
|
### New features
|
||||||
|
|
||||||
- **Torrent downloads**
|
- **BitTorrent downloads and browser handoff**
|
||||||
- Add `.torrent` files and magnet links from the Add window, file associations, `magnet:` links, and Firelink Companion.
|
- Add `.torrent` files and magnet links from the Add window, file associations, `magnet:` links, and Firelink Companion.
|
||||||
- Resolve remote metadata before enqueueing and safely reuse validated Torrent metadata.
|
- Review remote metadata before queueing; choose files and priorities, allocate or preallocate data, verify existing files, remove unselected files safely, and add per-file web seeds.
|
||||||
- Select files, prioritize pieces, preallocate or allocate as needed, verify existing data, remove unselected files safely, and add per-file web seeds.
|
- Configure trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
|
||||||
- Manage trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
|
- See per-file and piece progress, availability, peers, seeders, info hash, and upload activity.
|
||||||
- View file progress, piece availability, connected and listed peers, seeders, upload totals and speed, and the info hash.
|
- Set upload and seeding limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data.
|
||||||
- Set upload limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data to a new location.
|
- Manage Torrents in a dedicated category with pause, resume, retry, redownload, and safe cleanup.
|
||||||
- Use a dedicated Torrents category with pause, resume, retry, redownload, and safe cleanup.
|
- Send browser magnets, direct `.torrent` links, and browser-local Torrent attachments to the Add window for review.
|
||||||
- **Download and Torrent Properties windows**
|
- **Download and Torrent Properties windows**
|
||||||
- Open a selected download in its own window with overview, transfer, and advanced controls.
|
- Open a selected download in its own window with overview, transfer, and advanced controls.
|
||||||
- Use Torrent tabs for file selection, trackers, peers, options, and live diagnostics.
|
- Use Torrent tabs for file selection, trackers, peers, options, and live diagnostics.
|
||||||
- Edit supported settings while a transfer is active, including speed, connections, Torrent upload and peer limits, seeding, verification, allocation, and encryption.
|
- Change supported transfer, Torrent, seeding, verification, allocation, and encryption settings while work is active.
|
||||||
- Inspect allocation, exact progress, resume failures, destinations, and current diagnostics; copy long URLs or paths and export magnet links where available.
|
- Inspect exact progress, allocation, destinations, resume failures, and diagnostics; copy long URLs or paths and export magnet links.
|
||||||
- Keep the window size during the app session while the window follows the current theme and locale.
|
- Keep the Properties window size during the session while theme and locale follow the app.
|
||||||
- **Adaptive mirror selection**
|
- **Adaptive mirror selection**
|
||||||
- Optionally use recent transfer performance to choose among multiple mirrors. Mirror statistics stay private on this device.
|
- Optionally choose among mirrors using recent transfer performance; history remains private on this device.
|
||||||
- **Transfer and layout visibility**
|
- **Transfer and layout visibility**
|
||||||
- Show the file-allocation phase while a normal download prepares its destination.
|
- See when a normal download is allocating its destination.
|
||||||
- Remember the main-window size and position and the Folders collapse preference between launches.
|
- Remember the main-window size and position and the Folders collapse preference between launches.
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
- Improve normal-download recovery across restarts, stale transfers, redirects, mirrors, connection-pool slowdowns, retries, and resume operations without saved credentials.
|
- Make normal downloads recover more reliably across restarts, redirects, retries, resumed transfers, missing credentials, and connection slowdowns.
|
||||||
- Improve media recovery and resume messaging, preserve exact progress at the end of a transfer, and restore adaptive YouTube formats. This responds to the interrupted-YouTube-download report in [#36](https://github.com/nimbold/Firelink/issues/36).
|
- Improve media recovery and resume behavior, preserve exact final progress, and restore adaptive YouTube formats after interruptions, addressing [#36](https://github.com/nimbold/Firelink/issues/36).
|
||||||
- Make browser and deep-link inputs arrive in order, keep magnet clipboard handoffs usable, and make Add-window destination and metadata validation clearer.
|
- Keep browser and deep-link inputs in order; make magnet clipboard handoffs and Add-window destination and metadata validation clearer.
|
||||||
- Improve the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior at narrow window sizes.
|
- Make the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior more usable at narrow window sizes.
|
||||||
- Add clear guidance for the macOS first-launch security warning and safe approval steps, responding to [#34](https://github.com/nimbold/Firelink/issues/34).
|
- Document the macOS first-launch security warning and safe approval steps after the report in [#34](https://github.com/nimbold/Firelink/issues/34).
|
||||||
- Refresh bundled engines and dependencies, resume interrupted engine downloads safely, and strengthen package, release, and cross-platform verification.
|
- Refresh bundled engines and dependencies, resume interrupted engine downloads safely, and strengthen cross-platform package and release verification.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Retry affected transfers through the system resolver when a VPN or network tunnel leaves aria2 unable to resolve a host, addressing [#35](https://github.com/nimbold/Firelink/issues/35).
|
- Fix the Windows 1.4.0 startup failure, focus recursion, and Properties-window deadlock reported in [#37](https://github.com/nimbold/Firelink/issues/37) and [#41](https://github.com/nimbold/Firelink/issues/41).
|
||||||
- Prevent late or duplicate lifecycle events from reviving, removing, or misreporting a download after a newer action has already won.
|
- Fix immediate download failures after the Add window showed **Ready** when a VPN or TUN's DNS path could not reach its servers; retry through the system resolver for affected transfers, addressing [#35](https://github.com/nimbold/Firelink/issues/35).
|
||||||
- Keep replacement, removal, and pre-admission cleanup from leaving stale queue entries, partial files, or misleading progress behind.
|
- Fix false **Unsafe URL** failures under V2RayN, Proxifier, and other TUN or proxy setups by letting hostname lookups follow the active network route while continuing to block literal local and private targets, addressing [#38](https://github.com/nimbold/Firelink/issues/38).
|
||||||
- Keep completed, paused, failed, and retrying downloads authoritative while allocation and progress updates arrive asynchronously.
|
- Protect persisted downloads during startup and recover schema-v3 records instead of wiping or losing them.
|
||||||
- Make scheduled actions, speed limits, logs, persisted settings, and browser credentials safer when several changes happen close together.
|
- Prevent stale or duplicate pause, resume, retry, completion, and removal actions from reviving items, misreporting progress, or leaving queue ownership behind.
|
||||||
|
- Make replacements and cleanup safe when downloads are queued, retried, canceled, paused, completed, or removed, including multi-file assets.
|
||||||
|
- Prevent browser capture races from duplicating a download, resuming it too early, or applying cleanup to the wrong item.
|
||||||
|
- Fix network and Settings panel overlap plus Windows frame, shadow, focus, and cross-platform control issues.
|
||||||
|
- Restore edited-download credentials without stale keychain conflicts.
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
- Use [Firelink Companion `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0), or the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest), with Firelink `1.4.0`.
|
- Use [Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2), or the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest), with Firelink `1.4.2`. The Companion includes the shared Chromium package and localized Edge Add-ons submission material requested in [#39](https://github.com/nimbold/Firelink/issues/39); the public Edge listing still requires Microsoft's certification.
|
||||||
|
|
||||||
## [1.3.1] - 2026-07-30
|
## [1.3.1] - 2026-07-30
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule Extensions/Browser updated: 3fe0a52a59...f20954fe0a
@@ -28,9 +28,9 @@ It uses a Rust and Tauri backend with a React and TypeScript interface. Required
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Firelink `1.4.0` is the latest desktop release.
|
Firelink `1.4.2` is the latest desktop release.
|
||||||
|
|
||||||
Use [the latest Firelink Companion release, `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0) with Firelink. The Companion is maintained in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension).
|
Use [the latest Firelink Companion release, `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) with Firelink. The Companion is maintained in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension).
|
||||||
|
|
||||||
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ Only use these steps for Firelink downloaded from the [official GitHub release p
|
|||||||
|
|
||||||
## Browser integration
|
## Browser integration
|
||||||
|
|
||||||
[Firelink Companion `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0) connects browser downloads, links, media pages, magnets, and Torrent metadata to Firelink. Use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest) with the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
[Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) connects browser downloads, links, media pages, magnets, and Torrent metadata to Firelink. Use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest) with the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
||||||
|
|
||||||
Captured links open Firelink's Add window for review before they are started or queued.
|
Captured links open Firelink's Add window for review before they are started or queued.
|
||||||
|
|
||||||
|
|||||||
+35
-3
@@ -22,9 +22,20 @@ Firelink never falls back to system-installed media tools.
|
|||||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||||
- `scripts/provision-engines.js` downloads and verifies target archives.
|
- `scripts/provision-engines.js` downloads and verifies target archives.
|
||||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
- `scripts/stage-engines.js` creates one target-specific bundle payload in an
|
||||||
|
invocation-owned temporary workspace.
|
||||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||||
|
|
||||||
|
Aria2 allocation telemetry is a required bundle capability. Windows and Linux
|
||||||
|
provisioning now builds the checksum-pinned upstream source archive with
|
||||||
|
`scripts/aria2/firelink.patch`; this patch also retains Firelink's native DNS,
|
||||||
|
network target policy, and Torrent routing changes. CI installs the compiler
|
||||||
|
and static-library prerequisites. Windows uses the MSYS2 installation returned
|
||||||
|
by the setup action (`FIRELINK_MSYS2_ROOT`, default `C:/msys64` for local builds).
|
||||||
|
The patch checksum is recorded in both source and payload provenance. Never
|
||||||
|
replace these builds with stock Aria2 archives: package verification requires
|
||||||
|
`firelinkAllocationTelemetry: true` from `aria2.getVersion`.
|
||||||
|
|
||||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||||
|
|
||||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||||
@@ -41,8 +52,6 @@ Keep versions aligned:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
|
||||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
|
||||||
npm test -- --run
|
npm test -- --run
|
||||||
npm run build
|
npm run build
|
||||||
cd src-tauri && cargo test --all-targets
|
cd src-tauri && cargo test --all-targets
|
||||||
@@ -50,6 +59,29 @@ cd ..
|
|||||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run tauri` owns engine staging for `dev`, `build`, and `bundle`. The
|
||||||
|
wrapper creates a private workspace, verifies the payload, and removes the
|
||||||
|
workspace after Tauri exits. To stage and verify a payload manually, provide a
|
||||||
|
private output root explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ENGINE_OUTPUT_ROOT="$(mktemp -d -t firelink-engines)/engine-dist"
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
|
||||||
|
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
|
||||||
|
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `src-tauri/engine-dist` or another repository-shared directory as
|
||||||
|
the manual output root. On Windows, set `FIRELINK_ENGINE_OUTPUT_ROOT` to a
|
||||||
|
private directory under `$env:TEMP` and use the PowerShell form:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:FIRELINK_ENGINE_OUTPUT_ROOT = Join-Path $env:TEMP "firelink-engines-$PID\engine-dist"
|
||||||
|
node scripts/stage-engines.js --target x86_64-pc-windows-msvc
|
||||||
|
node scripts/verify-binaries.js --staged --target x86_64-pc-windows-msvc
|
||||||
|
```
|
||||||
|
|
||||||
Verify the DMG and the app it contains, then launch outside the repository
|
Verify the DMG and the app it contains, then launch outside the repository
|
||||||
working directory. The DMG bundler removes the intermediate app directory, so
|
working directory. The DMG bundler removes the intermediate app directory, so
|
||||||
the post-build checks must use the mounted release artifact:
|
the post-build checks must use the mounted release artifact:
|
||||||
|
|||||||
+13
-4
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
|
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
|
||||||
|
|
||||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
Exact versions, target hashes, sources, and build descriptions are pinned in
|
||||||
|
`engines.lock.json` for the packaged macOS payload and `engine-sources.lock.json`
|
||||||
|
for the provisioned Windows and Linux payloads.
|
||||||
|
|
||||||
## Bundled fonts
|
## Bundled fonts
|
||||||
|
|
||||||
@@ -27,9 +29,14 @@ License text: <https://openfontlicense.org/open-font-license-official-text/>
|
|||||||
- Source: <https://github.com/aria2/aria2>
|
- Source: <https://github.com/aria2/aria2>
|
||||||
- License: GNU General Public License version 2 or later
|
- License: GNU General Public License version 2 or later
|
||||||
|
|
||||||
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
|
Corresponding source for the distributed version is available from the source
|
||||||
|
link and release tag listed in the applicable engine lock file. Firelink release
|
||||||
|
notes must retain that source reference.
|
||||||
|
|
||||||
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
|
Windows and Linux use checksum-pinned upstream aria2 source archives built with
|
||||||
|
Firelink's reviewed native-DNS, network-target, and allocation-telemetry patch.
|
||||||
|
The archive, patch checksum, and build provenance are recorded in
|
||||||
|
`engine-sources.lock.json`.
|
||||||
|
|
||||||
## FFmpeg
|
## FFmpeg
|
||||||
|
|
||||||
@@ -56,4 +63,6 @@ Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python
|
|||||||
|
|
||||||
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
|
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
|
||||||
|
|
||||||
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
|
Release engineering must review each newly added target payload before adding its
|
||||||
|
hashes to the applicable engine lock file. Missing provenance or license data
|
||||||
|
blocks release.
|
||||||
|
|||||||
+38
-20
@@ -8,19 +8,29 @@
|
|||||||
"sha256": "30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf"
|
"sha256": "30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.5",
|
"version": "2.9.6",
|
||||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-pc-windows-msvc.zip",
|
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-pc-windows-msvc.zip",
|
||||||
"sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a"
|
"sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11"
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"version": "8.1.2-46-g139afe709a",
|
"version": "9.0.1-26-g5c8e7e2433",
|
||||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-win64-gpl-8.1.zip",
|
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-win64-gpl-9.0.zip",
|
||||||
"sha256": "f966bc2e843bcd680dedd6d1a2c0c895bab859a402c6dd107cbe72a796dfebcf"
|
"sha256": "dd232ccf8661f837a1faa5f534a1a0bdbdb25c42afe79391e8345154df78f791"
|
||||||
},
|
},
|
||||||
"aria2c": {
|
"aria2c": {
|
||||||
"version": "1.37.0",
|
"version": "1.37.0-firelink-native-dns-v1",
|
||||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
|
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||||
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
|
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||||
|
"buildFromSource": true,
|
||||||
|
"patch": "scripts/aria2/firelink.patch",
|
||||||
|
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
|
||||||
|
"allocationTelemetry": true,
|
||||||
|
"firelinkRouteContract": {
|
||||||
|
"revision": "firelink-native-dns-v1",
|
||||||
|
"dnsResolver": "native-async",
|
||||||
|
"networkTargetPolicy": "firelink-v1",
|
||||||
|
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x86_64-unknown-linux-gnu": {
|
"x86_64-unknown-linux-gnu": {
|
||||||
@@ -30,21 +40,29 @@
|
|||||||
"sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9"
|
"sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.5",
|
"version": "2.9.6",
|
||||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-unknown-linux-gnu.zip",
|
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-unknown-linux-gnu.zip",
|
||||||
"sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530"
|
"sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2"
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"version": "8.1.2-46-g139afe709a",
|
"version": "9.0.1-26-g5c8e7e2433",
|
||||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-linux64-gpl-8.1.tar.xz",
|
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-linux64-gpl-9.0.tar.xz",
|
||||||
"sha256": "0814f4491c2673ea505be8fb65a76c2bfabaa5aad8f33d49b1c5b87a2262e8c5"
|
"sha256": "e60c4187c792cc35d2558adbae5582c470713f5afed7212999200340f1394f8d"
|
||||||
},
|
},
|
||||||
"aria2c": {
|
"aria2c": {
|
||||||
"version": "1.37.0",
|
"version": "1.37.0-firelink-native-dns-v1",
|
||||||
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
|
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||||
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
|
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||||
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
"buildFromSource": true,
|
||||||
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
|
"patch": "scripts/aria2/firelink.patch",
|
||||||
|
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
|
||||||
|
"allocationTelemetry": true,
|
||||||
|
"firelinkRouteContract": {
|
||||||
|
"revision": "firelink-native-dns-v1",
|
||||||
|
"dnsResolver": "native-async",
|
||||||
|
"networkTargetPolicy": "firelink-v1",
|
||||||
|
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -10,23 +10,32 @@
|
|||||||
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
|
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
|
||||||
},
|
},
|
||||||
"aria2c": {
|
"aria2c": {
|
||||||
"version": "1.37.0",
|
"version": "1.37.0-firelink-native-dns-v1",
|
||||||
"source": "https://github.com/aria2/aria2",
|
"source": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||||
"build": "arm64 executable with adjacent aria2-libs",
|
"build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs",
|
||||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
"firelinkRouteContract": {
|
||||||
|
"revision": "firelink-native-dns-v1",
|
||||||
|
"dnsResolver": "native-async",
|
||||||
|
"networkTargetPolicy": "firelink-v1",
|
||||||
|
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||||
|
},
|
||||||
|
"sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7",
|
||||||
|
"allocationTelemetry": true,
|
||||||
|
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d"
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"version": "N-125892-g406c5a37aa",
|
"version": "9.0.1",
|
||||||
"source": "https://ffmpeg.org/",
|
"source": "https://ffmpeg.org/",
|
||||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
"build": "Stable GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1785661721_N-125892-g406c5a37aa/ffmpeg.zip",
|
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1787073674_9.0.1/ffmpeg.zip",
|
||||||
"sha256": "734e6b72a0c2d0d5e089b5a0094be74fa058c15158f6b0689207a01fedafd8f5"
|
"sourceSha256": "8287a1b2229e05eb41859f073e18e6c52c60a778f2f5e6881070fe51b79407fe",
|
||||||
|
"sha256": "393e4c395020a1cb7cbd77fbe00599ce69d1c6466fee0dbd59d13f86a81a1611"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.5",
|
"version": "2.9.6",
|
||||||
"source": "https://github.com/denoland/deno",
|
"source": "https://github.com/denoland/deno",
|
||||||
"build": "official aarch64-apple-darwin executable",
|
"build": "official aarch64-apple-darwin executable",
|
||||||
"sha256": "b5bd08edab254d42d7b05aa5b6cb4c9b8d4dede4975aff76951ce2cce18866fa"
|
"sha256": "b3ac3bd206e48c26026cadd80c1367e96c149f9c66130952382a642b09fa8a71"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeTrees": {
|
"runtimeTrees": {
|
||||||
|
|||||||
Generated
+145
-235
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
@@ -18,33 +18,33 @@
|
|||||||
"@formkit/auto-animate": "^0.10.0",
|
"@formkit/auto-animate": "^0.10.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.1",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.4.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2.5.5",
|
||||||
"i18next": "^26.4.0",
|
"i18next": "^26.4.2",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.42.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.12",
|
"react-i18next": "^17.0.13",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.5",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.2.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
@@ -418,13 +418,6 @@
|
|||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
@@ -997,54 +990,54 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-clipboard-manager": {
|
"node_modules/@tauri-apps/plugin-clipboard-manager": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.3.tgz",
|
||||||
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
|
"integrity": "sha512-KnyoTs9gj1yEgDkSPUNjOIOHjJTr5wk8IWcYMOWxYTIJCip6QwlyPW8u2X+6bd6kHM4fAdZNpxoal0gy/TwJbg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz",
|
||||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
"integrity": "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-fs": {
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.2.tgz",
|
||||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
"integrity": "sha512-XXvMSnFiob+G1H+YHCDf+bzWVumseQuEIhzpbOJzevUfL4k0U+sTApZWJHpoLiamggnVPJm5dJ5sDsniRyWxlg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-log": {
|
"node_modules/@tauri-apps/plugin-log": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.1.tgz",
|
||||||
"integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==",
|
"integrity": "sha512-8dYNEQOgZcIEqeFtHAsOIGLoptm+j94270Jf2MrGS/zbsYNd4C8DKyOdeYggAyE3ugwr12mTefIfC8lVVEhdlg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.4.0.tgz",
|
||||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
"integrity": "sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz",
|
||||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
"integrity": "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
@@ -1086,9 +1079,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1416,9 +1409,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1445,34 +1438,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
|
||||||
"version": "4.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
|
||||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.1.0",
|
|
||||||
"@types/chai": "^5.2.2",
|
|
||||||
"@vitest/spy": "4.1.11",
|
|
||||||
"@vitest/utils": "4.1.11",
|
|
||||||
"chai": "^6.2.2",
|
|
||||||
"tinyrainbow": "^3.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.11",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.11",
|
"@jridgewell/trace-mapping": "0.3.31",
|
||||||
|
"@vitest/spy": "5.0.0",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^1.2.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
@@ -1490,74 +1466,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
"version": "4.1.11",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
|
||||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyrainbow": "^3.1.0"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/runner": {
|
|
||||||
"version": "4.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
|
||||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/utils": "4.1.11",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/snapshot": {
|
|
||||||
"version": "4.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
|
||||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/pretty-format": "4.1.11",
|
|
||||||
"@vitest/utils": "4.1.11",
|
|
||||||
"magic-string": "^0.30.21",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.11",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
|
||||||
"version": "4.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
|
||||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/pretty-format": "4.1.11",
|
|
||||||
"convert-source-map": "^2.0.0",
|
|
||||||
"tinyrainbow": "^3.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1569,9 +1497,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz",
|
||||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
"integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1589,8 +1517,8 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.28.6",
|
"browserslist": "^4.28.9",
|
||||||
"caniuse-lite": "^1.0.30001806",
|
"caniuse-lite": "^1.0.30001810",
|
||||||
"fraction.js": "^5.3.4",
|
"fraction.js": "^5.3.4",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss-value-parser": "^4.2.0"
|
"postcss-value-parser": "^4.2.0"
|
||||||
@@ -1606,9 +1534,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.14",
|
"version": "2.11.21",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
"integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1619,9 +1547,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.9",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||||
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
|
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1639,11 +1567,11 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.11.12",
|
"baseline-browser-mapping": "^2.11.20",
|
||||||
"caniuse-lite": "^1.0.30001809",
|
"caniuse-lite": "^1.0.30001810",
|
||||||
"electron-to-chromium": "^1.5.402",
|
"electron-to-chromium": "^1.5.420",
|
||||||
"node-releases": "^2.0.53",
|
"node-releases": "^2.0.54",
|
||||||
"update-browserslist-db": "^1.3.0"
|
"update-browserslist-db": "^1.3.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"browserslist": "cli.js"
|
"browserslist": "cli.js"
|
||||||
@@ -1653,9 +1581,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001809",
|
"version": "1.0.30001810",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||||
"integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
|
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1683,13 +1611,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/convert-source-map": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1707,9 +1628,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.406",
|
"version": "1.5.422",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
|
||||||
"integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==",
|
"integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -1727,9 +1648,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1824,9 +1745,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.0",
|
"version": "26.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||||
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2122,9 +2043,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.34.0",
|
"version": "1.42.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
|
||||||
"integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
|
"integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -2158,9 +2079,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.53",
|
"version": "2.0.54",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||||
"integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
|
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2181,13 +2102,6 @@
|
|||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pathe": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2195,9 +2109,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2207,9 +2121,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2226,7 +2140,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.17",
|
"nanoid": "^3.3.18",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2263,9 +2177,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.12",
|
"version": "17.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
|
||||||
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
@@ -2377,11 +2291,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
@@ -2409,16 +2326,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyrainbow": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
@@ -2455,9 +2362,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||||
"integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
|
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2833,38 +2740,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.11",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
|
||||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.11",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/mocker": "4.1.11",
|
"@vitest/mocker": "5.0.0",
|
||||||
"@vitest/pretty-format": "4.1.11",
|
"chai": "^6.2.2",
|
||||||
"@vitest/runner": "4.1.11",
|
"es-module-lexer": "^2.3.2",
|
||||||
"@vitest/snapshot": "4.1.11",
|
"expect-type": "^1.4.0",
|
||||||
"@vitest/spy": "4.1.11",
|
"magic-string": "^1.2.3",
|
||||||
"@vitest/utils": "4.1.11",
|
"obug": "^2.1.4",
|
||||||
"es-module-lexer": "^2.0.0",
|
"picomatch": "^4.0.7",
|
||||||
"expect-type": "^1.3.0",
|
"std-env": "^4.2.0",
|
||||||
"magic-string": "^0.30.21",
|
"tinybench": "6.1.4",
|
||||||
"obug": "^2.1.1",
|
"tinyexec": "1.3.0",
|
||||||
"pathe": "^2.0.3",
|
"tinyglobby": "^0.2.17",
|
||||||
"picomatch": "^4.0.3",
|
|
||||||
"std-env": "^4.0.0-rc.1",
|
|
||||||
"tinybench": "^2.9.0",
|
|
||||||
"tinyexec": "^1.0.2",
|
|
||||||
"tinyglobby": "^0.2.15",
|
|
||||||
"tinyrainbow": "^3.1.0",
|
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vitest": "vitest.mjs"
|
"vitest": "vitest.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
@@ -2872,16 +2772,16 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.1.11",
|
"@vitest/browser-playwright": "5.0.0",
|
||||||
"@vitest/browser-preview": "4.1.11",
|
"@vitest/browser-preview": "5.0.0",
|
||||||
"@vitest/browser-webdriverio": "4.1.11",
|
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||||
"@vitest/coverage-istanbul": "4.1.11",
|
"@vitest/coverage-istanbul": "5.0.0",
|
||||||
"@vitest/coverage-v8": "4.1.11",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"@vitest/ui": "4.1.11",
|
"@vitest/ui": "5.0.0",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*",
|
"jsdom": "*",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edge-runtime/vm": {
|
"@edge-runtime/vm": {
|
||||||
@@ -2922,6 +2822,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
+19
-18
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://github.com/nimbold/Firelink",
|
"homepage": "https://github.com/nimbold/Firelink",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"desktop"
|
"desktop"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.12"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,8 +40,9 @@
|
|||||||
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
|
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
|
||||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "node scripts/tauri-command.js",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:race": "vitest run --repeats 5 src/utils/dockBadge.test.ts src/store/downloadStore.test.ts src/store/useDownloadStore.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
@@ -53,29 +54,29 @@
|
|||||||
"@formkit/auto-animate": "^0.10.0",
|
"@formkit/auto-animate": "^0.10.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.1",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.4.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2.5.5",
|
||||||
"i18next": "^26.4.0",
|
"i18next": "^26.4.2",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.42.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.12",
|
"react-i18next": "^17.0.13",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.5",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.2.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export const ARIA2_FIRELINK_REVISION = 'firelink-native-dns-v1';
|
||||||
|
export const ARIA2_DNS_RESOLVER = 'native-async';
|
||||||
|
export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1';
|
||||||
|
export const ARIA2_NETWORK_TARGET_POLICY_DIGEST =
|
||||||
|
'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705';
|
||||||
|
|
||||||
|
// The standard Aria2 option is the production default. It keeps hostname
|
||||||
|
// resolution on the OS/TUN route and is accepted by stock and patched builds.
|
||||||
|
// There is intentionally no daemon-wide flag here: async-dns=false is stamped
|
||||||
|
// per transfer after the caller has selected the compatible route.
|
||||||
|
export const ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS = Object.freeze([]);
|
||||||
|
export const ARIA2_SYSTEM_RESOLVER_OPTIONS = Object.freeze({
|
||||||
|
'async-dns': 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Firelink-patched Aria2 exposes these options for the bounded alternate
|
||||||
|
// magnet-probe attempt. They must never be sent to an unverified binary.
|
||||||
|
export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([
|
||||||
|
'--async-dns=true',
|
||||||
|
`--dns-resolver=${ARIA2_DNS_RESOLVER}`,
|
||||||
|
`--network-target-policy=${ARIA2_NETWORK_TARGET_POLICY}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ARIA2_ROUTE_OPTIONS = Object.freeze({
|
||||||
|
'async-dns': 'true',
|
||||||
|
'dns-resolver': ARIA2_DNS_RESOLVER,
|
||||||
|
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local HTTP servers are used only by engine smoke tests. Product transfers
|
||||||
|
// never apply the fixture exception; Firelink's own admission policy still
|
||||||
|
// rejects literal local targets before Aria2 is contacted.
|
||||||
|
export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({
|
||||||
|
...ARIA2_SYSTEM_RESOLVER_OPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function assertAria2Baseline(version) {
|
||||||
|
if (typeof version?.version !== 'string' || version.version.trim() === '') {
|
||||||
|
throw new Error(`aria2 returned an invalid baseline version response: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2RouteCapabilities(version) {
|
||||||
|
if (!Array.isArray(version?.enabledFeatures)
|
||||||
|
|| !version.enabledFeatures.includes('Async DNS')) {
|
||||||
|
throw new Error(`aria2 does not advertise asynchronous DNS: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
const expected = {
|
||||||
|
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||||
|
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
};
|
||||||
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
|
if (version?.[field] !== value) {
|
||||||
|
throw new Error(`aria2 route capability mismatch for ${field}: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(version.firelinkDnsResolvers)
|
||||||
|
|| !version.firelinkDnsResolvers.includes(ARIA2_DNS_RESOLVER)) {
|
||||||
|
throw new Error(`aria2 does not advertise the Firelink native resolver: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(version.firelinkNetworkTargetPolicies)
|
||||||
|
|| !version.firelinkNetworkTargetPolicies.includes(ARIA2_NETWORK_TARGET_POLICY)) {
|
||||||
|
throw new Error(`aria2 does not advertise the Firelink target policy: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAria2RouteCapabilities(version) {
|
||||||
|
try {
|
||||||
|
assertAria2RouteCapabilities(version);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2SystemResolverOptions(options, context = 'aria2 transfer') {
|
||||||
|
if (options?.['async-dns'] !== 'false') {
|
||||||
|
throw new Error(`${context} did not retain async-dns=false: ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(options || {}, 'dns-resolver')
|
||||||
|
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER) {
|
||||||
|
throw new Error(`${context} retained an unknown resolver mode: ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(options || {}, 'network-target-policy')
|
||||||
|
&& options['network-target-policy'] !== 'none') {
|
||||||
|
throw new Error(`${context} retained an active target policy: ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2RouteContract(version) {
|
||||||
|
assertAria2RouteCapabilities(version);
|
||||||
|
const expected = {
|
||||||
|
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||||
|
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
|
||||||
|
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
};
|
||||||
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
|
if (version?.[field] !== value) {
|
||||||
|
throw new Error(`aria2 route contract mismatch for ${field}: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version.firelinkNetworkTargetPolicyEnforced !== true) {
|
||||||
|
throw new Error(`aria2 is not enforcing the Firelink network target policy: ${JSON.stringify(version)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2RouteOptions(options, context = 'aria2 transfer') {
|
||||||
|
for (const [field, value] of Object.entries(ARIA2_ROUTE_OPTIONS)) {
|
||||||
|
if (options?.[field] !== value) {
|
||||||
|
throw new Error(`${context} did not retain ${field}=${value}: ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2RouteSource(source, target) {
|
||||||
|
const contract = source?.firelinkRouteContract;
|
||||||
|
const expected = {
|
||||||
|
revision: ARIA2_FIRELINK_REVISION,
|
||||||
|
dnsResolver: ARIA2_DNS_RESOLVER,
|
||||||
|
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
};
|
||||||
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
|
if (contract?.[field] !== value) {
|
||||||
|
throw new Error(
|
||||||
|
`aria2c source for ${target} is not a Firelink route-contract build; `
|
||||||
|
+ `expected firelinkRouteContract.${field}=${value}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAria2AllocationCapabilities(version) {
|
||||||
|
if (version?.firelinkAllocationTelemetry !== true) {
|
||||||
|
throw new Error('Bundled Aria2 does not expose file allocation telemetry');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assertAria2AllocationCapabilities,
|
||||||
|
ARIA2_DNS_RESOLVER,
|
||||||
|
ARIA2_FIRELINK_REVISION,
|
||||||
|
ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
assertAria2SystemResolverOptions,
|
||||||
|
assertAria2RouteCapabilities,
|
||||||
|
assertAria2RouteContract,
|
||||||
|
assertAria2RouteSource,
|
||||||
|
} from './aria2-route-contract.js';
|
||||||
|
|
||||||
|
const secureVersion = {
|
||||||
|
enabledFeatures: ['Async DNS'],
|
||||||
|
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||||
|
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
|
||||||
|
firelinkDnsResolvers: [ARIA2_DNS_RESOLVER],
|
||||||
|
firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY],
|
||||||
|
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
firelinkNetworkTargetPolicyEnforced: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Aria2 source metadata must identify the Firelink route contract', () => {
|
||||||
|
const source = {
|
||||||
|
firelinkRouteContract: {
|
||||||
|
revision: ARIA2_FIRELINK_REVISION,
|
||||||
|
dnsResolver: ARIA2_DNS_RESOLVER,
|
||||||
|
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => assertAria2RouteSource(source, 'test-target'));
|
||||||
|
assert.throws(
|
||||||
|
() => assertAria2RouteSource({}, 'test-target'),
|
||||||
|
/not a Firelink route-contract build/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertAria2RouteSource({
|
||||||
|
firelinkRouteContract: {
|
||||||
|
...source.firelinkRouteContract,
|
||||||
|
networkTargetPolicyDigest: 'sha256:wrong',
|
||||||
|
},
|
||||||
|
}, 'test-target'),
|
||||||
|
/networkTargetPolicyDigest/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route capabilities are distinct from the active local-fixture policy', () => {
|
||||||
|
assert.doesNotThrow(() => assertAria2RouteCapabilities({
|
||||||
|
...secureVersion,
|
||||||
|
firelinkNetworkTargetPolicy: 'none',
|
||||||
|
firelinkNetworkTargetPolicyEnforced: false,
|
||||||
|
}));
|
||||||
|
assert.doesNotThrow(() => assertAria2RouteContract(secureVersion));
|
||||||
|
assert.throws(
|
||||||
|
() => assertAria2RouteContract({
|
||||||
|
...secureVersion,
|
||||||
|
firelinkNetworkTargetPolicy: 'none',
|
||||||
|
firelinkNetworkTargetPolicyEnforced: false,
|
||||||
|
}),
|
||||||
|
/route contract mismatch for firelinkNetworkTargetPolicy/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('system resolver options cannot retain the custom target policy', () => {
|
||||||
|
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
|
||||||
|
'async-dns': 'false',
|
||||||
|
}));
|
||||||
|
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
|
||||||
|
'async-dns': 'false',
|
||||||
|
'network-target-policy': 'none',
|
||||||
|
}));
|
||||||
|
assert.throws(
|
||||||
|
() => assertAria2SystemResolverOptions({
|
||||||
|
'async-dns': 'false',
|
||||||
|
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
}),
|
||||||
|
/active target policy/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allocation telemetry is mandatory and must be a JSON boolean capability', () => {
|
||||||
|
assert.throws(() => assertAria2AllocationCapabilities({ version: '1.37.0' }));
|
||||||
|
assert.throws(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: 'true' }));
|
||||||
|
assert.doesNotThrow(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: true }));
|
||||||
|
});
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Build from the checksum-pinned upstream archive plus the reviewed patch.
|
||||||
|
source_root="$1"
|
||||||
|
patch_file="$2"
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
source_root="$(cygpath -u "$source_root")"
|
||||||
|
patch_file="$(cygpath -u "$patch_file")"
|
||||||
|
export PATH="/mingw64/bin:/usr/bin:$PATH"
|
||||||
|
export ACLOCAL_PATH="/mingw64/share/aclocal:/usr/share/aclocal${ACLOCAL_PATH:+:$ACLOCAL_PATH}"
|
||||||
|
export PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
|
||||||
|
fi
|
||||||
|
|
||||||
|
copy_mingw_runtime_dependencies() {
|
||||||
|
local binary="$1"
|
||||||
|
local runtime_dir="$2"
|
||||||
|
local dependency
|
||||||
|
local source
|
||||||
|
local destination
|
||||||
|
|
||||||
|
while IFS= read -r dependency; do
|
||||||
|
[[ -z "$dependency" ]] && continue
|
||||||
|
source="/mingw64/bin/$dependency"
|
||||||
|
if [[ ! -f "$source" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
destination="$runtime_dir/$dependency"
|
||||||
|
if [[ -e "$destination" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
cp "$source" "$destination"
|
||||||
|
copy_mingw_runtime_dependencies "$destination" "$runtime_dir"
|
||||||
|
done < <(objdump -p "$binary" | awk '/DLL Name:/{print $3}')
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$source_root"
|
||||||
|
patch --batch -p1 < "$patch_file"
|
||||||
|
autoreconf -fi
|
||||||
|
mkdir firelink-build
|
||||||
|
cd firelink-build
|
||||||
|
# Linux and Windows payloads are self-contained; do not inherit host dylibs.
|
||||||
|
export LDFLAGS="-static ${LDFLAGS:-}"
|
||||||
|
export PKG_CONFIG="pkg-config --static"
|
||||||
|
../configure --enable-static --disable-shared --disable-nls \
|
||||||
|
--without-gnutls --with-openssl --without-libxml2 --with-libexpat \
|
||||||
|
--without-libgmp --without-libnettle --without-libgcrypt \
|
||||||
|
--with-libssh2 --with-libcares
|
||||||
|
if command -v nproc >/dev/null 2>&1; then
|
||||||
|
JOBS="$(nproc 2>/dev/null || echo 4)"
|
||||||
|
elif [[ -n "${NUMBER_OF_PROCESSORS:-}" ]]; then
|
||||||
|
JOBS="$NUMBER_OF_PROCESSORS"
|
||||||
|
elif command -v sysctl >/dev/null 2>&1; then
|
||||||
|
JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)"
|
||||||
|
else
|
||||||
|
JOBS=4
|
||||||
|
fi
|
||||||
|
JOBS="${JOBS//$'\r'/}"
|
||||||
|
JOBS="${JOBS// /}"
|
||||||
|
if ! [[ "$JOBS" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
JOBS=4
|
||||||
|
fi
|
||||||
|
make -j"$JOBS"
|
||||||
|
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
command -v objdump >/dev/null 2>&1 || {
|
||||||
|
echo "MinGW objdump is required to collect Aria2 runtime dependencies." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
runtime_dir="$source_root/aria2-libs"
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
copy_mingw_runtime_dependencies "$source_root/firelink-build/src/aria2c.exe" "$runtime_dir"
|
||||||
|
if [[ -d /mingw64/lib/ossl-modules ]]; then
|
||||||
|
find /mingw64/lib/ossl-modules -maxdepth 1 -type f -iname '*.dll' -exec cp {} "$runtime_dir/" \;
|
||||||
|
fi
|
||||||
|
fi
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { resolveOutputRoot, resolveTargetTriple } from './engine-workspace.js';
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.FIRELINK_SKIP_ENGINE_RESOURCE === '1'
|
||||||
|
|| process.env.FIRELINK_ENGINE_BUNDLE_PREPARED === '1'
|
||||||
|
) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staging belongs to beforeBuildCommand or the standalone-bundle wrapper.
|
||||||
|
// This hook is a read-only fence against a missing payload immediately before
|
||||||
|
// Tauri consumes the resource tree.
|
||||||
|
const target = resolveTargetTriple();
|
||||||
|
const outputRoot = resolveOutputRoot();
|
||||||
|
const suffix = target.includes('windows') ? '.exe' : '';
|
||||||
|
const destination = path.join(outputRoot, target);
|
||||||
|
const expectedNames = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']
|
||||||
|
.map(engine => `${engine}-${target}${suffix}`);
|
||||||
|
|
||||||
|
for (const name of expectedNames) {
|
||||||
|
const candidate = path.join(destination, name);
|
||||||
|
if (!fs.existsSync(candidate) || !fs.lstatSync(candidate).isFile()) {
|
||||||
|
throw new Error(`Prepared engine payload is incomplete: ${candidate}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Prepared engine payload is present for ${target} at ${destination}`);
|
||||||
@@ -93,10 +93,10 @@ function run(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runChecked(command, args, label = command) {
|
async function runChecked(command, args, label = command, options = {}) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await run(command, args);
|
result = await run(command, args, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
|
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
|
||||||
}
|
}
|
||||||
@@ -130,13 +130,14 @@ async function main() {
|
|||||||
assertSafeTarget(target);
|
assertSafeTarget(target);
|
||||||
|
|
||||||
// The native-package build has already staged and verified the engines.
|
// The native-package build has already staged and verified the engines.
|
||||||
// Verify once more before creating the AppImage so a failed preparation
|
// Verify the immutable provisioned payload once more before creating the
|
||||||
// cannot produce an artifact that later appears valid only because its
|
// AppImage so a failed preparation cannot produce an artifact that later
|
||||||
// payload is absent.
|
// appears valid only because its payload is absent.
|
||||||
|
const provisionedRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||||
await runChecked(
|
await runChecked(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
['scripts/verify-binaries.js', '--staged', '--target', target],
|
['scripts/verify-binaries.js', '--root', provisionedRoot, '--target', target],
|
||||||
'staged engine verification'
|
'provisioned engine verification'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (receivedSignal) {
|
if (receivedSignal) {
|
||||||
@@ -146,7 +147,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
|
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
|
||||||
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling');
|
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling', {
|
||||||
|
env: { FIRELINK_SKIP_ENGINE_RESOURCE: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
if (receivedSignal) {
|
if (receivedSignal) {
|
||||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||||
|
|||||||
+188
-49
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ const repoRoot = path.resolve(__dirname, '..');
|
|||||||
const userAgent = 'firelink-update-check';
|
const userAgent = 'firelink-update-check';
|
||||||
const fetchRetryDelaysMs = [250, 1_000];
|
const fetchRetryDelaysMs = [250, 1_000];
|
||||||
const fetchTimeoutMs = 30_000;
|
const fetchTimeoutMs = 30_000;
|
||||||
|
const cargoOutputLimit = 64 * 1024 * 1024;
|
||||||
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||||
|
|
||||||
function httpResponseError(response, url) {
|
function httpResponseError(response, url) {
|
||||||
@@ -158,32 +160,39 @@ async function latestFfmpegStable() {
|
|||||||
async function latestMartinRiedlMacArm64Release() {
|
async function latestMartinRiedlMacArm64Release() {
|
||||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||||
const releaseSection = html.split('Download Release Build')[1] || '';
|
const releaseSection = html.split('Download Release Build')[1] || '';
|
||||||
const match =
|
const card = releaseSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
|
const version =
|
||||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
|
card.match(/<b>Release:\s*<\/b>\s*([0-9.]+)/)?.[1] ||
|
||||||
return match?.[1];
|
card.match(/Release:\s*([0-9.]+)/)?.[1];
|
||||||
|
const relativeUrl = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
||||||
|
if (!version || !relativeUrl) return undefined;
|
||||||
|
const url = new URL(relativeUrl, 'https://ffmpeg.martin-riedl.de').href;
|
||||||
|
const checksum = await fetchText(`${url}.sha256`);
|
||||||
|
const sha256 = checksum.match(/\b([0-9a-f]{64})\b/i)?.[1]?.toLowerCase();
|
||||||
|
return sha256 ? { version, url, sha256 } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function latestMartinRiedlMacArm64Snapshot() {
|
function escapeRegExp(value) {
|
||||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
|
|
||||||
const card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
|
||||||
const match =
|
|
||||||
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
|
||||||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
|
|
||||||
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
|
||||||
return match?.[1]
|
|
||||||
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function latestBtbnFfmpegN81Build() {
|
async function latestBtbnFfmpegStableBuild(stableVersion) {
|
||||||
|
if (!/^\d+\.\d+(?:\.\d+)?$/.test(stableVersion)) {
|
||||||
|
throw new Error(`unsupported FFmpeg stable version: ${stableVersion}`);
|
||||||
|
}
|
||||||
|
const stableSeries = stableVersion.split('.').slice(0, 2).join('.');
|
||||||
|
const versionPattern = escapeRegExp(stableVersion);
|
||||||
|
const seriesPattern = escapeRegExp(stableSeries);
|
||||||
|
const assetPattern = new RegExp(
|
||||||
|
`^ffmpeg-n(${versionPattern}(?:-\\d+-g[0-9a-f]+)?)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$`
|
||||||
|
);
|
||||||
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
|
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
|
||||||
|
if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array');
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
if (release.tag_name === 'latest') continue;
|
if (release.tag_name === 'latest') continue;
|
||||||
const assets = (release.assets || [])
|
const assets = (release.assets || [])
|
||||||
.map(asset => {
|
.map(asset => {
|
||||||
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
|
const match = asset.name.match(assetPattern);
|
||||||
if (!match) return undefined;
|
if (!match) return undefined;
|
||||||
return {
|
return {
|
||||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||||
@@ -210,6 +219,110 @@ async function latestBtbnFfmpegN81Build() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cargoPackages(metadata) {
|
||||||
|
if (!metadata || !Array.isArray(metadata.packages)) {
|
||||||
|
throw new Error('Cargo metadata contained no package list');
|
||||||
|
}
|
||||||
|
return metadata.packages.map(pkg => ({
|
||||||
|
name: pkg.name,
|
||||||
|
version: pkg.version,
|
||||||
|
source: pkg.source || 'path',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffCargoMetadata(currentMetadata, updatedMetadata) {
|
||||||
|
const current = cargoPackages(currentMetadata);
|
||||||
|
const updated = cargoPackages(updatedMetadata);
|
||||||
|
const groups = new Map();
|
||||||
|
for (const [side, packages] of [['current', current], ['updated', updated]]) {
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const key = `${pkg.name}\0${pkg.source}`;
|
||||||
|
const group = groups.get(key) || { name: pkg.name, source: pkg.source, current: [], updated: [] };
|
||||||
|
group[side].push(pkg.version);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
const oldVersions = [...group.current];
|
||||||
|
const newVersions = [...group.updated];
|
||||||
|
for (let index = oldVersions.length - 1; index >= 0; index -= 1) {
|
||||||
|
const unchangedIndex = newVersions.indexOf(oldVersions[index]);
|
||||||
|
if (unchangedIndex >= 0) {
|
||||||
|
oldVersions.splice(index, 1);
|
||||||
|
newVersions.splice(unchangedIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldVersions.sort(compareVersions);
|
||||||
|
newVersions.sort(compareVersions);
|
||||||
|
for (let index = 0; index < Math.min(oldVersions.length, newVersions.length); index += 1) {
|
||||||
|
changes.push({
|
||||||
|
name: group.name,
|
||||||
|
version: oldVersions[index],
|
||||||
|
latest: newVersions[index],
|
||||||
|
source: group.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const version of oldVersions.slice(newVersions.length)) {
|
||||||
|
changes.push({
|
||||||
|
name: group.name,
|
||||||
|
version,
|
||||||
|
latest: null,
|
||||||
|
source: group.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const latest of newVersions.slice(oldVersions.length)) {
|
||||||
|
changes.push({
|
||||||
|
name: group.name,
|
||||||
|
version: null,
|
||||||
|
latest,
|
||||||
|
source: group.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargoMetadata(manifestPath) {
|
||||||
|
return JSON.parse(execFileSync('cargo', [
|
||||||
|
'metadata', '--format-version', '1', '--locked', '--manifest-path', manifestPath,
|
||||||
|
], { encoding: 'utf8', maxBuffer: cargoOutputLimit, stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargoCompatibleUpdates() {
|
||||||
|
const sourceDir = path.join(repoRoot, 'src-tauri');
|
||||||
|
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-cargo-update-'));
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(path.join(sourceDir, 'Cargo.toml'), path.join(temporaryRoot, 'Cargo.toml'));
|
||||||
|
fs.copyFileSync(path.join(sourceDir, 'Cargo.lock'), path.join(temporaryRoot, 'Cargo.lock'));
|
||||||
|
fs.mkdirSync(path.join(temporaryRoot, 'src'));
|
||||||
|
fs.writeFileSync(path.join(temporaryRoot, 'src', 'lib.rs'), '');
|
||||||
|
const manifestPath = path.join(temporaryRoot, 'Cargo.toml');
|
||||||
|
const current = cargoMetadata(path.join(sourceDir, 'Cargo.toml'));
|
||||||
|
execFileSync('cargo', ['update', '--manifest-path', manifestPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: cargoOutputLimit,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return diffCargoMetadata(current, cargoMetadata(manifestPath));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCargoReport(updates) {
|
||||||
|
if (!updates.length) {
|
||||||
|
console.log('Rust Cargo: current');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
console.log(`Rust Cargo: ${updates.length} compatible locked package update(s)`);
|
||||||
|
for (const update of updates) {
|
||||||
|
console.log(` ${update.name}: ${update.version ?? '(absent)'} -> ${update.latest ?? '(removed)'}`);
|
||||||
|
}
|
||||||
|
return updates.length;
|
||||||
|
}
|
||||||
|
|
||||||
function printNpmReport(label, outdated) {
|
function printNpmReport(label, outdated) {
|
||||||
const entries = Object.entries(outdated);
|
const entries = Object.entries(outdated);
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
@@ -227,7 +340,14 @@ function sourceEngineVersions(sourceLock) {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
|
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
|
||||||
for (const [engine, meta] of Object.entries(engines)) {
|
for (const [engine, meta] of Object.entries(engines)) {
|
||||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
rows.push({
|
||||||
|
target,
|
||||||
|
engine,
|
||||||
|
version: meta.version,
|
||||||
|
url: meta.url,
|
||||||
|
sha256: meta.sha256,
|
||||||
|
sourceSha256: meta.sourceSha256,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -237,7 +357,14 @@ function packagedEngineVersions(engineLock) {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||||
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
||||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
rows.push({
|
||||||
|
target,
|
||||||
|
engine,
|
||||||
|
version: meta.version,
|
||||||
|
url: meta.url,
|
||||||
|
sha256: meta.sha256,
|
||||||
|
sourceSha256: meta.sourceSha256,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -276,7 +403,8 @@ function checkRows(
|
|||||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||||
const latestHash = latestHashesByTargetEngine[targetKey] || latestHashesByUrl[row.url];
|
const latestHash = latestHashesByTargetEngine[targetKey] || latestHashesByUrl[row.url];
|
||||||
const currentHash = typeof row.sha256 === 'string' ? row.sha256.toLowerCase() : '';
|
const checkedHash = row.sourceSha256 || row.sha256;
|
||||||
|
const currentHash = typeof checkedHash === 'string' ? checkedHash.toLowerCase() : '';
|
||||||
const hashOutdated = Boolean(latestHash && currentHash !== latestHash);
|
const hashOutdated = Boolean(latestHash && currentHash !== latestHash);
|
||||||
const status = versionOutdated
|
const status = versionOutdated
|
||||||
? 'outdated'
|
? 'outdated'
|
||||||
@@ -288,7 +416,7 @@ function checkRows(
|
|||||||
if (status !== 'current') outdated += 1;
|
if (status !== 'current') outdated += 1;
|
||||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||||
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
||||||
if (hashOutdated) console.log(` sha256: ${row.sha256 || 'missing'} -> ${latestHash}`);
|
if (hashOutdated) console.log(` source sha256: ${checkedHash || 'missing'} -> ${latestHash}`);
|
||||||
}
|
}
|
||||||
return outdated;
|
return outdated;
|
||||||
}
|
}
|
||||||
@@ -301,7 +429,9 @@ async function main() {
|
|||||||
'Browser extension npm',
|
'Browser extension npm',
|
||||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||||
);
|
);
|
||||||
|
outdatedCount += printCargoReport(cargoCompatibleUpdates());
|
||||||
|
|
||||||
|
const ffmpegStablePromise = latestFfmpegStable();
|
||||||
const providerChecks = [
|
const providerChecks = [
|
||||||
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
|
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
|
||||||
['Deno latest release', () => githubLatest('denoland/deno')],
|
['Deno latest release', () => githubLatest('denoland/deno')],
|
||||||
@@ -309,7 +439,7 @@ async function main() {
|
|||||||
[
|
[
|
||||||
'FFmpeg stable release',
|
'FFmpeg stable release',
|
||||||
async () => {
|
async () => {
|
||||||
const version = await latestFfmpegStable();
|
const version = await ffmpegStablePromise;
|
||||||
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
||||||
return version;
|
return version;
|
||||||
},
|
},
|
||||||
@@ -317,17 +447,15 @@ async function main() {
|
|||||||
[
|
[
|
||||||
'Martin Riedl macOS release',
|
'Martin Riedl macOS release',
|
||||||
async () => {
|
async () => {
|
||||||
const version = await latestMartinRiedlMacArm64Release();
|
const build = await latestMartinRiedlMacArm64Release();
|
||||||
if (!version) throw new Error('Martin Riedl macOS release provider response has no usable version');
|
const stableVersion = await ffmpegStablePromise;
|
||||||
return version;
|
if (
|
||||||
},
|
!build?.version ||
|
||||||
],
|
!build.url ||
|
||||||
[
|
!build.sha256 ||
|
||||||
'Martin Riedl macOS snapshot',
|
compareVersions(build.version, stableVersion) !== 0
|
||||||
async () => {
|
) {
|
||||||
const build = await latestMartinRiedlMacArm64Snapshot();
|
throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build');
|
||||||
if (!build?.version || !build.url) {
|
|
||||||
throw new Error('Martin Riedl FFmpeg provider response has no complete macOS arm64 snapshot');
|
|
||||||
}
|
}
|
||||||
return build;
|
return build;
|
||||||
},
|
},
|
||||||
@@ -335,7 +463,7 @@ async function main() {
|
|||||||
[
|
[
|
||||||
'BtbN FFmpeg Windows/Linux build',
|
'BtbN FFmpeg Windows/Linux build',
|
||||||
async () => {
|
async () => {
|
||||||
const build = await latestBtbnFfmpegN81Build();
|
const build = await latestBtbnFfmpegStableBuild(await ffmpegStablePromise);
|
||||||
if (
|
if (
|
||||||
!build?.version ||
|
!build?.version ||
|
||||||
!build.urls?.windows ||
|
!build.urls?.windows ||
|
||||||
@@ -365,8 +493,8 @@ async function main() {
|
|||||||
const deno = providerValue(1);
|
const deno = providerValue(1);
|
||||||
const aria2 = providerValue(2);
|
const aria2 = providerValue(2);
|
||||||
const ffmpeg = providerValue(3);
|
const ffmpeg = providerValue(3);
|
||||||
const martinRiedlMacArm64Snapshot = providerValue(5);
|
const martinRiedlMacArm64Release = providerValue(4);
|
||||||
const btbnFfmpegN81Build = providerValue(6);
|
const btbnFfmpegStableBuild = providerValue(5);
|
||||||
const latestByEngine = {
|
const latestByEngine = {
|
||||||
'yt-dlp': ytDlp?.tag_name,
|
'yt-dlp': ytDlp?.tag_name,
|
||||||
deno: deno?.tag_name,
|
deno: deno?.tag_name,
|
||||||
@@ -377,17 +505,18 @@ async function main() {
|
|||||||
const latestUrlsByTargetEngine = {};
|
const latestUrlsByTargetEngine = {};
|
||||||
const latestHashesByTargetEngine = {};
|
const latestHashesByTargetEngine = {};
|
||||||
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
|
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
|
||||||
if (btbnFfmpegN81Build?.version && btbnFfmpegN81Build.urls?.windows && btbnFfmpegN81Build.urls?.linux) {
|
if (btbnFfmpegStableBuild?.version && btbnFfmpegStableBuild.urls?.windows && btbnFfmpegStableBuild.urls?.linux) {
|
||||||
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.version;
|
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||||
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.version;
|
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||||
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.urls.windows;
|
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.urls.windows;
|
||||||
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.urls.linux;
|
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.urls.linux;
|
||||||
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.hashes?.windows;
|
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.hashes?.windows;
|
||||||
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.hashes?.linux;
|
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.hashes?.linux;
|
||||||
}
|
}
|
||||||
if (martinRiedlMacArm64Snapshot?.version && martinRiedlMacArm64Snapshot.url) {
|
if (martinRiedlMacArm64Release?.version && martinRiedlMacArm64Release.url) {
|
||||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.version;
|
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.version;
|
||||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.url;
|
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.url;
|
||||||
|
latestHashesByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.sha256;
|
||||||
}
|
}
|
||||||
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
||||||
|
|
||||||
@@ -396,8 +525,8 @@ async function main() {
|
|||||||
console.log(` ${engine}: ${displayVersion(version)}`);
|
console.log(` ${engine}: ${displayVersion(version)}`);
|
||||||
}
|
}
|
||||||
console.log('\nlatest engine provider builds:');
|
console.log('\nlatest engine provider builds:');
|
||||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${displayVersion(btbnFfmpegN81Build?.version)}`);
|
console.log(` BtbN FFmpeg stable Windows/Linux: ${displayVersion(btbnFfmpegStableBuild?.version)}`);
|
||||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${displayVersion(martinRiedlMacArm64Snapshot?.version)}`);
|
console.log(` Martin Riedl FFmpeg macOS arm64 stable: ${displayVersion(martinRiedlMacArm64Release?.version)}`);
|
||||||
|
|
||||||
const targetSpecificEngines = new Set(['ffmpeg']);
|
const targetSpecificEngines = new Set(['ffmpeg']);
|
||||||
const engineCheckFailures = [];
|
const engineCheckFailures = [];
|
||||||
@@ -456,4 +585,14 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { checkRows, fetchJson, fetchText, fetchWithContext, npmExecutable, providerAssetHashes };
|
export {
|
||||||
|
checkRows,
|
||||||
|
diffCargoMetadata,
|
||||||
|
fetchJson,
|
||||||
|
fetchText,
|
||||||
|
fetchWithContext,
|
||||||
|
latestBtbnFfmpegStableBuild,
|
||||||
|
latestMartinRiedlMacArm64Release,
|
||||||
|
npmExecutable,
|
||||||
|
providerAssetHashes,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|
||||||
import { checkRows, fetchJson, fetchText, npmExecutable, providerAssetHashes } from './check-updates.js';
|
import {
|
||||||
|
checkRows,
|
||||||
|
diffCargoMetadata,
|
||||||
|
fetchJson,
|
||||||
|
fetchText,
|
||||||
|
latestBtbnFfmpegStableBuild,
|
||||||
|
latestMartinRiedlMacArm64Release,
|
||||||
|
npmExecutable,
|
||||||
|
providerAssetHashes,
|
||||||
|
} from './check-updates.js';
|
||||||
|
|
||||||
async function withMockFetch(mockFetch, callback) {
|
async function withMockFetch(mockFetch, callback) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -86,6 +95,26 @@ test('checkRows detects a provider hash change when version and URL are current'
|
|||||||
assert.equal(outdated, 1);
|
assert.equal(outdated, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('checkRows compares packaged source provenance without confusing the payload digest', () => {
|
||||||
|
const outdated = checkRows(
|
||||||
|
[{
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
engine: 'ffmpeg',
|
||||||
|
version: '9.0.1',
|
||||||
|
url: 'https://example.test/ffmpeg.zip',
|
||||||
|
sourceSha256: 'a'.repeat(64),
|
||||||
|
sha256: 'b'.repeat(64),
|
||||||
|
}],
|
||||||
|
{ ffmpeg: '9.0.1' },
|
||||||
|
{ 'aarch64-apple-darwin:ffmpeg': '9.0.1' },
|
||||||
|
{ 'aarch64-apple-darwin:ffmpeg': 'https://example.test/ffmpeg.zip' },
|
||||||
|
new Set(['ffmpeg']),
|
||||||
|
{ 'aarch64-apple-darwin:ffmpeg': 'a'.repeat(64) },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(outdated, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('checkRows detects an aria2 asset digest change when the provider supplies it', () => {
|
test('checkRows detects an aria2 asset digest change when the provider supplies it', () => {
|
||||||
const url = 'https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip';
|
const url = 'https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip';
|
||||||
const digest = 'b'.repeat(64);
|
const digest = 'b'.repeat(64);
|
||||||
@@ -117,3 +146,151 @@ test('npm executable selection uses the Windows command shim when needed', () =>
|
|||||||
assert.equal(npmExecutable('darwin'), 'npm');
|
assert.equal(npmExecutable('darwin'), 'npm');
|
||||||
assert.equal(npmExecutable('linux'), 'npm');
|
assert.equal(npmExecutable('linux'), 'npm');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selects a complete BtbN build for the current stable series', async () => {
|
||||||
|
const digest = value => `sha256:${value.repeat(64)}`;
|
||||||
|
const release = {
|
||||||
|
tag_name: 'autobuild-test',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
|
||||||
|
browser_download_url: 'https://example.test/windows.zip',
|
||||||
|
digest: digest('a'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-linux64-gpl-9.0.tar.xz',
|
||||||
|
browser_download_url: 'https://example.test/linux.tar.xz',
|
||||||
|
digest: digest('b'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip',
|
||||||
|
browser_download_url: 'https://example.test/old.zip',
|
||||||
|
digest: digest('c'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = await withMockFetch(
|
||||||
|
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||||
|
() => latestBtbnFfmpegStableBuild('9.0.1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.version, '9.0.1-11-ge47273f4d9');
|
||||||
|
assert.equal(result.urls.windows, 'https://example.test/windows.zip');
|
||||||
|
assert.equal(result.hashes.linux, 'b'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects a complete BtbN build for a two-part stable version and tag-exact assets', async () => {
|
||||||
|
const digest = value => `sha256:${value.repeat(64)}`;
|
||||||
|
const release = {
|
||||||
|
tag_name: 'autobuild-test',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-n9.0-win64-gpl-9.0.zip',
|
||||||
|
browser_download_url: 'https://example.test/windows-exact.zip',
|
||||||
|
digest: digest('e'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-n9.0-linux64-gpl-9.0.tar.xz',
|
||||||
|
browser_download_url: 'https://example.test/linux-exact.tar.xz',
|
||||||
|
digest: digest('f'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = await withMockFetch(
|
||||||
|
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||||
|
() => latestBtbnFfmpegStableBuild('9.0'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.version, '9.0');
|
||||||
|
assert.equal(result.urls.windows, 'https://example.test/windows-exact.zip');
|
||||||
|
assert.equal(result.hashes.linux, 'f'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an incomplete BtbN stable target tuple', async () => {
|
||||||
|
const release = {
|
||||||
|
tag_name: 'autobuild-test',
|
||||||
|
assets: [{
|
||||||
|
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
|
||||||
|
browser_download_url: 'https://example.test/windows.zip',
|
||||||
|
digest: `sha256:${'a'.repeat(64)}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const result = await withMockFetch(
|
||||||
|
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||||
|
() => latestBtbnFfmpegStableBuild('9.0.1'),
|
||||||
|
);
|
||||||
|
assert.equal(result, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires a complete Martin Riedl stable artifact and digest', async () => {
|
||||||
|
const html = `
|
||||||
|
<h2>Download Release Build</h2>
|
||||||
|
<div><h3>macOS (Apple Silicon/arm64)</h3>
|
||||||
|
<p><b>Release: </b>9.0.1</p>
|
||||||
|
<a href="/download/macos/arm64/build/ffmpeg.zip">FFmpeg (ZIP)</a></div>`;
|
||||||
|
const result = await withMockFetch(
|
||||||
|
async url => new Response(
|
||||||
|
String(url).endsWith('.sha256') ? `${'d'.repeat(64)} ffmpeg.zip\n` : html,
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
() => latestMartinRiedlMacArm64Release(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
version: '9.0.1',
|
||||||
|
url: 'https://ffmpeg.martin-riedl.de/download/macos/arm64/build/ffmpeg.zip',
|
||||||
|
sha256: 'd'.repeat(64),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports compatible Cargo resolution drift from structured metadata', () => {
|
||||||
|
const metadata = versions => ({
|
||||||
|
packages: Object.entries(versions).map(([name, version]) => ({
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
diffCargoMetadata(metadata({ indexmap: '2.14.1', serde: '1.0.229' }), metadata({
|
||||||
|
indexmap: '2.14.2',
|
||||||
|
serde: '1.0.229',
|
||||||
|
})),
|
||||||
|
[{
|
||||||
|
name: 'indexmap',
|
||||||
|
version: '2.14.1',
|
||||||
|
latest: '2.14.2',
|
||||||
|
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports packages added to or removed from the resolved Cargo graph', () => {
|
||||||
|
const metadata = packages => ({
|
||||||
|
packages: packages.map(([name, version]) => ({
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
diffCargoMetadata(
|
||||||
|
metadata([['removed-crate', '1.0.0'], ['stable-crate', '1.0.0']]),
|
||||||
|
metadata([['added-crate', '2.0.0'], ['stable-crate', '1.0.0']]),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'added-crate',
|
||||||
|
version: null,
|
||||||
|
latest: '2.0.0',
|
||||||
|
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'removed-crate',
|
||||||
|
version: '1.0.0',
|
||||||
|
latest: null,
|
||||||
|
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||||
|
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
|
||||||
|
|
||||||
|
export function getAria2BuildScriptSha256(repoRoot) {
|
||||||
|
const buildScriptPath = path.join(repoRoot, 'scripts/aria2/build.sh');
|
||||||
|
return crypto.createHash('sha256')
|
||||||
|
.update(fs.readFileSync(buildScriptPath, 'utf8').replaceAll('\r\n', '\n'))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
target,
|
||||||
|
aria2Source,
|
||||||
|
buildScriptSha256,
|
||||||
|
toolchainFingerprint = null,
|
||||||
|
executableSuffix = '',
|
||||||
|
}) {
|
||||||
|
const manifestPath = path.join(aria2CacheRoot, 'aria2-build-manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
return { valid: false, reason: 'manifest-not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
if (
|
||||||
|
manifest.schemaVersion !== 1
|
||||||
|
|| manifest.target !== target
|
||||||
|
|| manifest.sourceSha256 !== aria2Source.sha256
|
||||||
|
|| manifest.patchSha256 !== aria2Source.patchSha256
|
||||||
|
|| manifest.buildScriptSha256 !== buildScriptSha256
|
||||||
|
) {
|
||||||
|
return { valid: false, reason: 'manifest-metadata-mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
toolchainFingerprint !== null
|
||||||
|
&& manifest.toolchainFingerprint !== toolchainFingerprint
|
||||||
|
) {
|
||||||
|
return { valid: false, reason: 'toolchain-fingerprint-mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exeName = `aria2c-${target}${executableSuffix}`;
|
||||||
|
const cachedExe = path.join(aria2CacheRoot, exeName);
|
||||||
|
if (!fs.existsSync(cachedExe) || sha256(cachedExe) !== manifest.files?.[exeName]) {
|
||||||
|
return { valid: false, reason: 'executable-mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.files) {
|
||||||
|
for (const [rel, expectedSha] of Object.entries(manifest.files)) {
|
||||||
|
if (rel === exeName) continue;
|
||||||
|
const libFile = path.join(aria2CacheRoot, rel);
|
||||||
|
if (!fs.existsSync(libFile) || sha256(libFile) !== expectedSha) {
|
||||||
|
return { valid: false, reason: 'library-mismatch' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualFiles = collectRegularFiles(aria2CacheRoot, {
|
||||||
|
ignoredNames: ['aria2-build-manifest.json'],
|
||||||
|
}).map(f => path.relative(aria2CacheRoot, f).split(path.sep).join('/'));
|
||||||
|
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||||
|
actualFiles.sort();
|
||||||
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
|
return { valid: false, reason: 'file-list-mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, manifest };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, reason: 'manifest-corrupted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
payloadDestination,
|
||||||
|
target,
|
||||||
|
executableSuffix = '',
|
||||||
|
isWindows = false,
|
||||||
|
}) {
|
||||||
|
const exeName = `aria2c-${target}${executableSuffix}`;
|
||||||
|
const cachedExe = path.join(aria2CacheRoot, exeName);
|
||||||
|
const targetExe = path.join(payloadDestination, exeName);
|
||||||
|
fs.copyFileSync(cachedExe, targetExe);
|
||||||
|
if (!isWindows) fs.chmodSync(targetExe, 0o755);
|
||||||
|
|
||||||
|
const cachedLibs = path.join(aria2CacheRoot, 'aria2-libs');
|
||||||
|
if (fs.existsSync(cachedLibs)) {
|
||||||
|
fs.cpSync(cachedLibs, path.join(payloadDestination, 'aria2-libs'), {
|
||||||
|
recursive: true,
|
||||||
|
preserveTimestamps: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
payloadDestination,
|
||||||
|
target,
|
||||||
|
aria2Source,
|
||||||
|
buildScriptSha256,
|
||||||
|
toolchainFingerprint = null,
|
||||||
|
executableSuffix = '',
|
||||||
|
aria2Runtime = null,
|
||||||
|
isWindows = false,
|
||||||
|
}) {
|
||||||
|
const cacheParent = path.dirname(aria2CacheRoot);
|
||||||
|
fs.mkdirSync(cacheParent, { recursive: true });
|
||||||
|
|
||||||
|
const stagingDir = fs.mkdtempSync(
|
||||||
|
path.join(cacheParent, `.${path.basename(aria2CacheRoot)}-staging-${process.pid}-`)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetExeName = `aria2c-${target}${executableSuffix}`;
|
||||||
|
const cachedExeDest = path.join(stagingDir, targetExeName);
|
||||||
|
fs.copyFileSync(path.join(payloadDestination, targetExeName), cachedExeDest);
|
||||||
|
if (!isWindows) fs.chmodSync(cachedExeDest, 0o755);
|
||||||
|
|
||||||
|
const manifestFiles = {
|
||||||
|
[targetExeName]: sha256(cachedExeDest),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aria2Runtime && fs.existsSync(aria2Runtime)) {
|
||||||
|
const cachedLibsDest = path.join(stagingDir, 'aria2-libs');
|
||||||
|
fs.cpSync(aria2Runtime, cachedLibsDest, { recursive: true, preserveTimestamps: true });
|
||||||
|
const libFiles = collectRegularFiles(cachedLibsDest);
|
||||||
|
for (const lib of libFiles) {
|
||||||
|
const rel = path.relative(stagingDir, lib).split(path.sep).join('/');
|
||||||
|
manifestFiles[rel] = sha256(lib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
target,
|
||||||
|
sourceSha256: aria2Source.sha256,
|
||||||
|
patchSha256: aria2Source.patchSha256,
|
||||||
|
buildScriptSha256,
|
||||||
|
toolchainFingerprint: toolchainFingerprint || null,
|
||||||
|
files: manifestFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(stagingDir, 'aria2-build-manifest.json'),
|
||||||
|
`${JSON.stringify(cacheManifest, null, 2)}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
await promoteDirectory(stagingDir, aria2CacheRoot);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await removePathWithRetry(stagingDir);
|
||||||
|
} catch {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAria2BuildScriptSha256,
|
||||||
|
restoreAria2Cache,
|
||||||
|
saveAria2Cache,
|
||||||
|
validateAria2Cache,
|
||||||
|
} from './engine-aria2-cache.js';
|
||||||
|
import { sha256 } from './engine-payload-integrity.js';
|
||||||
|
|
||||||
|
const TARGET = 'x86_64-pc-windows-msvc';
|
||||||
|
const ARIA2_SOURCE = {
|
||||||
|
version: '1.37.0-firelink-native-dns-v1',
|
||||||
|
url: 'https://example.invalid/aria2.tar.xz',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
buildFromSource: true,
|
||||||
|
patch: 'scripts/aria2/firelink.patch',
|
||||||
|
patchSha256: 'b'.repeat(64),
|
||||||
|
allocationTelemetry: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTestWorkspace() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-cache-test-'));
|
||||||
|
const payloadDir = path.join(root, 'payload');
|
||||||
|
const cacheRoot = path.join(root, 'cache', TARGET);
|
||||||
|
fs.mkdirSync(payloadDir, { recursive: true });
|
||||||
|
|
||||||
|
const exeName = `aria2c-${TARGET}.exe`;
|
||||||
|
const exePath = path.join(payloadDir, exeName);
|
||||||
|
fs.writeFileSync(exePath, 'binary-content-for-testing');
|
||||||
|
|
||||||
|
const libsDir = path.join(payloadDir, 'aria2-libs');
|
||||||
|
fs.mkdirSync(libsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(libsDir, 'test.dll'), 'dll-content');
|
||||||
|
|
||||||
|
return { root, payloadDir, cacheRoot, exeName, exePath, libsDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validateAria2Cache fails closed when cache manifest is missing or invalid', () => {
|
||||||
|
const { root, cacheRoot } = createTestWorkspace();
|
||||||
|
try {
|
||||||
|
const missing = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: 'c'.repeat(64),
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(missing.valid, false);
|
||||||
|
assert.equal(missing.reason, 'manifest-not-found');
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(cacheRoot, 'aria2-build-manifest.json'), 'not json');
|
||||||
|
const corrupt = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: 'c'.repeat(64),
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(corrupt.valid, false);
|
||||||
|
assert.equal(corrupt.reason, 'manifest-corrupted');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveAria2Cache, validateAria2Cache, and restoreAria2Cache work end-to-end', async () => {
|
||||||
|
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
|
||||||
|
try {
|
||||||
|
const buildScriptSha = 'c'.repeat(64);
|
||||||
|
const toolchainFingerprint = 'toolchain-v1';
|
||||||
|
|
||||||
|
await saveAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
payloadDestination: payloadDir,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
toolchainFingerprint,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
aria2Runtime: libsDir,
|
||||||
|
isWindows: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const valid = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
toolchainFingerprint,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(valid.valid, true);
|
||||||
|
|
||||||
|
const wrongFingerprint = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
toolchainFingerprint: 'toolchain-v2',
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(wrongFingerprint.valid, false);
|
||||||
|
assert.equal(wrongFingerprint.reason, 'toolchain-fingerprint-mismatch');
|
||||||
|
|
||||||
|
const restoreDir = path.join(root, 'restored');
|
||||||
|
fs.mkdirSync(restoreDir, { recursive: true });
|
||||||
|
restoreAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
payloadDestination: restoreDir,
|
||||||
|
target: TARGET,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
isWindows: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
sha256(path.join(restoreDir, `aria2c-${TARGET}.exe`)),
|
||||||
|
sha256(path.join(payloadDir, `aria2c-${TARGET}.exe`))
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sha256(path.join(restoreDir, 'aria2-libs', 'test.dll')),
|
||||||
|
sha256(path.join(payloadDir, 'aria2-libs', 'test.dll'))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateAria2Cache rejects tampered files or rogue untracked files', async () => {
|
||||||
|
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
|
||||||
|
try {
|
||||||
|
const buildScriptSha = 'c'.repeat(64);
|
||||||
|
await saveAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
payloadDestination: payloadDir,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
aria2Runtime: libsDir,
|
||||||
|
isWindows: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tamper with cached exe
|
||||||
|
const exePath = path.join(cacheRoot, `aria2c-${TARGET}.exe`);
|
||||||
|
fs.appendFileSync(exePath, 'tampered');
|
||||||
|
const tamperedExe = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(tamperedExe.valid, false);
|
||||||
|
assert.equal(tamperedExe.reason, 'executable-mismatch');
|
||||||
|
|
||||||
|
// Restore exe, tamper with library
|
||||||
|
fs.writeFileSync(exePath, 'binary-content-for-testing');
|
||||||
|
const libPath = path.join(cacheRoot, 'aria2-libs', 'test.dll');
|
||||||
|
fs.appendFileSync(libPath, 'tampered');
|
||||||
|
const tamperedLib = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(tamperedLib.valid, false);
|
||||||
|
assert.equal(tamperedLib.reason, 'library-mismatch');
|
||||||
|
|
||||||
|
// Restore library, add rogue file
|
||||||
|
fs.writeFileSync(libPath, 'dll-content');
|
||||||
|
fs.writeFileSync(path.join(cacheRoot, 'rogue.txt'), 'rogue');
|
||||||
|
const rogueFile = validateAria2Cache({
|
||||||
|
aria2CacheRoot: cacheRoot,
|
||||||
|
target: TARGET,
|
||||||
|
aria2Source: ARIA2_SOURCE,
|
||||||
|
buildScriptSha256: buildScriptSha,
|
||||||
|
executableSuffix: '.exe',
|
||||||
|
});
|
||||||
|
assert.equal(rogueFile.valid, false);
|
||||||
|
assert.equal(rogueFile.reason, 'file-list-mismatch');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAria2BuildScriptSha256 reads and hashes build.sh with normalized line endings', () => {
|
||||||
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||||
|
const hash = getAria2BuildScriptSha256(repoRoot);
|
||||||
|
assert.equal(typeof hash, 'string');
|
||||||
|
assert.equal(hash.length, 64);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||||
|
const ciWorkflow = fs.readFileSync(
|
||||||
|
path.join(repositoryRoot, '.github', 'workflows', 'ci.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const releaseWorkflow = fs.readFileSync(
|
||||||
|
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const cacheActionSha = '1bd1e32a3bdc45362d1e726936510720a7c30a57';
|
||||||
|
|
||||||
|
function assertSafeEngineCacheWorkflow(workflow) {
|
||||||
|
assert.match(workflow, new RegExp(`actions/cache/restore@${cacheActionSha}`));
|
||||||
|
assert.match(workflow, /key: firelink-engine-payload-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.fingerprint \}\}/);
|
||||||
|
assert.match(workflow, /engine-sources\.lock\.json/);
|
||||||
|
assert.match(workflow, /scripts\/aria2\/\*\*/);
|
||||||
|
assert.match(workflow, /scripts\/engine-\*\.js/);
|
||||||
|
assert.match(workflow, /scripts\/verify-binaries\.js/);
|
||||||
|
assert.doesNotMatch(workflow, /restore-keys:/);
|
||||||
|
|
||||||
|
const restore = workflow.indexOf('actions/cache/restore@');
|
||||||
|
const validation = workflow.indexOf('id: engine-cache-validation');
|
||||||
|
const provision = workflow.indexOf('node scripts/provision-engines.js');
|
||||||
|
assert.ok(restore >= 0 && restore < validation && validation < provision);
|
||||||
|
assert.match(workflow, /continue-on-error: true/);
|
||||||
|
assert.match(workflow, /FIRELINK_TARGET_TRIPLE: \$\{\{ matrix\.target \}\}/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CI and release restore only exact, validated engine payload caches', () => {
|
||||||
|
assertSafeEngineCacheWorkflow(ciWorkflow);
|
||||||
|
assertSafeEngineCacheWorkflow(releaseWorkflow);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only trusted main pushes save the shared engine cache', () => {
|
||||||
|
assert.match(ciWorkflow, new RegExp(`actions/cache/save@${cacheActionSha}`));
|
||||||
|
const save = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified engine payload cache'));
|
||||||
|
assert.match(save, /github\.event_name == 'push'/);
|
||||||
|
assert.match(save, /github\.ref == 'refs\/heads\/main'/);
|
||||||
|
assert.doesNotMatch(releaseWorkflow, /actions\/cache\/save@/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI and release use granular Aria2 build caching and safe timeouts', () => {
|
||||||
|
assert.match(ciWorkflow, /timeout-minutes: (?:4[5-9]|[5-9][0-9])/);
|
||||||
|
assert.match(ciWorkflow, /uses: Swatinem\/rust-cache@v2/);
|
||||||
|
assert.match(ciWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
|
||||||
|
assert.match(releaseWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
|
||||||
|
const saveAria2 = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified Aria2 build cache'));
|
||||||
|
assert.match(saveAria2, /github\.event_name == 'push'/);
|
||||||
|
assert.match(saveAria2, /github\.ref == 'refs\/heads\/main'/);
|
||||||
|
});
|
||||||
@@ -225,3 +225,32 @@ test('propagates external cancellation without retrying an in-flight archive', a
|
|||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects and removes an archive with a mismatched checksum', async () => {
|
||||||
|
const { directory, archive } = makeArchivePath();
|
||||||
|
const corrupt = Buffer.from('corrupt engine archive');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withMockFetch(
|
||||||
|
async () => new Response(corrupt, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Length': String(corrupt.length) },
|
||||||
|
}),
|
||||||
|
async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
downloadEngineArchive({
|
||||||
|
name: 'ffmpeg',
|
||||||
|
url: 'https://example.test/ffmpeg.zip',
|
||||||
|
archive,
|
||||||
|
expectedSha256: digest(Buffer.from('trusted engine archive')),
|
||||||
|
attempts: 1,
|
||||||
|
}),
|
||||||
|
/Archive checksum mismatch for ffmpeg/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(archive), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||||
|
|
||||||
|
function canonicalize(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(canonicalize);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map(key => [key, canonicalize(value[key])]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPayloadProvenance(targetSources) {
|
||||||
|
if (!targetSources || typeof targetSources !== 'object') {
|
||||||
|
throw new Error('Engine source lock is missing the target provenance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(targetSources).map(([name, source]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
version: source.version,
|
||||||
|
url: source.url || source.sourceUrl,
|
||||||
|
sha256: source.sha256 || source.sourceSha256,
|
||||||
|
...(source.buildFromSource === true
|
||||||
|
? {
|
||||||
|
patchSha256: source.patchSha256,
|
||||||
|
allocationTelemetry: source.allocationTelemetry === true,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(name === 'aria2c' && source.firelinkRouteContract
|
||||||
|
? { firelinkRouteContract: source.firelinkRouteContract }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPayloadManifestProvenance(manifest, targetSources, target) {
|
||||||
|
if (manifest?.schemaVersion !== 1) {
|
||||||
|
throw new Error(`Unsupported engine payload manifest schema for ${target}.`);
|
||||||
|
}
|
||||||
|
if (manifest.target !== target) {
|
||||||
|
throw new Error(`Engine payload manifest target mismatch for ${target}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = buildPayloadProvenance(targetSources);
|
||||||
|
if (JSON.stringify(canonicalize(manifest.generatedFrom))
|
||||||
|
!== JSON.stringify(canonicalize(expected))) {
|
||||||
|
throw new Error(`Engine payload manifest provenance mismatch for ${target}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveManifestFile(root, relative) {
|
||||||
|
if (typeof relative !== 'string' || relative.length === 0) {
|
||||||
|
throw new Error('Engine payload manifest contains an invalid file path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedRoot = path.resolve(root);
|
||||||
|
const candidate = path.resolve(resolvedRoot, relative);
|
||||||
|
const relativeToRoot = path.relative(resolvedRoot, candidate);
|
||||||
|
if (
|
||||||
|
relativeToRoot === '..'
|
||||||
|
|| relativeToRoot.startsWith(`..${path.sep}`)
|
||||||
|
|| path.isAbsolute(relativeToRoot)
|
||||||
|
) {
|
||||||
|
throw new Error(`Engine payload manifest escapes its root: ${relative}.`);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAndValidatePayloadManifest(root, targetSources, target) {
|
||||||
|
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
throw new Error(`Engine payload manifest is missing for ${target}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Engine payload manifest is invalid for ${target}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPayloadManifestProvenance(manifest, targetSources, target);
|
||||||
|
if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
|
||||||
|
throw new Error(`Engine payload manifest files are invalid for ${target}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFiles = Object.keys(manifest.files).sort();
|
||||||
|
const resolvedFiles = new Map(
|
||||||
|
expectedFiles.map(relative => [relative, resolveManifestFile(root, relative)]),
|
||||||
|
);
|
||||||
|
const actualFiles = collectRegularFiles(root, {
|
||||||
|
ignoredNames: ['payload-manifest.json'],
|
||||||
|
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
|
||||||
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
|
throw new Error(`Engine payload files do not match the manifest for ${target}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relative of expectedFiles) {
|
||||||
|
const expected = manifest.files[relative];
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(expected)) {
|
||||||
|
throw new Error(`Engine payload manifest checksum is invalid: ${relative}.`);
|
||||||
|
}
|
||||||
|
const file = resolvedFiles.get(relative);
|
||||||
|
if (!fs.statSync(file).isFile() || sha256(file) !== expected) {
|
||||||
|
throw new Error(`Engine payload manifest checksum mismatch: ${relative}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertPayloadManifestProvenance,
|
||||||
|
buildPayloadProvenance,
|
||||||
|
readAndValidatePayloadManifest,
|
||||||
|
} from './engine-payload-manifest.js';
|
||||||
|
import { sha256 } from './engine-payload-integrity.js';
|
||||||
|
|
||||||
|
const TARGET = 'x86_64-unknown-linux-gnu';
|
||||||
|
const SOURCES = {
|
||||||
|
'yt-dlp': {
|
||||||
|
version: '2026.08.19',
|
||||||
|
url: 'https://example.invalid/yt-dlp.zip',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
},
|
||||||
|
deno: {
|
||||||
|
version: '2.9.6',
|
||||||
|
url: 'https://example.invalid/deno.zip',
|
||||||
|
sha256: 'b'.repeat(64),
|
||||||
|
},
|
||||||
|
ffmpeg: {
|
||||||
|
version: '9.0.1',
|
||||||
|
url: 'https://example.invalid/ffmpeg.tar.xz',
|
||||||
|
sha256: 'c'.repeat(64),
|
||||||
|
},
|
||||||
|
aria2c: {
|
||||||
|
version: '1.37.0-firelink-native-dns-v1',
|
||||||
|
url: 'https://example.invalid/aria2.tar.xz',
|
||||||
|
sha256: 'd'.repeat(64),
|
||||||
|
buildFromSource: true,
|
||||||
|
patchSha256: 'e'.repeat(64),
|
||||||
|
allocationTelemetry: true,
|
||||||
|
firelinkRouteContract: {
|
||||||
|
revision: 'firelink-native-dns-v1',
|
||||||
|
dnsResolver: 'native-async',
|
||||||
|
networkTargetPolicy: 'firelink-v1',
|
||||||
|
networkTargetPolicyDigest: 'sha256:test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPayload() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-manifest-'));
|
||||||
|
const file = path.join(root, 'aria2c');
|
||||||
|
fs.writeFileSync(file, 'verified engine');
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
target: TARGET,
|
||||||
|
generatedFrom: buildPayloadProvenance(SOURCES),
|
||||||
|
files: { aria2c: sha256(file) },
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(root, 'payload-manifest.json'), `${JSON.stringify(manifest)}\n`);
|
||||||
|
return { root, manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('payload manifest validation binds files and source provenance', () => {
|
||||||
|
const { root, manifest } = createPayload();
|
||||||
|
try {
|
||||||
|
assert.deepEqual(readAndValidatePayloadManifest(root, SOURCES, TARGET), manifest);
|
||||||
|
assert.doesNotThrow(() => assertPayloadManifestProvenance(manifest, SOURCES, TARGET));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload manifest validation rejects changed provenance and path traversal', () => {
|
||||||
|
const { root, manifest } = createPayload();
|
||||||
|
try {
|
||||||
|
const changed = { ...manifest, generatedFrom: { ...manifest.generatedFrom } };
|
||||||
|
changed.generatedFrom.aria2c = {
|
||||||
|
...changed.generatedFrom.aria2c,
|
||||||
|
patchSha256: 'f'.repeat(64),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(changed));
|
||||||
|
assert.throws(
|
||||||
|
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
|
||||||
|
/provenance mismatch/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const traversal = {
|
||||||
|
...manifest,
|
||||||
|
files: { '../outside': '0'.repeat(64) },
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(traversal));
|
||||||
|
assert.throws(
|
||||||
|
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
|
||||||
|
/escapes its root|files do not match/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { getAria2BuildScriptSha256 } from './engine-aria2-cache.js';
|
||||||
|
|
||||||
|
const WINDOWS_PACKAGES = [
|
||||||
|
'autoconf',
|
||||||
|
'automake',
|
||||||
|
'libtool',
|
||||||
|
'gettext-devel',
|
||||||
|
'pkgconf',
|
||||||
|
'make',
|
||||||
|
'patch',
|
||||||
|
'binutils',
|
||||||
|
'mingw-w64-x86_64-gcc',
|
||||||
|
'mingw-w64-x86_64-binutils',
|
||||||
|
'mingw-w64-x86_64-pkgconf',
|
||||||
|
'mingw-w64-x86_64-openssl',
|
||||||
|
'mingw-w64-x86_64-libssh2',
|
||||||
|
'mingw-w64-x86_64-c-ares',
|
||||||
|
'mingw-w64-x86_64-expat',
|
||||||
|
'mingw-w64-x86_64-sqlite3',
|
||||||
|
'mingw-w64-x86_64-zlib',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LINUX_PACKAGES = [
|
||||||
|
'gcc',
|
||||||
|
'g++',
|
||||||
|
'make',
|
||||||
|
'patch',
|
||||||
|
'binutils',
|
||||||
|
'autoconf',
|
||||||
|
'automake',
|
||||||
|
'libtool',
|
||||||
|
'gettext',
|
||||||
|
'autopoint',
|
||||||
|
'pkg-config',
|
||||||
|
'libssl-dev',
|
||||||
|
'libssh2-1-dev',
|
||||||
|
'libgcrypt20-dev',
|
||||||
|
'libc-ares-dev',
|
||||||
|
'libexpat1-dev',
|
||||||
|
'libsqlite3-dev',
|
||||||
|
'zlib1g-dev',
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
try {
|
||||||
|
return execFileSync(command, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).replaceAll('\r\n', '\n').trim();
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error.stderr?.toString().trim() || error.message;
|
||||||
|
throw new Error(`Could not fingerprint the engine toolchain with ${command}: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOutput(name, value) {
|
||||||
|
const line = `${name}=${value}\n`;
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, line);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const target = process.env.FIRELINK_TARGET_TRIPLE;
|
||||||
|
if (!target) throw new Error('FIRELINK_TARGET_TRIPLE is required.');
|
||||||
|
|
||||||
|
const records = [`target=${target}`];
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const msysRoot = process.env.FIRELINK_MSYS2_ROOT;
|
||||||
|
if (!msysRoot) throw new Error('FIRELINK_MSYS2_ROOT is required on Windows.');
|
||||||
|
const bash = path.join(msysRoot, 'usr', 'bin', 'bash.exe');
|
||||||
|
const packages = WINDOWS_PACKAGES.join(' ');
|
||||||
|
records.push(`msys2-packages=${run(bash, ['-lc', `pacman -Q ${packages}`])}`);
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
records.push(`debian-packages=${run('dpkg-query', [
|
||||||
|
'-W',
|
||||||
|
'-f=${binary:Package}=${Version}\\n',
|
||||||
|
...LINUX_PACKAGES,
|
||||||
|
])}`);
|
||||||
|
for (const [command, args] of [
|
||||||
|
['gcc', ['--version']],
|
||||||
|
['make', ['--version']],
|
||||||
|
['autoconf', ['--version']],
|
||||||
|
['automake', ['--version']],
|
||||||
|
['pkg-config', ['--version']],
|
||||||
|
]) {
|
||||||
|
records.push(`${command}=${run(command, args).split('\n', 1)[0]}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported engine toolchain host: ${process.platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = crypto.createHash('sha256').update(records.join('\n')).digest('hex');
|
||||||
|
writeOutput('fingerprint', fingerprint);
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const sourceLockPath = path.join(repoRoot, 'engine-sources.lock.json');
|
||||||
|
if (fs.existsSync(sourceLockPath)) {
|
||||||
|
const sourceLock = JSON.parse(fs.readFileSync(sourceLockPath, 'utf8'));
|
||||||
|
const aria2Source = sourceLock.targets?.[target]?.aria2c;
|
||||||
|
if (aria2Source) {
|
||||||
|
const canonicalize = val => {
|
||||||
|
if (Array.isArray(val)) return val.map(canonicalize);
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(val).sort().map(k => [k, canonicalize(val[k])])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchPath = path.join(repoRoot, aria2Source.patch || 'scripts/aria2/firelink.patch');
|
||||||
|
const buildShPath = path.join(repoRoot, 'scripts/aria2/build.sh');
|
||||||
|
const patchSha = fs.existsSync(patchPath)
|
||||||
|
? crypto.createHash('sha256').update(fs.readFileSync(patchPath, 'utf8').replaceAll('\r\n', '\n')).digest('hex')
|
||||||
|
: (aria2Source.patchSha256 || '');
|
||||||
|
const buildShSha = fs.existsSync(buildShPath)
|
||||||
|
? getAria2BuildScriptSha256(repoRoot)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const aria2Records = [
|
||||||
|
...records,
|
||||||
|
`aria2-source=${JSON.stringify(canonicalize(aria2Source))}`,
|
||||||
|
`aria2-patch-sha256=${patchSha}`,
|
||||||
|
`aria2-build-sh-sha256=${buildShSha}`,
|
||||||
|
];
|
||||||
|
const aria2Fingerprint = crypto.createHash('sha256').update(aria2Records.join('\n')).digest('hex');
|
||||||
|
writeOutput('aria2-fingerprint', aria2Fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { removePathWithRetry } from './engine-payload-promotion.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' };
|
||||||
|
const PLATFORM_MAP = {
|
||||||
|
darwin: 'apple-darwin',
|
||||||
|
win32: 'pc-windows-msvc',
|
||||||
|
linux: 'unknown-linux-gnu',
|
||||||
|
};
|
||||||
|
const SAFE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
|
||||||
|
function argumentValue(args, name) {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index >= 0) return args[index + 1];
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const inline = args.find(argument => argument.startsWith(prefix));
|
||||||
|
return inline?.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeTarget(target) {
|
||||||
|
if (typeof target !== 'string' || !SAFE_TARGET_PATTERN.test(target)) {
|
||||||
|
throw new Error(`Invalid target triple: ${target ?? '<missing>'}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTargetTriple(
|
||||||
|
args = process.argv.slice(2),
|
||||||
|
env = process.env,
|
||||||
|
platform = os.platform(),
|
||||||
|
arch = os.arch(),
|
||||||
|
) {
|
||||||
|
const hostTarget = ARCH_MAP[arch] && PLATFORM_MAP[platform]
|
||||||
|
? `${ARCH_MAP[arch]}-${PLATFORM_MAP[platform]}`
|
||||||
|
: undefined;
|
||||||
|
const target = argumentValue(args, '--target')
|
||||||
|
|| env.TAURI_ENV_TARGET_TRIPLE
|
||||||
|
|| env.FIRELINK_TARGET_TRIPLE
|
||||||
|
|| hostTarget;
|
||||||
|
return assertSafeTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOutputRoot(args = process.argv.slice(2), env = process.env) {
|
||||||
|
const outputRoot = argumentValue(args, '--output-root') || env.FIRELINK_ENGINE_OUTPUT_ROOT;
|
||||||
|
if (!outputRoot) {
|
||||||
|
throw new Error(
|
||||||
|
'No engine output workspace was provided. Run through npm run tauri or set FIRELINK_ENGINE_OUTPUT_ROOT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path.resolve(outputRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalPathWithMissingComponents(value) {
|
||||||
|
let cursor = path.resolve(value);
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const canonical = fs.realpathSync.native(cursor);
|
||||||
|
return path.join(canonical, ...missing.reverse());
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') throw error;
|
||||||
|
const parent = path.dirname(cursor);
|
||||||
|
if (parent === cursor) throw error;
|
||||||
|
missing.push(path.basename(cursor));
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparablePath(value) {
|
||||||
|
const normalized = path.normalize(value);
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinPath(root, candidate) {
|
||||||
|
const relative = path.relative(comparablePath(root), comparablePath(candidate));
|
||||||
|
return relative === ''
|
||||||
|
|| (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeOutputRoot(outputRoot, forbiddenRoots = []) {
|
||||||
|
const canonicalOutputRoot = canonicalPathWithMissingComponents(outputRoot);
|
||||||
|
for (const forbiddenRoot of forbiddenRoots) {
|
||||||
|
const canonicalForbiddenRoot = canonicalPathWithMissingComponents(forbiddenRoot);
|
||||||
|
if (isWithinPath(canonicalForbiddenRoot, canonicalOutputRoot)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to use a repository-shared engine workspace: ${outputRoot}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return canonicalOutputRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripVerbatimPrefix(filePath) {
|
||||||
|
return typeof filePath === 'string' && filePath.startsWith('\\\\?\\')
|
||||||
|
? filePath.slice(4)
|
||||||
|
: filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceTempBase(referencePath = repoRoot) {
|
||||||
|
if (process.env.FIRELINK_ENGINE_WORKSPACE_BASE) {
|
||||||
|
return path.resolve(process.env.FIRELINK_ENGINE_WORKSPACE_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const referenceDrive = path.parse(path.resolve(referencePath)).root.toLowerCase();
|
||||||
|
|
||||||
|
if (process.env.RUNNER_TEMP) {
|
||||||
|
const runnerTemp = path.resolve(process.env.RUNNER_TEMP);
|
||||||
|
if (path.parse(runnerTemp).root.toLowerCase() === referenceDrive) {
|
||||||
|
return runnerTemp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const osTemp = path.resolve(os.tmpdir());
|
||||||
|
if (path.parse(osTemp).root.toLowerCase() === referenceDrive) {
|
||||||
|
return osTemp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjacentTemp = path.join(path.resolve(referencePath, '..'), '.firelink-engine-workspaces');
|
||||||
|
fs.mkdirSync(adjacentTemp, { recursive: true, mode: 0o700 });
|
||||||
|
return adjacentTemp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.RUNNER_TEMP) {
|
||||||
|
return path.resolve(process.env.RUNNER_TEMP);
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.tmpdir();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEngineWorkspace(target) {
|
||||||
|
assertSafeTarget(target);
|
||||||
|
const tempBase = resolveWorkspaceTempBase();
|
||||||
|
const rawWorkspace = fs.realpathSync.native(
|
||||||
|
fs.mkdtempSync(path.join(tempBase, `firelink-engine-${target}-${process.pid}-`)),
|
||||||
|
);
|
||||||
|
const workspace = stripVerbatimPrefix(rawWorkspace);
|
||||||
|
const outputRoot = path.join(workspace, 'engine-dist');
|
||||||
|
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
|
||||||
|
return { outputRoot, runtimeRoot: outputRoot, workspace };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function engineResourceConfig(outputRoot) {
|
||||||
|
const source = `${stripVerbatimPrefix(path.resolve(outputRoot))}${path.sep}`;
|
||||||
|
return JSON.stringify({
|
||||||
|
bundle: {
|
||||||
|
resources: {
|
||||||
|
[source]: 'engine-dist/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEngineWorkspace(workspace) {
|
||||||
|
const resolved = stripVerbatimPrefix(path.resolve(workspace));
|
||||||
|
const basename = path.basename(resolved);
|
||||||
|
if (!basename.startsWith('firelink-engine-')) {
|
||||||
|
throw new Error(`Refusing to remove an unexpected engine workspace: ${resolved}`);
|
||||||
|
}
|
||||||
|
await removePathWithRetry(resolved);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
assertSafeTarget,
|
||||||
|
assertSafeOutputRoot,
|
||||||
|
createEngineWorkspace,
|
||||||
|
engineResourceConfig,
|
||||||
|
removeEngineWorkspace,
|
||||||
|
resolveOutputRoot,
|
||||||
|
resolveTargetTriple,
|
||||||
|
resolveWorkspaceTempBase,
|
||||||
|
stripVerbatimPrefix,
|
||||||
|
} from './engine-workspace.js';
|
||||||
|
|
||||||
|
test('target resolution accepts explicit and inline target arguments', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveTargetTriple(['--target', 'x86_64-unknown-linux-gnu'], {}, 'darwin', 'arm64'),
|
||||||
|
'x86_64-unknown-linux-gnu',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveTargetTriple(['--target=x86_64-pc-windows-msvc'], {}, 'darwin', 'arm64'),
|
||||||
|
'x86_64-pc-windows-msvc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('target validation rejects path traversal before filesystem use', () => {
|
||||||
|
assert.throws(() => assertSafeTarget('../outside'), /Invalid target triple/);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveTargetTriple(['--target', 'x86_64/../../outside'], {}, 'darwin', 'arm64'),
|
||||||
|
/Invalid target triple/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('engine workspaces are unique and produce an absolute Tauri resource mapping', async () => {
|
||||||
|
const first = createEngineWorkspace('aarch64-apple-darwin');
|
||||||
|
const second = createEngineWorkspace('aarch64-apple-darwin');
|
||||||
|
try {
|
||||||
|
assert.notEqual(first.workspace, second.workspace);
|
||||||
|
assert.equal(fs.statSync(first.outputRoot).isDirectory(), true);
|
||||||
|
const config = JSON.parse(engineResourceConfig(first.outputRoot));
|
||||||
|
assert.equal(
|
||||||
|
config.bundle.resources[`${path.resolve(first.outputRoot)}${path.sep}`],
|
||||||
|
'engine-dist/',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await removeEngineWorkspace(first.workspace);
|
||||||
|
await removeEngineWorkspace(second.workspace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('staging requires an explicit private output workspace', () => {
|
||||||
|
assert.throws(() => resolveOutputRoot([], {}), /No engine output workspace/);
|
||||||
|
assert.equal(
|
||||||
|
resolveOutputRoot(['--output-root', '/tmp/firelink-engine-run'], {}).endsWith(
|
||||||
|
path.join('firelink-engine-run'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared repository output roots and descendants are rejected', () => {
|
||||||
|
const repoRoot = path.resolve('/repo');
|
||||||
|
assert.throws(
|
||||||
|
() => assertSafeOutputRoot('/repo/src-tauri/engine-dist/target', [
|
||||||
|
repoRoot,
|
||||||
|
path.join(repoRoot, 'src-tauri'),
|
||||||
|
path.join(repoRoot, 'src-tauri', 'engine-dist'),
|
||||||
|
]),
|
||||||
|
/repository-shared engine workspace/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output roots are checked after resolving symlinked parents', () => {
|
||||||
|
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-workspace-test-'));
|
||||||
|
const sharedRoot = path.join(temporaryRoot, 'shared');
|
||||||
|
const linkedRoot = path.join(temporaryRoot, 'linked');
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(sharedRoot, 'src-tauri', 'engine-dist'), { recursive: true });
|
||||||
|
fs.symlinkSync(sharedRoot, linkedRoot, 'dir');
|
||||||
|
assert.throws(
|
||||||
|
() => assertSafeOutputRoot(path.join(linkedRoot, 'src-tauri', 'engine-dist', 'target'), [
|
||||||
|
sharedRoot,
|
||||||
|
path.join(sharedRoot, 'src-tauri'),
|
||||||
|
path.join(sharedRoot, 'src-tauri', 'engine-dist'),
|
||||||
|
]),
|
||||||
|
/repository-shared engine workspace/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripVerbatimPrefix removes Win32 verbatim namespaces', () => {
|
||||||
|
assert.equal(stripVerbatimPrefix('\\\\?\\D:\\a\\_temp'), 'D:\\a\\_temp');
|
||||||
|
assert.equal(stripVerbatimPrefix('D:\\a\\_temp'), 'D:\\a\\_temp');
|
||||||
|
assert.equal(stripVerbatimPrefix('/tmp/firelink'), '/tmp/firelink');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveWorkspaceTempBase honors FIRELINK_ENGINE_WORKSPACE_BASE override', () => {
|
||||||
|
const custom = path.resolve('/custom/engine/base');
|
||||||
|
const prev = process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
|
||||||
|
process.env.FIRELINK_ENGINE_WORKSPACE_BASE = custom;
|
||||||
|
try {
|
||||||
|
assert.equal(resolveWorkspaceTempBase(), custom);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
|
||||||
|
else process.env.FIRELINK_ENGINE_WORKSPACE_BASE = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function run(script, args) {
|
||||||
|
const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', script), ...args], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[FAIL] Could not run ${script}: ${result.error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
run('stage-engines.js', []);
|
||||||
|
run('verify-binaries.js', ['--staged']);
|
||||||
@@ -5,6 +5,7 @@ import { execFile } from 'node:child_process';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||||
|
import { buildPayloadProvenance } from './engine-payload-manifest.js';
|
||||||
import { downloadEngineArchive } from './engine-download.js';
|
import { downloadEngineArchive } from './engine-download.js';
|
||||||
import {
|
import {
|
||||||
promoteDirectory,
|
promoteDirectory,
|
||||||
@@ -12,6 +13,13 @@ import {
|
|||||||
removeOrphanedProvisioningDirectories,
|
removeOrphanedProvisioningDirectories,
|
||||||
removePathWithRetry,
|
removePathWithRetry,
|
||||||
} from './engine-payload-promotion.js';
|
} from './engine-payload-promotion.js';
|
||||||
|
import { assertAria2RouteSource } from './aria2-route-contract.js';
|
||||||
|
import {
|
||||||
|
getAria2BuildScriptSha256,
|
||||||
|
restoreAria2Cache,
|
||||||
|
saveAria2Cache,
|
||||||
|
validateAria2Cache,
|
||||||
|
} from './engine-aria2-cache.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
@@ -39,6 +47,15 @@ if (!targetSources) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetSources.aria2c?.firelinkRouteContract) {
|
||||||
|
try {
|
||||||
|
assertAria2RouteSource(targetSources.aria2c, target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||||
const isWindows = target.includes('windows');
|
const isWindows = target.includes('windows');
|
||||||
const executableSuffix = isWindows ? '.exe' : '';
|
const executableSuffix = isWindows ? '.exe' : '';
|
||||||
@@ -128,16 +145,7 @@ function writePayloadManifest() {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
target,
|
target,
|
||||||
generatedFrom: Object.fromEntries(
|
generatedFrom: buildPayloadProvenance(targetSources),
|
||||||
Object.entries(targetSources).map(([name, source]) => [
|
|
||||||
name,
|
|
||||||
{
|
|
||||||
version: source.version,
|
|
||||||
url: source.url || source.sourceUrl,
|
|
||||||
sha256: source.sha256 || source.sourceSha256
|
|
||||||
}
|
|
||||||
])
|
|
||||||
),
|
|
||||||
files: Object.fromEntries(
|
files: Object.fromEntries(
|
||||||
files.map(file => [
|
files.map(file => [
|
||||||
path.relative(payloadDestination, file).split(path.sep).join('/'),
|
path.relative(payloadDestination, file).split(path.sep).join('/'),
|
||||||
@@ -180,15 +188,93 @@ try {
|
|||||||
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
||||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||||
|
|
||||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
const aria2Source = targetSources.aria2c;
|
||||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
if (aria2Source.buildFromSource !== true || aria2Source.allocationTelemetry !== true) {
|
||||||
|
throw new Error('Aria2 provisioning requires the allocation telemetry source build.');
|
||||||
|
}
|
||||||
|
const patchFile = path.join(repoRoot, aria2Source.patch);
|
||||||
|
if (sha256(patchFile) !== aria2Source.patchSha256) throw new Error('Aria2 source patch checksum mismatch');
|
||||||
|
|
||||||
|
const aria2CacheRoot = process.env.FIRELINK_ARIA2_CACHE_DIR
|
||||||
|
|| path.join(destinationParent, '.aria2-cache', target);
|
||||||
|
const buildScriptSha256 = getAria2BuildScriptSha256(repoRoot);
|
||||||
|
const toolchainFingerprint = process.env.FIRELINK_TOOLCHAIN_FINGERPRINT || null;
|
||||||
|
|
||||||
|
const cacheValidation = validateAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
target,
|
||||||
|
aria2Source,
|
||||||
|
buildScriptSha256,
|
||||||
|
toolchainFingerprint,
|
||||||
|
executableSuffix,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheValidation.valid) {
|
||||||
|
restoreAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
payloadDestination,
|
||||||
|
target,
|
||||||
|
executableSuffix,
|
||||||
|
isWindows,
|
||||||
|
});
|
||||||
|
console.log(`Reused cached Aria2 build from ${aria2CacheRoot}`);
|
||||||
|
} else {
|
||||||
|
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||||
|
const sourceRoots = fs.readdirSync(aria2, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(aria2, entry.name, 'configure.ac')))
|
||||||
|
.map(entry => path.join(aria2, entry.name));
|
||||||
|
if (sourceRoots.length !== 1) throw new Error('Aria2 archive must contain exactly one source root');
|
||||||
|
const [sourceRoot] = sourceRoots;
|
||||||
|
const bash = isWindows ? path.join(process.env.FIRELINK_MSYS2_ROOT || 'C:/msys64', 'usr/bin/bash.exe') : 'bash';
|
||||||
|
await execFileAsync(bash, [path.join(repoRoot, 'scripts/aria2/build.sh').replaceAll('\\', '/'), sourceRoot, patchFile], {
|
||||||
|
signal: provisioningAbortController.signal,
|
||||||
|
env: { ...process.env, ...(isWindows ? { MSYSTEM: 'MINGW64' } : {}) },
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
timeout: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
copyExecutable(path.join(sourceRoot, 'firelink-build', 'src', `aria2c${executableSuffix}`), 'aria2c');
|
||||||
|
const aria2Runtime = path.join(sourceRoot, 'aria2-libs');
|
||||||
|
if (fs.existsSync(aria2Runtime)) {
|
||||||
|
fs.cpSync(aria2Runtime, path.join(payloadDestination, 'aria2-libs'), {
|
||||||
|
recursive: true,
|
||||||
|
preserveTimestamps: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveAria2Cache({
|
||||||
|
aria2CacheRoot,
|
||||||
|
payloadDestination,
|
||||||
|
target,
|
||||||
|
aria2Source,
|
||||||
|
buildScriptSha256,
|
||||||
|
toolchainFingerprint,
|
||||||
|
executableSuffix,
|
||||||
|
aria2Runtime,
|
||||||
|
isWindows,
|
||||||
|
});
|
||||||
|
console.log(`Saved built Aria2 cache to ${aria2CacheRoot}`);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn(`Could not save Aria2 build cache: ${cacheError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writePayloadManifest();
|
writePayloadManifest();
|
||||||
throwIfProvisioningAborted();
|
throwIfProvisioningAborted();
|
||||||
await promoteDirectory(payloadDestination, destination);
|
await promoteDirectory(payloadDestination, destination);
|
||||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (temporary) await removePathWithRetry(temporary);
|
if (temporary) {
|
||||||
|
try {
|
||||||
|
await removePathWithRetry(temporary);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
if (provisioningAbortController.signal.aborted) {
|
||||||
|
console.warn(`Could not remove temporary directory during abort: ${cleanupError.message}`);
|
||||||
|
} else {
|
||||||
|
throw cleanupError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const [signalName, handler] of signalHandlers) {
|
for (const [signalName, handler] of signalHandlers) {
|
||||||
process.removeListener(signalName, handler);
|
process.removeListener(signalName, handler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,19 @@ test('macOS release verification uses the app mounted from the final DMG', () =>
|
|||||||
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
|
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
|
||||||
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
|
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('release workflow normalizes all 6 distribution target artifacts', () => {
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
|
||||||
|
assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
|
||||||
|
assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
|
||||||
|
assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
|
||||||
|
assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
|
||||||
|
assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
|
ARIA2_SYSTEM_RESOLVER_OPTIONS,
|
||||||
|
ARIA2_ROUTE_OPTIONS,
|
||||||
|
assertAria2Baseline,
|
||||||
|
assertAria2RouteOptions,
|
||||||
|
assertAria2SystemResolverOptions,
|
||||||
|
hasAria2RouteCapabilities,
|
||||||
|
} from './aria2-route-contract.js';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||||
@@ -24,12 +33,18 @@ const argumentIndex = process.argv.indexOf('--binary');
|
|||||||
const binaryPath = path.resolve(
|
const binaryPath = path.resolve(
|
||||||
argumentIndex >= 0
|
argumentIndex >= 0
|
||||||
? process.argv[argumentIndex + 1]
|
? process.argv[argumentIndex + 1]
|
||||||
: path.join(
|
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
|
||||||
repoRoot,
|
? path.join(
|
||||||
'src-tauri',
|
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
|
||||||
'binaries',
|
targetTriple,
|
||||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||||
),
|
)
|
||||||
|
: path.join(
|
||||||
|
repoRoot,
|
||||||
|
'src-tauri',
|
||||||
|
'binaries',
|
||||||
|
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
if (!fs.existsSync(binaryPath)) {
|
||||||
@@ -69,6 +84,14 @@ async function rpc(port, secret, method, params = []) {
|
|||||||
return body.result;
|
return body.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forceRemoveIfPresent(port, secret, gid) {
|
||||||
|
try {
|
||||||
|
await rpc(port, secret, 'aria2.forceRemove', [gid]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!/not found|no such download|active download not found/i.test(error.message)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function childExited(child) {
|
function childExited(child) {
|
||||||
return child.exitCode !== null || child.signalCode !== null;
|
return child.exitCode !== null || child.signalCode !== null;
|
||||||
}
|
}
|
||||||
@@ -166,11 +189,15 @@ await new Promise((resolve, reject) => {
|
|||||||
});
|
});
|
||||||
const contentPort = contentServer.address().port;
|
const contentPort = contentServer.address().port;
|
||||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||||
|
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||||
const environment = fs.existsSync(libraryPath)
|
const environment = fs.existsSync(libraryPath)
|
||||||
? {
|
? {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENSSL_MODULES: libraryPath,
|
OPENSSL_MODULES: libraryPath,
|
||||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||||
|
...(process.platform === 'win32'
|
||||||
|
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: process.env;
|
: process.env;
|
||||||
const child = spawn(binaryPath, [
|
const child = spawn(binaryPath, [
|
||||||
@@ -181,6 +208,7 @@ const child = spawn(binaryPath, [
|
|||||||
`--dir=${tempRoot}`,
|
`--dir=${tempRoot}`,
|
||||||
'--file-allocation=none',
|
'--file-allocation=none',
|
||||||
'--enable-dht=false',
|
'--enable-dht=false',
|
||||||
|
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
'--console-log-level=error',
|
'--console-log-level=error',
|
||||||
'--quiet=true',
|
'--quiet=true',
|
||||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
@@ -189,17 +217,26 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const version = await waitForRpc(rpcPort, secret);
|
const version = await waitForRpc(rpcPort, secret);
|
||||||
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
|
assertAria2Baseline(version);
|
||||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
|
const routeCapabilitiesAvailable = hasAria2RouteCapabilities(version);
|
||||||
|
if (routeCapabilitiesAvailable) {
|
||||||
|
// The fixture server is intentionally loopback. Disable only the custom
|
||||||
|
// target policy for this smoke daemon after capabilities are attested.
|
||||||
|
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
|
||||||
|
console.log(`[INFO] aria2 ${version.version || 'unknown'}; optional Firelink route capabilities available`);
|
||||||
|
} else {
|
||||||
|
console.log(`[INFO] aria2 ${version.version || 'unknown'}; using stock system-resolver capabilities`);
|
||||||
|
}
|
||||||
|
const systemFixtureOptions = routeCapabilitiesAvailable
|
||||||
|
? { ...ARIA2_SYSTEM_RESOLVER_OPTIONS, 'network-target-policy': 'none' }
|
||||||
|
: { ...ARIA2_SYSTEM_RESOLVER_OPTIONS };
|
||||||
|
|
||||||
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
||||||
'async-dns': 'false',
|
...systemFixtureOptions,
|
||||||
out: 'resolver-normal.bin',
|
out: 'resolver-normal.bin',
|
||||||
}]);
|
}]);
|
||||||
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
||||||
if (uriOptions['async-dns'] !== 'false') {
|
assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri');
|
||||||
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const torrent = bencode({
|
const torrent = bencode({
|
||||||
info: {
|
info: {
|
||||||
@@ -210,15 +247,52 @@ try {
|
|||||||
},
|
},
|
||||||
}).toString('base64');
|
}).toString('base64');
|
||||||
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||||
'async-dns': 'false',
|
...systemFixtureOptions,
|
||||||
dir: tempRoot,
|
dir: tempRoot,
|
||||||
}]);
|
}]);
|
||||||
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
||||||
if (torrentOptions['async-dns'] !== 'false') {
|
assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent');
|
||||||
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
|
const proxyRoute = 'http://127.0.0.1:9';
|
||||||
|
const normalizedProxyRoute = new URL(proxyRoute).toString();
|
||||||
|
const proxiedUriResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/file'], {
|
||||||
|
...systemFixtureOptions,
|
||||||
|
'all-proxy': proxyRoute,
|
||||||
|
pause: 'true',
|
||||||
|
out: 'resolver-proxied.bin',
|
||||||
|
}]);
|
||||||
|
const proxiedUriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedUriResult]);
|
||||||
|
if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) {
|
||||||
|
throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`);
|
||||||
|
}
|
||||||
|
assertAria2SystemResolverOptions(proxiedUriOptions, 'proxied aria2.addUri');
|
||||||
|
|
||||||
|
const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||||
|
...systemFixtureOptions,
|
||||||
|
'all-proxy': proxyRoute,
|
||||||
|
pause: 'true',
|
||||||
|
dir: tempRoot,
|
||||||
|
}]);
|
||||||
|
const proxiedTorrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedTorrentResult]);
|
||||||
|
if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) {
|
||||||
|
throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`);
|
||||||
|
}
|
||||||
|
assertAria2SystemResolverOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent');
|
||||||
|
|
||||||
|
if (routeCapabilitiesAvailable) {
|
||||||
|
const alternateResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/alternate'], {
|
||||||
|
...ARIA2_ROUTE_OPTIONS,
|
||||||
|
pause: 'true',
|
||||||
|
out: 'resolver-alternate.bin',
|
||||||
|
}]);
|
||||||
|
const alternateOptions = await rpc(rpcPort, secret, 'aria2.getOption', [alternateResult]);
|
||||||
|
assertAria2RouteOptions(alternateOptions, 'alternate aria2.addUri');
|
||||||
|
await forceRemoveIfPresent(rpcPort, secret, alternateResult);
|
||||||
|
}
|
||||||
|
await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult);
|
||||||
|
await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult);
|
||||||
|
|
||||||
|
console.log('[PASS] Aria2 preserved system route options for direct/proxied normal/Torrent transfers');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = stderr.trim();
|
const detail = stderr.trim();
|
||||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
|
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
|
assertAria2Baseline,
|
||||||
|
hasAria2RouteCapabilities,
|
||||||
|
} from './aria2-route-contract.js';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||||
@@ -17,7 +23,13 @@ const targetTriple = `${arch}-${platform}`;
|
|||||||
const argumentIndex = process.argv.indexOf('--binary');
|
const argumentIndex = process.argv.indexOf('--binary');
|
||||||
const binaryPath = path.resolve(argumentIndex >= 0
|
const binaryPath = path.resolve(argumentIndex >= 0
|
||||||
? process.argv[argumentIndex + 1]
|
? process.argv[argumentIndex + 1]
|
||||||
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
|
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
|
||||||
|
? path.join(
|
||||||
|
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
|
||||||
|
targetTriple,
|
||||||
|
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||||
|
)
|
||||||
|
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
|
||||||
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||||
|
|
||||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||||
@@ -303,11 +315,15 @@ const secret = `firelink-transfers-${crypto.randomUUID()}`;
|
|||||||
const configPath = path.join(tempRoot, 'aria2.conf');
|
const configPath = path.join(tempRoot, 'aria2.conf');
|
||||||
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
|
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
|
||||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||||
|
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||||
const environment = fs.existsSync(libraryPath)
|
const environment = fs.existsSync(libraryPath)
|
||||||
? {
|
? {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENSSL_MODULES: libraryPath,
|
OPENSSL_MODULES: libraryPath,
|
||||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||||
|
...(process.platform === 'win32'
|
||||||
|
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: process.env;
|
: process.env;
|
||||||
const child = spawn(binaryPath, [
|
const child = spawn(binaryPath, [
|
||||||
@@ -320,6 +336,7 @@ const child = spawn(binaryPath, [
|
|||||||
'--enable-dht=false',
|
'--enable-dht=false',
|
||||||
'--console-log-level=error',
|
'--console-log-level=error',
|
||||||
'--quiet=true',
|
'--quiet=true',
|
||||||
|
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
`--server-stat-if=${serverStatPath}`,
|
`--server-stat-if=${serverStatPath}`,
|
||||||
`--server-stat-of=${serverStatPath}`,
|
`--server-stat-of=${serverStatPath}`,
|
||||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
@@ -328,9 +345,16 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const version = await waitForRpc(rpcPort, secret);
|
const version = await waitForRpc(rpcPort, secret);
|
||||||
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke`);
|
assertAria2Baseline(version);
|
||||||
|
if (hasAria2RouteCapabilities(version)) {
|
||||||
|
// The fixture server is intentionally loopback. Disable only the custom
|
||||||
|
// target policy after the optional capabilities have been attested.
|
||||||
|
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
|
||||||
|
}
|
||||||
|
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke (system resolver)`);
|
||||||
|
|
||||||
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||||
}]);
|
}]);
|
||||||
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
||||||
@@ -339,6 +363,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
|
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
||||||
}]);
|
}]);
|
||||||
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
|
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
|
||||||
@@ -346,6 +371,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
|
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
||||||
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
|
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
|
||||||
}]);
|
}]);
|
||||||
@@ -354,6 +380,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'resume.bin', split: '1', continue: 'true',
|
out: 'resume.bin', split: '1', continue: 'true',
|
||||||
}]);
|
}]);
|
||||||
await waitForProgress(rpcPort, secret, resumeGid);
|
await waitForProgress(rpcPort, secret, resumeGid);
|
||||||
@@ -368,6 +395,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'cancel.bin', split: '1',
|
out: 'cancel.bin', split: '1',
|
||||||
}]);
|
}]);
|
||||||
await waitForProgress(rpcPort, secret, cancelGid);
|
await waitForProgress(rpcPort, secret, cancelGid);
|
||||||
@@ -380,17 +408,19 @@ try {
|
|||||||
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
|
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
|
||||||
`http://127.0.0.1:${fixturePort}/missing`,
|
`http://127.0.0.1:${fixturePort}/missing`,
|
||||||
`http://127.0.0.1:${fixturePort}/range`,
|
`http://127.0.0.1:${fixturePort}/range`,
|
||||||
], { out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
|
], { ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
|
||||||
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
|
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
|
||||||
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
|
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
|
||||||
|
|
||||||
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
||||||
}]);
|
}]);
|
||||||
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
|
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
|
||||||
throw new Error('valid checksum transfer did not complete');
|
throw new Error('valid checksum transfer did not complete');
|
||||||
}
|
}
|
||||||
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||||
}]);
|
}]);
|
||||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||||
@@ -417,6 +447,7 @@ try {
|
|||||||
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
|
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
|
||||||
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
|
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
|
||||||
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
|
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'redirect.bin',
|
out: 'redirect.bin',
|
||||||
}]);
|
}]);
|
||||||
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
|
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
|
||||||
@@ -425,6 +456,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
|
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'missing.bin', 'max-tries': '1',
|
out: 'missing.bin', 'max-tries': '1',
|
||||||
}]);
|
}]);
|
||||||
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
|
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
|
||||||
@@ -433,6 +465,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
|
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||||
}]);
|
}]);
|
||||||
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
||||||
@@ -441,6 +474,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
|
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'malformed.bin', 'max-tries': '1',
|
out: 'malformed.bin', 'max-tries': '1',
|
||||||
}]);
|
}]);
|
||||||
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
|
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
|
||||||
@@ -448,6 +482,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||||
|
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||||
}]);
|
}]);
|
||||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
function argValue(name) {
|
function argValue(name) {
|
||||||
const index = process.argv.indexOf(name);
|
const index = process.argv.indexOf(name);
|
||||||
@@ -24,12 +25,16 @@ const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
|
|||||||
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
||||||
: 5000;
|
: 5000;
|
||||||
const READY_PORT_TIMEOUT_MS = 500;
|
const READY_PORT_TIMEOUT_MS = 500;
|
||||||
|
// Portable-package checks intentionally inspect their disposable bundle's
|
||||||
|
// data directory. Every other smoke run gets its own disposable profile.
|
||||||
|
const smokeStorageRoot = assertPortableData ? null : fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-smoke-')));
|
||||||
const child = spawn(executable, [], {
|
const child = spawn(executable, [], {
|
||||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||||
detached: process.platform !== 'win32',
|
detached: process.platform !== 'win32',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
FIRELINK_SMOKE_TEST: '1',
|
FIRELINK_SMOKE_TEST: '1',
|
||||||
|
FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '',
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
||||||
GDK_BACKEND: 'x11',
|
GDK_BACKEND: 'x11',
|
||||||
},
|
},
|
||||||
@@ -401,5 +406,7 @@ try {
|
|||||||
if (!await terminateChild()) {
|
if (!await terminateChild()) {
|
||||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
} else if (smokeStorageRoot) {
|
||||||
|
fs.rmSync(smokeStorageRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-10
@@ -8,6 +8,12 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
ARIA2_DNS_RESOLVER,
|
||||||
|
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
|
assertAria2Baseline,
|
||||||
|
hasAria2RouteCapabilities,
|
||||||
|
} from './aria2-route-contract.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
@@ -36,7 +42,10 @@ if (!arch || !platform) {
|
|||||||
const targetTriple = `${arch}-${platform}`;
|
const targetTriple = `${arch}-${platform}`;
|
||||||
const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`;
|
const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`;
|
||||||
const binaryPath = path.resolve(
|
const binaryPath = path.resolve(
|
||||||
argumentValue('--binary') || path.join(repoRoot, 'src-tauri', 'binaries', executableName),
|
argumentValue('--binary')
|
||||||
|
|| (process.env.FIRELINK_ENGINE_OUTPUT_ROOT
|
||||||
|
? path.join(process.env.FIRELINK_ENGINE_OUTPUT_ROOT, targetTriple, executableName)
|
||||||
|
: path.join(repoRoot, 'src-tauri', 'binaries', executableName)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const runtimeAbortController = new AbortController();
|
const runtimeAbortController = new AbortController();
|
||||||
@@ -65,6 +74,14 @@ let signalTerminationRequested = false;
|
|||||||
|
|
||||||
class DaemonExitedError extends Error {}
|
class DaemonExitedError extends Error {}
|
||||||
|
|
||||||
|
function assertFixtureRouteOptions(options, context) {
|
||||||
|
if (options['async-dns'] !== 'false'
|
||||||
|
|| (Object.hasOwn(options, 'dns-resolver')
|
||||||
|
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER)) {
|
||||||
|
throw new Error(`${context} did not retain the system resolver contract: ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function childExited(child) {
|
function childExited(child) {
|
||||||
return child.exitCode !== null || child.signalCode !== null;
|
return child.exitCode !== null || child.signalCode !== null;
|
||||||
}
|
}
|
||||||
@@ -245,9 +262,15 @@ async function listen(server) {
|
|||||||
|
|
||||||
function daemonEnvironment() {
|
function daemonEnvironment() {
|
||||||
const libraries = path.join(path.dirname(binaryPath), 'aria2-libs');
|
const libraries = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||||
return fs.existsSync(libraries)
|
if (!fs.existsSync(libraries)) return process.env;
|
||||||
? { ...process.env, OPENSSL_MODULES: libraries }
|
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||||
: process.env;
|
return {
|
||||||
|
...process.env,
|
||||||
|
OPENSSL_MODULES: libraries,
|
||||||
|
...(process.platform === 'win32'
|
||||||
|
? { [pathKey]: `${libraries}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rpc(port, secret, method, params = [], { signal = runtimeAbortController.signal } = {}) {
|
async function rpc(port, secret, method, params = [], { signal = runtimeAbortController.signal } = {}) {
|
||||||
@@ -305,6 +328,7 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
|
|||||||
'--enable-dht=false',
|
'--enable-dht=false',
|
||||||
'--enable-peer-exchange=false',
|
'--enable-peer-exchange=false',
|
||||||
'--bt-enable-lpd=false',
|
'--bt-enable-lpd=false',
|
||||||
|
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
'--console-log-level=error',
|
'--console-log-level=error',
|
||||||
'--quiet=true',
|
'--quiet=true',
|
||||||
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
|
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
|
||||||
@@ -326,15 +350,28 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
|
|||||||
};
|
};
|
||||||
activeDaemons.add(daemon);
|
activeDaemons.add(daemon);
|
||||||
try {
|
try {
|
||||||
await waitFor(`${name} Aria2 RPC`, async () => {
|
const version = await waitFor(`${name} Aria2 RPC`, async () => {
|
||||||
if (exit) throw new DaemonExitedError(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`);
|
if (exit) throw new DaemonExitedError(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`);
|
||||||
try {
|
try {
|
||||||
await rpc(selectedRpcPort, secret, 'aria2.getVersion');
|
return await rpc(selectedRpcPort, secret, 'aria2.getVersion');
|
||||||
return true;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
assertAria2Baseline(version);
|
||||||
|
if (Array.isArray(version.enabledFeatures) && version.enabledFeatures.includes('Async DNS')) {
|
||||||
|
// Use the standard system resolver for every fixture transfer. This
|
||||||
|
// also covers fixture additions that intentionally omit per-transfer
|
||||||
|
// route fields; product additions still stamp async-dns=false on
|
||||||
|
// every request.
|
||||||
|
await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'async-dns': 'false' }]);
|
||||||
|
}
|
||||||
|
if (hasAria2RouteCapabilities(version)) {
|
||||||
|
// The smoke fixtures intentionally use loopback tracker/peer routes.
|
||||||
|
// Disable only the custom target policy after capabilities are
|
||||||
|
// attested; stock Aria2 has no such option to send.
|
||||||
|
await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
|
||||||
|
}
|
||||||
return daemon;
|
return daemon;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -720,7 +757,6 @@ async function main() {
|
|||||||
'bt-metadata-only': 'false',
|
'bt-metadata-only': 'false',
|
||||||
'bt-save-metadata': 'false',
|
'bt-save-metadata': 'false',
|
||||||
'follow-torrent': 'false',
|
'follow-torrent': 'false',
|
||||||
'async-dns': 'false',
|
|
||||||
'max-tries': '3',
|
'max-tries': '3',
|
||||||
'retry-wait': '2',
|
'retry-wait': '2',
|
||||||
'connect-timeout': '20',
|
'connect-timeout': '20',
|
||||||
@@ -767,7 +803,7 @@ async function main() {
|
|||||||
assert(directHandoff.parent.status === 'complete', `normal magnet parent did not complete metadata: ${JSON.stringify(directHandoff.parent)}`);
|
assert(directHandoff.parent.status === 'complete', `normal magnet parent did not complete metadata: ${JSON.stringify(directHandoff.parent)}`);
|
||||||
assert(directHandoff.parent.files?.some(file => String(file.path).startsWith('[METADATA]')), 'normal magnet parent did not expose a metadata file');
|
assert(directHandoff.parent.files?.some(file => String(file.path).startsWith('[METADATA]')), 'normal magnet parent did not expose a metadata file');
|
||||||
assert(directOptions['bt-metadata-only'] === 'false', 'normal magnet child did not retain payload mode');
|
assert(directOptions['bt-metadata-only'] === 'false', 'normal magnet child did not retain payload mode');
|
||||||
assert(directOptions['async-dns'] === 'false', 'direct Torrent did not retain system DNS resolution');
|
assertFixtureRouteOptions(directOptions, 'fresh direct Torrent');
|
||||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directGid]);
|
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directGid]);
|
||||||
try {
|
try {
|
||||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
|
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
|
||||||
@@ -796,6 +832,8 @@ async function main() {
|
|||||||
timeout: '15',
|
timeout: '15',
|
||||||
'auto-file-renaming': 'false',
|
'auto-file-renaming': 'false',
|
||||||
}]);
|
}]);
|
||||||
|
const probeOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [probeGid]);
|
||||||
|
assertFixtureRouteOptions(probeOptions, 'fresh direct magnet probe');
|
||||||
const probeStatus = await waitForTerminal(client, probeGid, 30000);
|
const probeStatus = await waitForTerminal(client, probeGid, 30000);
|
||||||
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
|
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
|
||||||
const savedTorrentPaths = fs.readdirSync(probeDir)
|
const savedTorrentPaths = fs.readdirSync(probeDir)
|
||||||
@@ -841,7 +879,22 @@ async function main() {
|
|||||||
if (probeRemoved) await waitForRemoved(client, probeGid);
|
if (probeRemoved) await waitForRemoved(client, probeGid);
|
||||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(probeDir, { recursive: true });
|
fs.mkdirSync(probeDir, { recursive: true });
|
||||||
console.log('[OK] metadata probe was removed after resolution');
|
const proxiedProbeRoute = 'http://127.0.0.1:9';
|
||||||
|
const normalizedProxiedProbeRoute = new URL(proxiedProbeRoute).toString();
|
||||||
|
const proxiedProbeGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], {
|
||||||
|
dir: probeDir,
|
||||||
|
'bt-metadata-only': 'true',
|
||||||
|
'bt-save-metadata': 'true',
|
||||||
|
'all-proxy': proxiedProbeRoute,
|
||||||
|
pause: 'true',
|
||||||
|
'auto-file-renaming': 'false',
|
||||||
|
}]);
|
||||||
|
const proxiedProbeOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [proxiedProbeGid]);
|
||||||
|
assert(proxiedProbeOptions['all-proxy'] === normalizedProxiedProbeRoute, 'proxied magnet metadata probe did not retain the configured proxy route');
|
||||||
|
assertFixtureRouteOptions(proxiedProbeOptions, 'proxied magnet metadata probe');
|
||||||
|
assert(await forceRemoveIfPresent(client, proxiedProbeGid), 'proxied magnet metadata probe was not removable');
|
||||||
|
await waitForRemoved(client, proxiedProbeGid);
|
||||||
|
console.log('[OK] metadata probe was removed after resolution; direct and proxied probes kept system resolver options');
|
||||||
|
|
||||||
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
||||||
trackerlessTorrentBytes.toString('base64'),
|
trackerlessTorrentBytes.toString('base64'),
|
||||||
|
|||||||
+66
-65
@@ -1,33 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
|
import { sha256, treeDigest } from './engine-payload-integrity.js';
|
||||||
|
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
|
||||||
|
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
|
||||||
|
import {
|
||||||
|
assertSafeOutputRoot,
|
||||||
|
resolveOutputRoot,
|
||||||
|
resolveTargetTriple,
|
||||||
|
} from './engine-workspace.js';
|
||||||
|
import { assertAria2RouteSource } from './aria2-route-contract.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
|
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
|
||||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
|
||||||
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
|
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
|
||||||
|
const sourceLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8'));
|
||||||
|
|
||||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
const target = resolveTargetTriple();
|
||||||
const platformMap = {
|
const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [
|
||||||
darwin: 'apple-darwin',
|
repoRoot,
|
||||||
win32: 'pc-windows-msvc',
|
path.join(repoRoot, 'src-tauri'),
|
||||||
linux: 'unknown-linux-gnu',
|
path.join(repoRoot, 'src-tauri', 'engine-dist'),
|
||||||
};
|
]);
|
||||||
|
|
||||||
function argValue(name) {
|
|
||||||
const index = process.argv.indexOf(name);
|
|
||||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
|
|
||||||
const target = argValue('--target')
|
|
||||||
|| process.env.TAURI_ENV_TARGET_TRIPLE
|
|
||||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
|
||||||
|| hostTarget;
|
|
||||||
const isWindowsTarget = target.includes('windows');
|
const isWindowsTarget = target.includes('windows');
|
||||||
const suffix = isWindowsTarget ? '.exe' : '';
|
const suffix = isWindowsTarget ? '.exe' : '';
|
||||||
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||||
@@ -52,6 +48,15 @@ if (!source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetLock) {
|
if (targetLock) {
|
||||||
|
if (targetLock.engines?.aria2c?.firelinkRouteContract) {
|
||||||
|
try {
|
||||||
|
assertAria2RouteSource(targetLock.engines.aria2c, target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const engine of engines) {
|
for (const engine of engines) {
|
||||||
const name = `${engine}-${target}${suffix}`;
|
const name = `${engine}-${target}${suffix}`;
|
||||||
const expected = targetLock.engines?.[engine]?.sha256;
|
const expected = targetLock.engines?.[engine]?.sha256;
|
||||||
@@ -75,58 +80,54 @@ if (targetLock) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const manifestPath = path.join(source, 'payload-manifest.json');
|
const sourceTargetLock = sourceLock.targets?.[target];
|
||||||
if (!fs.existsSync(manifestPath)) {
|
if (!sourceTargetLock) {
|
||||||
console.error(`No committed lock or payload manifest exists for ${target}.`);
|
console.error(`No source lock exists for the provisioned engine target ${target}.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
try {
|
||||||
if (manifest.target !== target) {
|
const manifest = readAndValidatePayloadManifest(source, sourceTargetLock, target);
|
||||||
console.error(`Payload manifest target mismatch: ${manifest.target}`);
|
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
|
||||||
|
assertAria2RouteSource(manifest.generatedFrom.aria2c, target);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
for (const [relative, expected] of Object.entries(manifest.files || {})) {
|
}
|
||||||
const file = path.join(source, relative);
|
|
||||||
if (!fs.existsSync(file) || sha256(file) !== expected) {
|
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
|
||||||
console.error(`Payload manifest mismatch: ${relative}`);
|
const destination = path.join(outputRoot, target);
|
||||||
process.exit(1);
|
const temporaryRoot = fs.mkdtempSync(path.join(outputRoot, `.staging-${target}-${process.pid}-`));
|
||||||
|
const temporaryDestination = path.join(temporaryRoot, target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(temporaryDestination, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
for (const name of expectedNames) {
|
||||||
|
fs.copyFileSync(path.join(source, name), path.join(temporaryDestination, name));
|
||||||
|
if (!isWindowsTarget) {
|
||||||
|
fs.chmodSync(path.join(temporaryDestination, name), 0o755);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const actualFiles = collectRegularFiles(source, {
|
|
||||||
ignoredNames: ['payload-manifest.json'],
|
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||||
}).map(file => path.relative(source, file).split(path.sep).join('/'));
|
const sourceDir = path.join(source, runtimeDir);
|
||||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
if (fs.existsSync(sourceDir)) {
|
||||||
actualFiles.sort();
|
fs.cpSync(sourceDir, path.join(temporaryDestination, runtimeDir), {
|
||||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
recursive: true,
|
||||||
console.error(`Payload contains files not covered by manifest for ${target}.`);
|
dereference: false,
|
||||||
process.exit(1);
|
preserveTimestamps: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||||
|
if (fs.existsSync(payloadManifest)) {
|
||||||
const destination = path.join(outputRoot, target);
|
fs.copyFileSync(payloadManifest, path.join(temporaryDestination, 'payload-manifest.json'));
|
||||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
|
||||||
|
|
||||||
for (const name of expectedNames) {
|
|
||||||
fs.copyFileSync(path.join(source, name), path.join(destination, name));
|
|
||||||
if (!isWindowsTarget) {
|
|
||||||
fs.chmodSync(path.join(destination, name), 0o755);
|
|
||||||
}
|
}
|
||||||
|
await promoteDirectory(temporaryDestination, destination);
|
||||||
|
} finally {
|
||||||
|
await removePathWithRetry(temporaryRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
console.log(`Staged Firelink engines for ${target} from ${source} into ${destination}`);
|
||||||
const sourceDir = path.join(source, runtimeDir);
|
|
||||||
if (fs.existsSync(sourceDir)) {
|
|
||||||
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
|
|
||||||
recursive: true,
|
|
||||||
dereference: false,
|
|
||||||
preserveTimestamps: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
|
||||||
if (fs.existsSync(payloadManifest)) {
|
|
||||||
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Staged Firelink engines for ${target} from ${source}`);
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
createEngineWorkspace,
|
||||||
|
engineResourceConfig,
|
||||||
|
removeEngineWorkspace,
|
||||||
|
resolveTargetTriple,
|
||||||
|
} from './engine-workspace.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
|
||||||
|
const ENGINE_TREE_COMMANDS = new Set(['dev', 'build', 'bundle']);
|
||||||
|
|
||||||
|
export function commandUsesEngineTree(args) {
|
||||||
|
return args.some(argument => ENGINE_TREE_COMMANDS.has(argument));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandIsStandaloneBundle(args) {
|
||||||
|
return args.includes('bundle');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalExitCode(signal) {
|
||||||
|
return {
|
||||||
|
SIGHUP: 129,
|
||||||
|
SIGINT: 130,
|
||||||
|
SIGTERM: 143,
|
||||||
|
}[signal] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const usesEngineWorkspace = commandUsesEngineTree(args);
|
||||||
|
let engineWorkspace;
|
||||||
|
let child;
|
||||||
|
let receivedSignal;
|
||||||
|
let escalationTimer;
|
||||||
|
let interruptedProcessPid;
|
||||||
|
|
||||||
|
function windowsTaskkillPath() {
|
||||||
|
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
|
||||||
|
return systemRoot ? path.join(systemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceTerminateProcessTree(pid) {
|
||||||
|
if (!pid) return Promise.resolve();
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, 'SIGKILL');
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ESRCH') {
|
||||||
|
console.error(`[WARN] Could not force-terminate the Tauri process group: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
let killer;
|
||||||
|
try {
|
||||||
|
killer = spawn(
|
||||||
|
windowsTaskkillPath(),
|
||||||
|
['/PID', String(pid), '/T', '/F'],
|
||||||
|
{ stdio: 'ignore', windowsHide: true },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
killer.once('error', finish);
|
||||||
|
killer.once('close', finish);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignal(signal) {
|
||||||
|
receivedSignal ??= signal;
|
||||||
|
if (child && child.exitCode === null && child.signalCode === null) {
|
||||||
|
const pid = child.pid;
|
||||||
|
interruptedProcessPid ??= pid;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Node's Windows child.kill() does not reliably terminate descendants.
|
||||||
|
// taskkill's process-tree mode is the OS-supported equivalent of the
|
||||||
|
// POSIX process-group kill used below.
|
||||||
|
void forceTerminateProcessTree(pid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!pid) {
|
||||||
|
child.kill(signal);
|
||||||
|
} else {
|
||||||
|
process.kill(-pid, signal);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ESRCH') {
|
||||||
|
console.error(`[WARN] Could not terminate the Tauri process group: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!escalationTimer) {
|
||||||
|
escalationTimer = setTimeout(() => {
|
||||||
|
void forceTerminateProcessTree(pid);
|
||||||
|
}, 2_000);
|
||||||
|
escalationTimer.unref();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = signalExitCode(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runChild(command, commandArgs, env) {
|
||||||
|
const spawned = spawn(command, commandArgs, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
});
|
||||||
|
child = spawned;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = callback => value => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (child === spawned) child = undefined;
|
||||||
|
callback(value);
|
||||||
|
};
|
||||||
|
spawned.once('error', settle(reject));
|
||||||
|
spawned.once('close', (code, signal) => settle(resolve)({ code, signal }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const handlers = new Map(['SIGHUP', 'SIGINT', 'SIGTERM'].map(signal => [
|
||||||
|
signal,
|
||||||
|
() => handleSignal(signal),
|
||||||
|
]));
|
||||||
|
for (const [signal, handler] of handlers) process.once(signal, handler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = { ...process.env };
|
||||||
|
let commandArgs = args;
|
||||||
|
if (usesEngineWorkspace) {
|
||||||
|
const target = resolveTargetTriple(args, env);
|
||||||
|
engineWorkspace = createEngineWorkspace(target);
|
||||||
|
env.FIRELINK_ENGINE_WORKSPACE = engineWorkspace.workspace;
|
||||||
|
env.FIRELINK_ENGINE_OUTPUT_ROOT = engineWorkspace.outputRoot;
|
||||||
|
env.FIRELINK_ENGINE_RUNTIME_ROOT = engineWorkspace.runtimeRoot;
|
||||||
|
env.FIRELINK_TARGET_TRIPLE = target;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(args.includes('build') || args.includes('bundle'))
|
||||||
|
&& env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1'
|
||||||
|
) {
|
||||||
|
commandArgs = [...args, '--config', engineResourceConfig(engineWorkspace.outputRoot)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedSignal) return;
|
||||||
|
|
||||||
|
if (usesEngineWorkspace && commandIsStandaloneBundle(args) && env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1') {
|
||||||
|
let preparation;
|
||||||
|
try {
|
||||||
|
preparation = await runChild(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(repoRoot, 'scripts', 'prepare-tauri-engines.js')],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Engine preparation failed: ${error.message}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (receivedSignal) return;
|
||||||
|
if (preparation.signal) {
|
||||||
|
process.exitCode = signalExitCode(preparation.signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preparation.code !== 0) {
|
||||||
|
process.exitCode = preparation.code ?? 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
env.FIRELINK_ENGINE_BUNDLE_PREPARED = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedSignal) return;
|
||||||
|
|
||||||
|
const result = await runChild(process.execPath, [tauriCli, ...commandArgs], env);
|
||||||
|
|
||||||
|
if (receivedSignal) {
|
||||||
|
process.exitCode = signalExitCode(receivedSignal);
|
||||||
|
} else if (result.signal) {
|
||||||
|
process.exitCode = signalExitCode(result.signal);
|
||||||
|
} else {
|
||||||
|
process.exitCode = result.code ?? 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
||||||
|
if (escalationTimer) clearTimeout(escalationTimer);
|
||||||
|
if (interruptedProcessPid) await forceTerminateProcessTree(interruptedProcessPid);
|
||||||
|
if (engineWorkspace) {
|
||||||
|
try {
|
||||||
|
await removeEngineWorkspace(engineWorkspace.workspace);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WARN] Could not remove the temporary engine workspace: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
if (isMain) {
|
||||||
|
run().catch(error => {
|
||||||
|
console.error(`[FAIL] Tauri command failed: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { commandIsStandaloneBundle, commandUsesEngineTree } from './tauri-command.js';
|
||||||
|
|
||||||
|
test('Tauri engine-consuming commands use an engine workspace', () => {
|
||||||
|
assert.equal(commandUsesEngineTree(['dev']), true);
|
||||||
|
assert.equal(commandUsesEngineTree(['build', '--target', 'x86_64-unknown-linux-gnu']), true);
|
||||||
|
assert.equal(commandUsesEngineTree(['bundle', '--bundles', 'appimage']), true);
|
||||||
|
assert.equal(commandUsesEngineTree(['info']), false);
|
||||||
|
assert.equal(commandUsesEngineTree(['--help']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standalone bundle commands prepare engines before Tauri starts', () => {
|
||||||
|
assert.equal(commandIsStandaloneBundle(['bundle', '--bundles', 'app']), true);
|
||||||
|
assert.equal(commandIsStandaloneBundle(['build', '--bundles', 'app']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the bundle hook accepts a completed wrapper preflight', () => {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(import.meta.dirname, 'before-tauri-bundle.js')],
|
||||||
|
{
|
||||||
|
cwd: path.join(import.meta.dirname, '..'),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FIRELINK_ENGINE_BUNDLE_PREPARED: '1',
|
||||||
|
FIRELINK_SKIP_ENGINE_RESOURCE: '',
|
||||||
|
FIRELINK_ENGINE_OUTPUT_ROOT: '',
|
||||||
|
},
|
||||||
|
stdio: 'pipe',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr.toString());
|
||||||
|
});
|
||||||
@@ -5,6 +5,17 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
resolveOutputRoot,
|
||||||
|
resolveTargetTriple,
|
||||||
|
} from './engine-workspace.js';
|
||||||
|
import {
|
||||||
|
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
|
assertAria2Baseline,
|
||||||
|
assertAria2AllocationCapabilities,
|
||||||
|
assertAria2RouteSource,
|
||||||
|
} from './aria2-route-contract.js';
|
||||||
|
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -29,9 +40,7 @@ if (!currentArch || !currentPlatform) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTriple = argValue('--target')
|
const targetTriple = resolveTargetTriple();
|
||||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
|
||||||
|| `${currentArch}-${currentPlatform}`;
|
|
||||||
const hostTriple = `${currentArch}-${currentPlatform}`;
|
const hostTriple = `${currentArch}-${currentPlatform}`;
|
||||||
const canExecuteTarget = targetTriple === hostTriple;
|
const canExecuteTarget = targetTriple === hostTriple;
|
||||||
const isWindows = targetTriple.includes('windows');
|
const isWindows = targetTriple.includes('windows');
|
||||||
@@ -41,6 +50,10 @@ const ext = isWindows ? '.exe' : '';
|
|||||||
const suffix = `-${targetTriple}${ext}`;
|
const suffix = `-${targetTriple}${ext}`;
|
||||||
|
|
||||||
const scriptsDir = __dirname;
|
const scriptsDir = __dirname;
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
const sourceLock = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||||
|
);
|
||||||
const searchRoot = argValue('--search-root');
|
const searchRoot = argValue('--search-root');
|
||||||
function findEngineRoot(root) {
|
function findEngineRoot(root) {
|
||||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||||
@@ -81,7 +94,7 @@ function findEngineRoot(root) {
|
|||||||
|
|
||||||
const configuredRoot = argValue('--root')
|
const configuredRoot = argValue('--root')
|
||||||
|| (process.argv.includes('--staged')
|
|| (process.argv.includes('--staged')
|
||||||
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
? path.join(resolveOutputRoot(), targetTriple)
|
||||||
: searchRoot
|
: searchRoot
|
||||||
? findEngineRoot(searchRoot)
|
? findEngineRoot(searchRoot)
|
||||||
: null);
|
: null);
|
||||||
@@ -89,6 +102,8 @@ const binariesDir = configuredRoot
|
|||||||
? path.resolve(configuredRoot)
|
? path.resolve(configuredRoot)
|
||||||
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||||
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||||
|
const stagedVerification = process.argv.includes('--staged');
|
||||||
|
const sourceTargetLock = sourceLock.targets?.[targetTriple];
|
||||||
|
|
||||||
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
||||||
const FORBIDDEN_STDERR = [
|
const FORBIDDEN_STDERR = [
|
||||||
@@ -109,6 +124,23 @@ function ok(msg) {
|
|||||||
console.log(`[OK] ${msg}`);
|
console.log(`[OK] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceTargetLock) {
|
||||||
|
try {
|
||||||
|
const manifest = readAndValidatePayloadManifest(binariesDir, sourceTargetLock, targetTriple);
|
||||||
|
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
|
||||||
|
assertAria2RouteSource(manifest.generatedFrom.aria2c, targetTriple);
|
||||||
|
}
|
||||||
|
ok('Payload manifest provenance and checksums');
|
||||||
|
} catch (error) {
|
||||||
|
fail(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
console.error('\nAborting: engine payload integrity checks failed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
function rejectSymlinks(root, label) {
|
function rejectSymlinks(root, label) {
|
||||||
if (!fs.existsSync(root)) {
|
if (!fs.existsSync(root)) {
|
||||||
return;
|
return;
|
||||||
@@ -152,9 +184,13 @@ function engineEnv(engine) {
|
|||||||
return process.env;
|
return process.env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||||
return {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENSSL_MODULES: modulesDir,
|
OPENSSL_MODULES: modulesDir,
|
||||||
|
...(process.platform === 'win32'
|
||||||
|
? { [pathKey]: `${modulesDir}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +502,7 @@ if (canExecuteTarget) {
|
|||||||
'--quiet',
|
'--quiet',
|
||||||
'--console-log-level=error',
|
'--console-log-level=error',
|
||||||
'--rpc-listen-all=false',
|
'--rpc-listen-all=false',
|
||||||
|
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||||
], {
|
], {
|
||||||
env: engineEnv('aria2c'),
|
env: engineEnv('aria2c'),
|
||||||
stdio: ['ignore', 'ignore', 'pipe'],
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
@@ -560,6 +597,8 @@ if (canExecuteTarget) {
|
|||||||
try {
|
try {
|
||||||
const resp = JSON.parse(result.data);
|
const resp = JSON.parse(result.data);
|
||||||
if (resp?.result?.version) {
|
if (resp?.result?.version) {
|
||||||
|
assertAria2Baseline(resp.result);
|
||||||
|
assertAria2AllocationCapabilities(resp.result);
|
||||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||||
} else {
|
} else {
|
||||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ function readJson(file) {
|
|||||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function exactVersionTag(extensionRoot, expectedTag) {
|
export function exactVersionTag(extensionRoot, expectedTag) {
|
||||||
try {
|
try {
|
||||||
const tags = execFileSync(
|
const tags = execFileSync(
|
||||||
'git',
|
'git',
|
||||||
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
|
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map(tag => tag.trim())
|
.map(tag => tag.trim())
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { verifyCompanionRelease } from './verify-companion-release.js';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
|
||||||
|
|
||||||
function createFixture(packageVersion, manifestVersion) {
|
function createFixture(packageVersion, manifestVersion) {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
|
||||||
@@ -116,3 +117,35 @@ test('rejects a Companion tag for another version', () => {
|
|||||||
fs.rmSync(root, { recursive: true, force: true });
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
|
||||||
|
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
||||||
|
try {
|
||||||
|
const gitEnv = {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
GIT_AUTHOR_NAME: 'Test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||||
|
GIT_COMMITTER_NAME: 'Test',
|
||||||
|
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||||
|
};
|
||||||
|
execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
|
||||||
|
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
|
||||||
|
execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
|
||||||
|
|
||||||
|
const globalConfig = path.join(root, 'global.gitconfig');
|
||||||
|
fs.writeFileSync(globalConfig, '[alias]\n\ttag = !printf "v2.0.8\\n"\n');
|
||||||
|
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
||||||
|
assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
|
||||||
|
assert.equal(exactVersionTag(root, 'v2.0.8'), null);
|
||||||
|
} finally {
|
||||||
|
if (previousGlobalConfig === undefined) {
|
||||||
|
delete process.env.GIT_CONFIG_GLOBAL;
|
||||||
|
} else {
|
||||||
|
process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
||||||
|
}
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const windowStyles = fs.readFileSync('src/index.css', 'utf8');
|
||||||
|
const propertiesWindowSource = fs.readFileSync('src-tauri/src/properties_window.rs', 'utf8');
|
||||||
|
const mainWindowSource = fs.readFileSync('src-tauri/src/lib.rs', 'utf8');
|
||||||
|
const windowsConfiguration = JSON.parse(
|
||||||
|
fs.readFileSync('src-tauri/tauri.windows.conf.json', 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
const cssBlock = selector => {
|
||||||
|
const opening = `${selector} {`;
|
||||||
|
const start = windowStyles.indexOf(opening);
|
||||||
|
assert.notEqual(start, -1, `${selector} should exist`);
|
||||||
|
const end = windowStyles.indexOf('}', start + opening.length);
|
||||||
|
assert.notEqual(end, -1, `${selector} should have a closing brace`);
|
||||||
|
return windowStyles.slice(start, end + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Windows native shadows remain disabled for the main and Properties windows', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
transparent: windowsConfiguration.app.windows[0].transparent,
|
||||||
|
decorations: windowsConfiguration.app.windows[0].decorations,
|
||||||
|
shadow: windowsConfiguration.app.windows[0].shadow,
|
||||||
|
},
|
||||||
|
{ transparent: true, decorations: false, shadow: false }
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
mainWindowSource,
|
||||||
|
/let main_window_config = app\s*\.config\(\)\s*\.app\s*\.windows[\s\S]*?\.find\(\|window\| window\.label == "main"\)/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
mainWindowSource,
|
||||||
|
/WebviewWindowBuilder::from_config\(\s*app\.handle\(\),\s*&main_window_config,\s*\)/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
propertiesWindowSource,
|
||||||
|
/#\[cfg\(target_os = "windows"\)\]\s*let builder = builder\.transparent\(true\)\.shadow\(false\);/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows selects stronger renderer-owned contours for every theme', () => {
|
||||||
|
const expectedTokens = [
|
||||||
|
[':root', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
|
||||||
|
['.theme-light', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
|
||||||
|
['.theme-dark', '0 0% 100% / 0.35', '0 0% 100% / 0.14'],
|
||||||
|
['.theme-dracula', '228 14% 84% / 0.45', '228 14% 84% / 0.18'],
|
||||||
|
['.theme-nord', '218 27% 88% / 0.45', '218 27% 88% / 0.18'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [selector, active, inactive] of expectedTokens) {
|
||||||
|
const block = cssBlock(selector);
|
||||||
|
assert.match(block, new RegExp(`--window-frame-windows-active: ${active.replace('.', '\\.')}\\s*;`));
|
||||||
|
assert.match(block, new RegExp(`--window-frame-windows-inactive: ${inactive.replace('.', '\\.')}\\s*;`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsBlock = cssBlock('html[data-platform="windows"]');
|
||||||
|
assert.match(
|
||||||
|
windowsBlock,
|
||||||
|
/--window-frame-active:\s*var\(--window-frame-windows-active\);/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
windowsBlock,
|
||||||
|
/--window-frame-inactive:\s*var\(--window-frame-windows-inactive\);/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maximized Windows shells remain square and borderless', () => {
|
||||||
|
assert.match(
|
||||||
|
windowStyles,
|
||||||
|
/html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-color:\s*transparent;/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
windowStyles,
|
||||||
|
/html\[data-platform="linux"\] :is\(\.app-shell, \.properties-window-shell\),\s*html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-radius:\s*0;/
|
||||||
|
);
|
||||||
|
});
|
||||||
Generated
+292
-389
File diff suppressed because it is too large
Load Diff
+19
-20
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "firelink"
|
name = "firelink"
|
||||||
version = "1.4.0"
|
version = "1.4.2"
|
||||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||||
authors = ["NimBold"]
|
authors = ["NimBold"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -24,46 +24,45 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2.5.5"
|
||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.3"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2.3.6"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
rustls = { version = "0.23.44", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.4.0"
|
||||||
tauri-plugin-clipboard-manager = "2.3.2"
|
tauri-plugin-clipboard-manager = "2.3.3"
|
||||||
sysinfo = "0.39.3"
|
sysinfo = "0.39.6"
|
||||||
hmac = "0.13"
|
hmac = "0.13"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
sha1 = "0.10"
|
sha1 = "0.11"
|
||||||
base64 = "0.22"
|
base64 = { version = "0.23.1", default-features = false, features = ["std"] }
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2.4.10"
|
||||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
thiserror = "2.0.19"
|
thiserror = "2.0.20"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||||
sysproxy = "0.3.0"
|
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
keepawake = "0.6.0"
|
keepawake = "0.6.1"
|
||||||
system_shutdown = "4.1.0"
|
system_shutdown = "4.1.0"
|
||||||
tokio-tungstenite = "0.30.0"
|
tokio-tungstenite = "0.30.0"
|
||||||
futures-util = { version = "0.3.33", features = ["sink"] }
|
futures-util = { version = "0.3.33", features = ["sink"] }
|
||||||
chrono = "0.4.38"
|
chrono = "0.4.38"
|
||||||
url = "2"
|
url = "2"
|
||||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||||
log = "0.4.32"
|
log = "0.4.34"
|
||||||
tauri-plugin-log = "2.9.0"
|
tauri-plugin-log = "2.9.1"
|
||||||
trash = "5"
|
trash = "5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
keyring-core = "1.0.0"
|
keyring-core = "1.0.0"
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
|
apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] }
|
||||||
objc = "0.2.7"
|
objc = "0.2.7"
|
||||||
unicode-normalization = "0.1.25"
|
unicode-normalization = "0.1.25"
|
||||||
|
|
||||||
@@ -72,4 +71,4 @@ windows-native-keyring-store = "1.1.0"
|
|||||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
zbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-rust"] }
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Rust dependency advisory policy
|
||||||
|
|
||||||
|
`cargo audit` is a required CI gate. Vulnerability advisories must be resolved;
|
||||||
|
the gate must not be bypassed with a broad ignore list.
|
||||||
|
|
||||||
|
As of 2026-09-05, Cargo reports no vulnerability advisories. It does report
|
||||||
|
the following informational warnings, which remain visible in CI output:
|
||||||
|
|
||||||
|
- `RUSTSEC-2024-0411` through `RUSTSEC-2024-0420` (GTK3 bindings) and
|
||||||
|
`RUSTSEC-2024-0370` (`proc-macro-error`) are Linux-only dependencies reached
|
||||||
|
through Tauri/Wry's GTK3 and tray integration.
|
||||||
|
- `RUSTSEC-2024-0429` (`glib` 0.18.5 iterator unsoundness) is in that same
|
||||||
|
Linux Tauri/Wry GTK3 graph. Firelink does not directly use
|
||||||
|
`glib::VariantStrIter`, but this remains an upstream risk rather than a
|
||||||
|
Firelink-level remediation.
|
||||||
|
- `RUSTSEC-2025-0075`, `RUSTSEC-2025-0080`, `RUSTSEC-2025-0081`,
|
||||||
|
`RUSTSEC-2025-0098`, and `RUSTSEC-2025-0100` are unmaintained UNIC crates
|
||||||
|
reached through `tauri-utils -> urlpattern`.
|
||||||
|
|
||||||
|
Review these paths with every Tauri/Wry update and no later than 2026-12-05.
|
||||||
|
Remove this acknowledgement when the upstream graph no longer contains the
|
||||||
|
affected packages. Do not add these advisory IDs to Cargo's ignore list: a
|
||||||
|
future severity change must remain visible.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
std::fs::create_dir_all("engine-dist")
|
|
||||||
.expect("failed to create generated engine resource directory");
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
+888
-2
@@ -7,7 +7,7 @@ use std::sync::Mutex;
|
|||||||
const DATABASE_NAME: &str = "firelink.sqlite";
|
const DATABASE_NAME: &str = "firelink.sqlite";
|
||||||
const LEGACY_STORE_NAME: &str = "store.bin";
|
const LEGACY_STORE_NAME: &str = "store.bin";
|
||||||
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
||||||
const CURRENT_SCHEMA_VERSION: i64 = 3;
|
const CURRENT_SCHEMA_VERSION: i64 = 4;
|
||||||
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
||||||
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||||
// Development builds are a different executable identity from the packaged
|
// Development builds are a different executable identity from the packaged
|
||||||
@@ -105,6 +105,7 @@ fn init_at_path_internal(
|
|||||||
migrate_schema(&mut connection, version)?;
|
migrate_schema(&mut connection, version)?;
|
||||||
|
|
||||||
import_legacy_data(&mut connection, app_data_dir, portable)?;
|
import_legacy_data(&mut connection, app_data_dir, portable)?;
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, app_data_dir, portable)?;
|
||||||
if portable {
|
if portable {
|
||||||
sanitize_persisted_downloads(&mut connection)?;
|
sanitize_persisted_downloads(&mut connection)?;
|
||||||
}
|
}
|
||||||
@@ -227,6 +228,16 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
|
|||||||
.map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?;
|
.map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from_version < 4 {
|
||||||
|
transaction.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS download_removal_jobs (
|
||||||
|
id TEXT PRIMARY KEY, data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS download_removal_assets (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
||||||
|
").map_err(|error| format!("failed to migrate removal jobs: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
transaction
|
transaction
|
||||||
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
||||||
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
||||||
@@ -309,6 +320,399 @@ fn import_legacy_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn recover_downloads_from_migration_backup(
|
||||||
|
connection: &mut Connection,
|
||||||
|
app_data_dir: &Path,
|
||||||
|
portable: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
const RECOVERY_MARKER: &str = "migration-backup-recovered:schema-v3";
|
||||||
|
if !table_exists(connection, "metadata")? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let recovery_status = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM metadata WHERE key = ?1",
|
||||||
|
params![RECOVERY_MARKER],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|error| format!("failed to read recovery status: {error}"))?;
|
||||||
|
if !table_exists(connection, "downloads")? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_downloads_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM downloads", [], |row| row.get(0))
|
||||||
|
.map_err(|error| format!("failed to count downloads for recovery: {error}"))?;
|
||||||
|
|
||||||
|
let mut malformed_download_ids = std::collections::HashSet::new();
|
||||||
|
{
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare("SELECT id, data FROM downloads")
|
||||||
|
.map_err(|error| format!("failed to inspect downloads for recovery: {error}"))?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to query downloads for recovery: {error}"))?;
|
||||||
|
for row in rows {
|
||||||
|
let (id, data) = row
|
||||||
|
.map_err(|error| format!("failed to read download for recovery: {error}"))?;
|
||||||
|
let valid_record = serde_json::from_str::<Value>(&data)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.is_some_and(|stored_id| stored_id == id && !stored_id.is_empty());
|
||||||
|
if !valid_record {
|
||||||
|
malformed_download_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A previous startup could have persisted a partial renderer snapshot:
|
||||||
|
// the downloads table was replaced, but the native ownership registry was
|
||||||
|
// deliberately retained. In that state an owned target is correctly
|
||||||
|
// protected from replacement, yet its download row is invisible to the
|
||||||
|
// renderer and the duplicate modal has no safe Replace action. Repair
|
||||||
|
// only IDs with current durable evidence: ownership-backed missing rows,
|
||||||
|
// nonterminal removal jobs, or an existing row whose persisted document
|
||||||
|
// is malformed. They must not be covered by a completed removal job.
|
||||||
|
// Never reconstruct an unknown ID from a backup without one of those
|
||||||
|
// durable records.
|
||||||
|
// A completed full recovery does not make the ownership registry
|
||||||
|
// self-healing: a later stale renderer snapshot can still remove rows
|
||||||
|
// while leaving native ownership intact. Any existing marker therefore
|
||||||
|
// suppresses another unscoped full restore, but still permits this narrow
|
||||||
|
// ownership-backed repair pass.
|
||||||
|
let has_removal_jobs_table = table_exists(connection, "download_removal_jobs")?;
|
||||||
|
let has_download_ownership_table = table_exists(connection, "download_ownership")?;
|
||||||
|
let ownership_count: i64 = if has_download_ownership_table {
|
||||||
|
connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM download_ownership", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to count download ownership for recovery: {error}"))?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let mut tombstoned_removal_ids = std::collections::HashSet::new();
|
||||||
|
let mut recoverable_removal_ids = std::collections::HashSet::new();
|
||||||
|
if has_removal_jobs_table {
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare("SELECT id, data FROM download_removal_jobs")
|
||||||
|
.map_err(|error| format!("failed to read removal jobs for recovery: {error}"))?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to query removal jobs for recovery: {error}"))?;
|
||||||
|
for row in rows {
|
||||||
|
let (id, data) = row
|
||||||
|
.map_err(|error| format!("failed to read removal job for recovery: {error}"))?;
|
||||||
|
let nonterminal = serde_json::from_str::<Value>(&data)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("phase").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.is_some_and(|phase| matches!(phase.as_str(), "pending" | "running" | "failed"));
|
||||||
|
// Pending, running, and failed jobs retain recoverable download
|
||||||
|
// intent. Completed jobs are the durable deletion tombstone. An
|
||||||
|
// invalid or unknown phase remains protected conservatively.
|
||||||
|
if nonterminal {
|
||||||
|
recoverable_removal_ids.insert(id);
|
||||||
|
} else {
|
||||||
|
tombstoned_removal_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let orphan_recovery = current_downloads_count > 0
|
||||||
|
|| recovery_status.is_some()
|
||||||
|
|| ownership_count > 0
|
||||||
|
|| !malformed_download_ids.is_empty()
|
||||||
|
|| !recoverable_removal_ids.is_empty();
|
||||||
|
let mut orphan_ids = std::collections::HashSet::new();
|
||||||
|
if orphan_recovery && has_download_ownership_table {
|
||||||
|
let query = "SELECT ownership.id
|
||||||
|
FROM download_ownership AS ownership
|
||||||
|
LEFT JOIN downloads AS downloads ON downloads.id = ownership.id
|
||||||
|
WHERE downloads.id IS NULL";
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare(query)
|
||||||
|
.map_err(|error| format!("failed to find orphaned download ownership: {error}"))?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.map_err(|error| format!("failed to query orphaned download ownership: {error}"))?;
|
||||||
|
for row in rows {
|
||||||
|
let id = row
|
||||||
|
.map_err(|error| format!("failed to read orphaned download ownership: {error}"))?;
|
||||||
|
if !tombstoned_removal_ids.contains(&id) {
|
||||||
|
orphan_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in malformed_download_ids {
|
||||||
|
if !tombstoned_removal_ids.contains(&id) {
|
||||||
|
orphan_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in recoverable_removal_ids {
|
||||||
|
let download_exists: bool = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM downloads WHERE id = ?1)",
|
||||||
|
[&id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to check removal download for recovery: {error}"))?;
|
||||||
|
if !download_exists {
|
||||||
|
orphan_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if orphan_recovery && orphan_ids.is_empty() {
|
||||||
|
if current_downloads_count > 0 && recovery_status.is_none() {
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO metadata (key, value) VALUES (?1, 'skipped')
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![RECOVERY_MARKER],
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to record recovery status: {error}"))?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_prefix = format!("{DATABASE_NAME}.backup-schema-v3-");
|
||||||
|
let entries = fs::read_dir(app_data_dir)
|
||||||
|
.map_err(|error| format!("failed to read app data directory for recovery: {error}"))?;
|
||||||
|
let mut backup_candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
if file_name.starts_with(&backup_prefix) {
|
||||||
|
if let Ok(metadata) = fs::symlink_metadata(&path) {
|
||||||
|
if metadata.is_file() && !metadata.file_type().is_symlink() {
|
||||||
|
backup_candidates.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backup_candidates.sort_by(|a, b| b.cmp(a));
|
||||||
|
|
||||||
|
let mut processed_valid_candidate = false;
|
||||||
|
let mut total_restored_count = 0;
|
||||||
|
for candidate in backup_candidates {
|
||||||
|
let Ok(backup_conn) = Connection::open(&candidate) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !table_exists(&backup_conn, "downloads").unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let backup_count: i64 = backup_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM downloads", [], |row| row.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if backup_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(mut backup_downloads) =
|
||||||
|
query_string_column(&backup_conn, "SELECT data FROM downloads ORDER BY rowid")
|
||||||
|
else {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to read downloads from migration backup candidate '{}'",
|
||||||
|
candidate.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !backup_downloads.iter().any(|data| {
|
||||||
|
serde_json::from_str::<Value>(data)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.is_some_and(|id| !id.is_empty())
|
||||||
|
}) {
|
||||||
|
log::warn!(
|
||||||
|
"Migration backup candidate '{}' contains no valid download records",
|
||||||
|
candidate.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if portable {
|
||||||
|
if let Err(error) = sanitize_download_strings(&mut backup_downloads) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to sanitize migration backup downloads from '{}': {error}",
|
||||||
|
candidate.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let transaction = connection
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| format!("failed to begin recovery transaction: {error}"))?;
|
||||||
|
|
||||||
|
let mut restored_ids = std::collections::HashSet::new();
|
||||||
|
let mut restored_queue_ids = std::collections::HashSet::new();
|
||||||
|
let mut restored_count = 0;
|
||||||
|
for data in &backup_downloads {
|
||||||
|
let Ok(value) = serde_json::from_str::<Value>(data) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(id) = value
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if tombstoned_removal_ids.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if orphan_recovery && !orphan_ids.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = value
|
||||||
|
.get("status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("completed");
|
||||||
|
let queue_id = value.get("queueId").and_then(Value::as_str);
|
||||||
|
let upsert = if orphan_recovery && orphan_ids.contains(id) {
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET status = excluded.status,
|
||||||
|
queue_id = excluded.queue_id, data = excluded.data"
|
||||||
|
} else {
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(id) DO NOTHING"
|
||||||
|
};
|
||||||
|
let inserted = transaction
|
||||||
|
.execute(upsert, params![id, status, queue_id, data])
|
||||||
|
.map_err(|error| format!("failed to restore download '{id}': {error}"))?;
|
||||||
|
if inserted > 0 {
|
||||||
|
restored_ids.insert(id.to_string());
|
||||||
|
if let Some(queue_id) = queue_id {
|
||||||
|
restored_queue_ids.insert(queue_id.to_string());
|
||||||
|
}
|
||||||
|
restored_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if table_exists(&backup_conn, "download_ownership").unwrap_or(false)
|
||||||
|
&& table_exists(&transaction, "download_ownership").unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Ok(mut stmt) = backup_conn.prepare("SELECT id, primary_path FROM download_ownership") {
|
||||||
|
if let Ok(rows) = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) {
|
||||||
|
for (id, primary_path) in rows.flatten() {
|
||||||
|
if restored_ids.contains(&id) {
|
||||||
|
let _ = transaction.execute(
|
||||||
|
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING",
|
||||||
|
params![id, primary_path],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table_exists(&backup_conn, "download_owned_paths").unwrap_or(false)
|
||||||
|
&& table_exists(&transaction, "download_owned_paths").unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Ok(mut stmt) = backup_conn.prepare("SELECT id, paths FROM download_owned_paths") {
|
||||||
|
if let Ok(rows) = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) {
|
||||||
|
for (id, paths) in rows.flatten() {
|
||||||
|
if restored_ids.contains(&id) {
|
||||||
|
let _ = transaction.execute(
|
||||||
|
"INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING",
|
||||||
|
params![id, paths],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table_exists(&backup_conn, "download_removal_paths").unwrap_or(false)
|
||||||
|
&& table_exists(&transaction, "download_removal_paths").unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Ok(mut stmt) = backup_conn.prepare("SELECT id, paths FROM download_removal_paths") {
|
||||||
|
if let Ok(rows) = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) {
|
||||||
|
for (id, paths) in rows.flatten() {
|
||||||
|
if restored_ids.contains(&id) {
|
||||||
|
let _ = transaction.execute(
|
||||||
|
"INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING",
|
||||||
|
params![id, paths],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if table_exists(&backup_conn, "queues").unwrap_or(false)
|
||||||
|
&& table_exists(&transaction, "queues").unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Ok(mut stmt) = backup_conn.prepare("SELECT id, data FROM queues") {
|
||||||
|
if let Ok(rows) = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) {
|
||||||
|
for (id, data) in rows.flatten() {
|
||||||
|
if !orphan_recovery || restored_queue_ids.contains(&id) {
|
||||||
|
let _ = transaction.execute(
|
||||||
|
"INSERT INTO queues (id, data) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING",
|
||||||
|
params![id, data],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !orphan_recovery {
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO metadata (key, value) VALUES (?1, 'complete')
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![RECOVERY_MARKER],
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to record recovery completion: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction
|
||||||
|
.commit()
|
||||||
|
.map_err(|error| format!("failed to commit recovery: {error}"))?;
|
||||||
|
|
||||||
|
processed_valid_candidate = true;
|
||||||
|
total_restored_count += restored_count;
|
||||||
|
if orphan_recovery {
|
||||||
|
for id in restored_ids {
|
||||||
|
orphan_ids.remove(&id);
|
||||||
|
}
|
||||||
|
if orphan_ids.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Restored {restored_count} download(s) from migration backup '{}'",
|
||||||
|
candidate.display()
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if orphan_recovery && processed_valid_candidate {
|
||||||
|
if orphan_ids.is_empty() {
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO metadata (key, value) VALUES (?1, 'complete')
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![RECOVERY_MARKER],
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to record recovery completion: {error}"))?;
|
||||||
|
log::info!(
|
||||||
|
"Reconciled {total_restored_count} orphaned download(s) from migration backups"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Could not reconcile {} orphaned download(s) from migration backups",
|
||||||
|
orphan_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> {
|
fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> {
|
||||||
match fs::symlink_metadata(path) {
|
match fs::symlink_metadata(path) {
|
||||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
@@ -1582,12 +1986,17 @@ fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), Strin
|
|||||||
|
|
||||||
fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> {
|
fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> {
|
||||||
transaction
|
transaction
|
||||||
.execute("DELETE FROM downloads", [])
|
.execute("DELETE FROM downloads WHERE id NOT IN (SELECT id FROM download_removal_jobs)", [])
|
||||||
.map_err(|error| format!("failed to clear downloads: {error}"))?;
|
.map_err(|error| format!("failed to clear downloads: {error}"))?;
|
||||||
for data in downloads {
|
for data in downloads {
|
||||||
let value: Value = serde_json::from_str(data)
|
let value: Value = serde_json::from_str(data)
|
||||||
.map_err(|error| format!("failed to decode download: {error}"))?;
|
.map_err(|error| format!("failed to decode download: {error}"))?;
|
||||||
let id = required_string(&value, "id")?;
|
let id = required_string(&value, "id")?;
|
||||||
|
// Native removal intent and terminal tombstones outrank renderer snapshots.
|
||||||
|
if transaction.query_row("SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", [id], |row| row.get::<_, bool>(0))
|
||||||
|
.map_err(|error| error.to_string())? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let status = required_string(&value, "status")?;
|
let status = required_string(&value, "status")?;
|
||||||
let queue_id = value.get("queueId").and_then(Value::as_str);
|
let queue_id = value.get("queueId").and_then(Value::as_str);
|
||||||
transaction
|
transaction
|
||||||
@@ -2400,6 +2809,37 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removal_jobs_preserve_intent_and_prevent_stale_snapshot_resurrection() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
let original = r#"[{"id":"remove-me","status":"paused","fileName":"payload"},{"id":"keep-me","status":"paused"}]"#;
|
||||||
|
replace_downloads(&mut connection, original, false).unwrap();
|
||||||
|
connection.execute("INSERT INTO download_removal_jobs VALUES ('remove-me', ?1)",
|
||||||
|
[r#"{"id":"remove-me","deleteAssets":true,"phase":"pending","error":null}"#]).unwrap();
|
||||||
|
replace_downloads(&mut connection, r#"[{"id":"keep-me","status":"completed"}]"#, false).unwrap();
|
||||||
|
assert_eq!(load_downloads(&connection).unwrap().len(), 2);
|
||||||
|
mutate_download(&mut connection, "remove-me", false, |row| {
|
||||||
|
row.insert("status".into(), json!("completed"));
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
replace_downloads(&mut connection, original, false).unwrap();
|
||||||
|
let completed: String = connection.query_row("SELECT status FROM downloads WHERE id='remove-me'", [], |row| row.get(0)).unwrap();
|
||||||
|
assert_eq!(completed, "completed");
|
||||||
|
connection.execute("DELETE FROM downloads WHERE id='remove-me'", []).unwrap();
|
||||||
|
connection.execute("UPDATE download_removal_jobs SET data=?1 WHERE id='remove-me'",
|
||||||
|
[r#"{"id":"remove-me","deleteAssets":true,"phase":"completed","error":null}"#]).unwrap();
|
||||||
|
replace_downloads(&mut connection, original, false).unwrap();
|
||||||
|
let saved = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(saved.len(), 1);
|
||||||
|
assert!(saved[0].contains("keep-me"));
|
||||||
|
drop(connection);
|
||||||
|
drop(db);
|
||||||
|
let reopened = init_at_path(root.path()).unwrap();
|
||||||
|
assert_eq!(load_downloads(&reopened.lock().unwrap()).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn site_login_settings_update_preserves_envelope_without_password() {
|
fn site_login_settings_update_preserves_envelope_without_password() {
|
||||||
let original = json!({
|
let original = json!({
|
||||||
@@ -3854,4 +4294,450 @@ mod tests {
|
|||||||
3
|
3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_restores_empty_downloads_and_excludes_tombstones() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root.path().join("firelink.sqlite.backup-schema-v3-20260907T120000Z-unit");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn.execute_batch("
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('keep-1', 'completed', 'main', '{\"id\":\"keep-1\",\"fileName\":\"keep1.bin\",\"status\":\"completed\"}');
|
||||||
|
INSERT INTO downloads VALUES ('keep-2', 'completed', 'main', '{\"id\":\"keep-2\",\"fileName\":\"keep2.bin\",\"status\":\"completed\"}');
|
||||||
|
INSERT INTO downloads VALUES ('deleted-tombstone', 'completed', 'main', '{\"id\":\"deleted-tombstone\",\"fileName\":\"deleted.bin\",\"status\":\"completed\"}');
|
||||||
|
CREATE TABLE download_ownership (id TEXT PRIMARY KEY, primary_path TEXT NOT NULL);
|
||||||
|
INSERT INTO download_ownership VALUES ('keep-1', '/path/to/keep1.bin');
|
||||||
|
INSERT INTO download_ownership VALUES ('deleted-tombstone', '/path/to/deleted.bin');
|
||||||
|
CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
||||||
|
INSERT INTO queues VALUES ('custom-queue', '{\"id\":\"custom-queue\",\"name\":\"Custom\"}');
|
||||||
|
").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate that a removal job already exists in download_removal_jobs
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO download_removal_jobs (id, data) VALUES (?1, ?2)",
|
||||||
|
params![
|
||||||
|
"deleted-tombstone",
|
||||||
|
r#"{"id":"deleted-tombstone","revision":1,"deleteAssets":true,"phase":"completed","error":null}"#
|
||||||
|
],
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert!(loaded.iter().any(|d| d.contains("keep-1")));
|
||||||
|
assert!(loaded.iter().any(|d| d.contains("keep-2")));
|
||||||
|
assert!(!loaded.iter().any(|d| d.contains("deleted-tombstone")));
|
||||||
|
|
||||||
|
// Ownership should be restored for keep-1 but not for deleted-tombstone
|
||||||
|
assert_eq!(
|
||||||
|
connection.query_row::<String, _, _>(
|
||||||
|
"SELECT primary_path FROM download_ownership WHERE id = 'keep-1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0)
|
||||||
|
).unwrap(),
|
||||||
|
"/path/to/keep1.bin"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
connection.query_row::<i64, _, _>(
|
||||||
|
"SELECT COUNT(*) FROM download_ownership WHERE id = 'deleted-tombstone'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0)
|
||||||
|
).unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom queue should be restored
|
||||||
|
assert!(load_queues(&connection).unwrap().iter().any(|q| q.contains("custom-queue")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
connection.query_row::<String, _, _>(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0)
|
||||||
|
).unwrap(),
|
||||||
|
"complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_skips_when_downloads_exist() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root.path().join("firelink.sqlite.backup-schema-v3-20260907T120000Z-unit");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn.execute_batch("
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('from-backup', 'completed', 'main', '{\"id\":\"from-backup\"}');
|
||||||
|
").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES ('existing', 'completed', 'main', '{\"id\":\"existing\"}')",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
connection.execute("DELETE FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'", []).unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert!(loaded[0].contains("existing"));
|
||||||
|
assert_eq!(
|
||||||
|
connection.query_row::<String, _, _>(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0)
|
||||||
|
).unwrap(),
|
||||||
|
"skipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_repairs_orphaned_owned_rows() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-partial");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('orphaned', 'completed', 'custom-queue', '{"id":"orphaned","fileName":"orphaned.bin","status":"completed","queueId":"custom-queue"}');
|
||||||
|
INSERT INTO downloads VALUES ('not-owned', 'completed', 'custom-queue', '{"id":"not-owned","fileName":"not-owned.bin","status":"completed","queueId":"custom-queue"}');
|
||||||
|
CREATE TABLE download_ownership (id TEXT PRIMARY KEY, primary_path TEXT NOT NULL);
|
||||||
|
INSERT INTO download_ownership VALUES ('orphaned', '/downloads/orphaned.bin');
|
||||||
|
INSERT INTO download_ownership VALUES ('not-owned', '/downloads/not-owned.bin');
|
||||||
|
CREATE TABLE download_owned_paths (id TEXT PRIMARY KEY, paths TEXT NOT NULL);
|
||||||
|
INSERT INTO download_owned_paths VALUES ('orphaned', '["/downloads/orphaned.bin"]');
|
||||||
|
CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
||||||
|
INSERT INTO queues VALUES ('custom-queue', '{"id":"custom-queue","name":"Custom"}');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a live row surviving a partial renderer snapshot while its
|
||||||
|
// previously persisted ownership record has no corresponding row.
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES ('new-download', 'failed', 'main', '{\"id\":\"new-download\",\"status\":\"failed\"}')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_ownership (id, primary_path) VALUES ('orphaned', '/downloads/orphaned.bin')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_owned_paths (id, paths) VALUES ('orphaned', '[\"/downloads/orphaned.bin\"]')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO metadata (key, value) VALUES ('migration-backup-recovered:schema-v3', 'skipped')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert!(loaded.iter().any(|data| data.contains("new-download")));
|
||||||
|
assert!(loaded.iter().any(|data| data.contains("orphaned")));
|
||||||
|
assert!(!loaded.iter().any(|data| data.contains("not-owned")));
|
||||||
|
assert!(load_queues(&connection)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|data| data.contains("custom-queue")));
|
||||||
|
assert_eq!(
|
||||||
|
connection
|
||||||
|
.query_row::<String, _, _>(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A later startup must not duplicate a repaired row.
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
assert_eq!(load_downloads(&connection).unwrap().len(), 2);
|
||||||
|
|
||||||
|
// The repair remains available even after the marker becomes
|
||||||
|
// complete, because a later stale snapshot can create the same
|
||||||
|
// orphan shape again.
|
||||||
|
connection
|
||||||
|
.execute("DELETE FROM downloads WHERE id = 'orphaned'", [])
|
||||||
|
.unwrap();
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
assert_eq!(load_downloads(&connection).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_narrows_empty_table_to_owned_rows() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-empty");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('owned', 'completed', 'main', '{"id":"owned","fileName":"owned.bin","status":"completed"}');
|
||||||
|
INSERT INTO downloads VALUES ('not-owned', 'completed', 'main', '{"id":"not-owned","fileName":"not-owned.bin","status":"completed"}');
|
||||||
|
CREATE TABLE download_ownership (id TEXT PRIMARY KEY, primary_path TEXT NOT NULL);
|
||||||
|
INSERT INTO download_ownership VALUES ('owned', '/downloads/owned.bin');
|
||||||
|
INSERT INTO download_ownership VALUES ('not-owned', '/downloads/not-owned.bin');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty downloads table is ambiguous once durable native ownership
|
||||||
|
// exists. Restore only the ownership-backed IDs; never repopulate the
|
||||||
|
// UI from unrelated backup rows.
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_ownership (id, primary_path) VALUES ('owned', '/downloads/owned.bin')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert!(loaded[0].contains("owned"));
|
||||||
|
assert!(!loaded[0].contains("not-owned"));
|
||||||
|
assert_eq!(
|
||||||
|
connection
|
||||||
|
.query_row::<String, _, _>(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_restores_nonterminal_removal_jobs() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-removals");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('pending-removal', 'paused', 'main', '{"id":"pending-removal","status":"paused"}');
|
||||||
|
INSERT INTO downloads VALUES ('failed-removal', 'failed', 'main', '{"id":"failed-removal","status":"failed"}');
|
||||||
|
INSERT INTO downloads VALUES ('completed-removal', 'completed', 'main', '{"id":"completed-removal","status":"completed"}');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, phase) in [
|
||||||
|
("pending-removal", "pending"),
|
||||||
|
("failed-removal", "failed"),
|
||||||
|
("completed-removal", "completed"),
|
||||||
|
] {
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_removal_jobs (id, data) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![
|
||||||
|
id,
|
||||||
|
format!(
|
||||||
|
"{{\"id\":\"{id}\",\"revision\":1,\"deleteAssets\":true,\"phase\":\"{phase}\",\"error\":null}}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert!(loaded.iter().any(|data| data.contains("pending-removal")));
|
||||||
|
assert!(loaded.iter().any(|data| data.contains("failed-removal")));
|
||||||
|
assert!(!loaded.iter().any(|data| data.contains("completed-removal")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_does_not_mark_unresolved_orphans_complete() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-unrelated");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('unrelated', 'completed', 'main', '{"id":"unrelated","status":"completed"}');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES ('live', 'completed', 'main', '{\"id\":\"live\",\"status\":\"completed\"}')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_ownership (id, primary_path) VALUES ('missing-from-backup', '/downloads/missing.bin')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(load_downloads(&connection).unwrap().len(), 1);
|
||||||
|
let recovery_marker: Option<String> = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap();
|
||||||
|
assert!(recovery_marker.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_repairs_malformed_rows_by_id() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
let backup_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-malformed-row");
|
||||||
|
{
|
||||||
|
let backup_conn = Connection::open(&backup_path).unwrap();
|
||||||
|
backup_conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('corrupt', 'completed', 'main', '{"id":"corrupt","status":"completed"}');
|
||||||
|
INSERT INTO downloads VALUES ('unrelated', 'completed', 'main', '{"id":"unrelated","status":"completed"}');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO downloads (id, status, queue_id, data) VALUES ('corrupt', 'completed', 'main', 'not-json')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert!(loaded[0].contains("corrupt"));
|
||||||
|
assert!(!loaded[0].contains("unrelated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_downloads_from_migration_backup_skips_corrupt_candidate_and_continues_to_valid() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let corrupt_path = root.path().join("firelink.sqlite.backup-schema-v3-20260907T130000Z-corrupt");
|
||||||
|
fs::write(&corrupt_path, b"not a valid sqlite file").unwrap();
|
||||||
|
|
||||||
|
let unrelated_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v30-20260907T140000Z-unrelated");
|
||||||
|
{
|
||||||
|
let unrelated_conn = Connection::open(&unrelated_path).unwrap();
|
||||||
|
unrelated_conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);\n INSERT INTO downloads VALUES ('unrelated', 'completed', 'main', '{\"id\":\"unrelated\"}');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let malformed_path = root
|
||||||
|
.path()
|
||||||
|
.join("firelink.sqlite.backup-schema-v3-20260907T120000Z-malformed");
|
||||||
|
{
|
||||||
|
let malformed_conn = Connection::open(&malformed_path).unwrap();
|
||||||
|
malformed_conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);\n INSERT INTO downloads VALUES ('malformed', 'completed', 'main', 'not-json');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let valid_path = root.path().join("firelink.sqlite.backup-schema-v3-20260907T110000Z-valid");
|
||||||
|
{
|
||||||
|
let valid_conn = Connection::open(&valid_path).unwrap();
|
||||||
|
valid_conn.execute_batch("
|
||||||
|
CREATE TABLE downloads (id TEXT PRIMARY KEY, status TEXT NOT NULL, queue_id TEXT, data TEXT NOT NULL);
|
||||||
|
INSERT INTO downloads VALUES ('valid-1', 'completed', 'main', '{\"id\":\"valid-1\"}');
|
||||||
|
").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = init_at_path(root.path()).unwrap();
|
||||||
|
let mut connection = db.lock().unwrap();
|
||||||
|
|
||||||
|
connection.execute("DELETE FROM downloads", []).unwrap();
|
||||||
|
connection.execute("DELETE FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'", []).unwrap();
|
||||||
|
|
||||||
|
recover_downloads_from_migration_backup(&mut connection, root.path(), false).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_downloads(&connection).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert!(loaded[0].contains("valid-1"));
|
||||||
|
assert_eq!(
|
||||||
|
connection.query_row::<String, _, _>(
|
||||||
|
"SELECT value FROM metadata WHERE key = 'migration-backup-recovered:schema-v3'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0)
|
||||||
|
).unwrap(),
|
||||||
|
"complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ pub fn resolve_bundled_binary_path(
|
|||||||
let binary_name = crate::platform::engine_binary_name(engine);
|
let binary_name = crate::platform::engine_binary_name(engine);
|
||||||
let target = crate::platform::target_triple();
|
let target = crate::platform::target_triple();
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Some(runtime_root) = std::env::var_os("FIRELINK_ENGINE_RUNTIME_ROOT") {
|
||||||
|
for candidate in runtime_candidates(Path::new(&runtime_root), &target, &binary_name) {
|
||||||
|
if candidate.is_file() {
|
||||||
|
let absolute = candidate.canonicalize().map_err(|error| {
|
||||||
|
format!("Failed to canonicalize '{}': {error}", candidate.display())
|
||||||
|
})?;
|
||||||
|
log::info!(
|
||||||
|
"Resolved development engine '{}' for target '{}'",
|
||||||
|
engine,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
return Ok(absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
||||||
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
|
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
|
||||||
if candidate.is_file() {
|
if candidate.is_file() {
|
||||||
@@ -119,6 +136,11 @@ fn executable_relative_candidates(
|
|||||||
candidates
|
candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(debug_assertions, test))]
|
||||||
|
fn runtime_candidates(root: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||||
|
vec![root.join(target).join(binary_name)]
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(any(debug_assertions, test))]
|
#[cfg(any(debug_assertions, test))]
|
||||||
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||||
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
|
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
|
||||||
@@ -138,19 +160,49 @@ pub fn ytdlp_internal_dir(binary_path: &Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_aria2_environment(command: &mut std::process::Command, binary_path: &Path) {
|
pub fn apply_aria2_environment(command: &mut std::process::Command, binary_path: &Path) {
|
||||||
if let Some(modules_dir) = aria2_openssl_modules_dir(binary_path) {
|
apply_aria2_runtime_environment(command, binary_path);
|
||||||
command.env("OPENSSL_MODULES", modules_dir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_aria2_tokio_environment(command: &mut tokio::process::Command, binary_path: &Path) {
|
pub fn apply_aria2_tokio_environment(command: &mut tokio::process::Command, binary_path: &Path) {
|
||||||
|
apply_aria2_runtime_environment(command, binary_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_aria2_runtime_environment<C>(command: &mut C, binary_path: &Path)
|
||||||
|
where
|
||||||
|
C: Aria2CommandEnvironment,
|
||||||
|
{
|
||||||
if let Some(modules_dir) = aria2_openssl_modules_dir(binary_path) {
|
if let Some(modules_dir) = aria2_openssl_modules_dir(binary_path) {
|
||||||
command.env("OPENSSL_MODULES", modules_dir);
|
command.set_env("OPENSSL_MODULES", &modules_dir);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut path = modules_dir.as_os_str().to_os_string();
|
||||||
|
path.push(";");
|
||||||
|
if let Some(existing) = std::env::var_os("PATH") {
|
||||||
|
path.push(existing);
|
||||||
|
}
|
||||||
|
command.set_env("PATH", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Aria2CommandEnvironment {
|
||||||
|
fn set_env(&mut self, key: &str, value: impl AsRef<std::ffi::OsStr>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Aria2CommandEnvironment for std::process::Command {
|
||||||
|
fn set_env(&mut self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
|
||||||
|
self.env(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Aria2CommandEnvironment for tokio::process::Command {
|
||||||
|
fn set_env(&mut self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
|
||||||
|
self.env(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_openssl_modules_dir(binary_path: &Path) -> Option<PathBuf> {
|
fn aria2_openssl_modules_dir(binary_path: &Path) -> Option<PathBuf> {
|
||||||
if !cfg!(target_os = "macos") {
|
if !cfg!(any(target_os = "macos", target_os = "windows")) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +215,10 @@ fn aria2_openssl_modules_dir(binary_path: &Path) -> Option<PathBuf> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{development_candidates, development_candidates_for_runtime, packaged_candidates};
|
use super::{
|
||||||
|
development_candidates, development_candidates_for_runtime, packaged_candidates,
|
||||||
|
runtime_candidates,
|
||||||
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -194,6 +249,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_development_layout_is_target_scoped() {
|
||||||
|
let candidates = runtime_candidates(
|
||||||
|
Path::new("/tmp/firelink-engine-run/engine-dist"),
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"yt-dlp-x86_64-unknown-linux-gnu",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0],
|
||||||
|
Path::new(
|
||||||
|
"/tmp/firelink-engine-run/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn development_resolution_is_disabled_in_release_builds() {
|
fn development_resolution_is_disabled_in_release_builds() {
|
||||||
let candidates = development_candidates_for_runtime(
|
let candidates = development_candidates_for_runtime(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use base64::Engine as _;
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
use hmac::{Hmac, KeyInit, Mac};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -27,7 +28,11 @@ use ts_rs::TS;
|
|||||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||||
const MAX_URL_COUNT: usize = 200;
|
const MAX_URL_COUNT: usize = 200;
|
||||||
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
const MAX_NON_TORRENT_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||||
|
const MAX_ENCODED_TORRENT_BYTES: usize =
|
||||||
|
((crate::torrent::MAX_TORRENT_BYTES + 2) / 3) * 4;
|
||||||
|
const MAX_REQUEST_BODY_BYTES: usize =
|
||||||
|
MAX_ENCODED_TORRENT_BYTES + MAX_NON_TORRENT_REQUEST_BODY_BYTES;
|
||||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||||
const SERVER_HEADER: &str = "x-firelink-server";
|
const SERVER_HEADER: &str = "x-firelink-server";
|
||||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||||
@@ -36,7 +41,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
|||||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||||
const PROTOCOL_VERSION: &str = "5";
|
const PROTOCOL_VERSION: &str = "6";
|
||||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -80,6 +85,8 @@ struct ExtensionRequest {
|
|||||||
batch: bool,
|
batch: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
batch_name: Option<String>,
|
batch_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
torrent_bytes_base64: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||||
@@ -105,6 +112,11 @@ pub struct ExtensionDownload {
|
|||||||
torrent: bool,
|
torrent: bool,
|
||||||
batch: bool,
|
batch: bool,
|
||||||
batch_name: Option<String>,
|
batch_name: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
|
torrent_path: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
#[ts(skip)]
|
||||||
|
torrent_bytes: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_server(
|
pub async fn start_server(
|
||||||
@@ -303,11 +315,25 @@ async fn download_handler(
|
|||||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||||
};
|
};
|
||||||
|
|
||||||
let download = match normalize_download(payload) {
|
let mut download = match normalize_download(payload) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return Err(StatusCode::BAD_REQUEST),
|
None => return Err(StatusCode::BAD_REQUEST),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
if let Some(torrent_bytes) = download.torrent_bytes.take() {
|
||||||
|
let torrent_path = crate::torrent::cache_torrent_bytes(
|
||||||
|
&state.app_handle,
|
||||||
|
&request_id,
|
||||||
|
&torrent_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
download.urls = vec![torrent_path.clone()];
|
||||||
|
download.torrent_path = Some(torrent_path);
|
||||||
|
}
|
||||||
|
let cached_torrent = download.torrent_path.is_some();
|
||||||
|
|
||||||
let is_hidden = state
|
let is_hidden = state
|
||||||
.app_handle
|
.app_handle
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
@@ -321,13 +347,19 @@ async fn download_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !wait_for_frontend(&state.frontend_ready).await {
|
if !wait_for_frontend(&state.frontend_ready).await {
|
||||||
|
if cached_torrent {
|
||||||
|
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||||
|
}
|
||||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
let Some(ack_receiver) = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||||
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
else {
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
if cached_torrent {
|
||||||
let mut download = download;
|
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||||
|
}
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
};
|
||||||
download.request_id = Some(request_id.clone());
|
download.request_id = Some(request_id.clone());
|
||||||
|
|
||||||
if state
|
if state
|
||||||
@@ -336,6 +368,9 @@ async fn download_handler(
|
|||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
remove_extension_ack(&state.extension_acks, &request_id);
|
remove_extension_ack(&state.extension_acks, &request_id);
|
||||||
|
if cached_torrent {
|
||||||
|
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||||
|
}
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,11 +440,33 @@ fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_torrent_bytes(encoded: &str) -> Option<Vec<u8>> {
|
||||||
|
if encoded.is_empty()
|
||||||
|
|| encoded.len() > MAX_ENCODED_TORRENT_BYTES
|
||||||
|
|| encoded.len() % 4 != 0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.ok()?;
|
||||||
|
if bytes.is_empty() || bytes.len() > crate::torrent::MAX_TORRENT_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
crate::torrent::parse_torrent_bytes(&bytes).ok()?;
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||||
if payload.urls.len() > MAX_URL_COUNT {
|
if payload.urls.len() > MAX_URL_COUNT {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let torrent_bytes = match payload.torrent_bytes_base64.as_deref() {
|
||||||
|
Some(encoded) => Some(decode_torrent_bytes(encoded)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let urls = payload
|
let urls = payload
|
||||||
.urls
|
.urls
|
||||||
@@ -420,6 +477,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
if urls.is_empty() {
|
if urls.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if torrent_bytes.is_some()
|
||||||
|
&& (payload.media
|
||||||
|
|| !payload.torrent
|
||||||
|
|| urls.len() != 1
|
||||||
|
|| Url::parse(&urls[0])
|
||||||
|
.ok()
|
||||||
|
.is_none_or(|url| !matches!(url.scheme(), "http" | "https")))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if payload.media
|
if payload.media
|
||||||
&& urls.iter().any(|url| {
|
&& urls.iter().any(|url| {
|
||||||
Url::parse(url)
|
Url::parse(url)
|
||||||
@@ -437,6 +504,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
}
|
}
|
||||||
matches!(url.scheme(), "http" | "https")
|
matches!(url.scheme(), "http" | "https")
|
||||||
&& (payload.torrent
|
&& (payload.torrent
|
||||||
|
|| torrent_bytes.is_some()
|
||||||
|| filename_is_torrent(payload.filename.as_deref())
|
|| filename_is_torrent(payload.filename.as_deref())
|
||||||
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
||||||
});
|
});
|
||||||
@@ -500,6 +568,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
torrent,
|
torrent,
|
||||||
batch,
|
batch,
|
||||||
batch_name,
|
batch_name,
|
||||||
|
torrent_path: None,
|
||||||
|
torrent_bytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,9 +800,10 @@ fn is_allowed_origin(origin: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||||
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
decode_torrent_bytes, has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||||
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
|
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
|
||||||
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
|
||||||
|
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
@@ -740,6 +811,7 @@ mod tests {
|
|||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use base64::Engine as _;
|
||||||
use hmac::{Hmac, KeyInit, Mac};
|
use hmac::{Hmac, KeyInit, Mac};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -766,7 +838,7 @@ mod tests {
|
|||||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||||
"5"
|
"6"
|
||||||
);
|
);
|
||||||
|
|
||||||
server.abort();
|
server.abort();
|
||||||
@@ -833,6 +905,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(download.is_none());
|
assert!(download.is_none());
|
||||||
@@ -854,6 +927,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(download.is_none());
|
assert!(download.is_none());
|
||||||
@@ -916,6 +990,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid media handoff");
|
.expect("valid media handoff");
|
||||||
|
|
||||||
@@ -941,6 +1016,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid download handoff");
|
.expect("valid download handoff");
|
||||||
|
|
||||||
@@ -971,6 +1047,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: Some("batch".to_string()),
|
batch_name: Some("batch".to_string()),
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid multi-url handoff");
|
.expect("valid multi-url handoff");
|
||||||
|
|
||||||
@@ -998,6 +1075,7 @@ mod tests {
|
|||||||
torrent: true,
|
torrent: true,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid magnet torrent handoff");
|
.expect("valid magnet torrent handoff");
|
||||||
|
|
||||||
@@ -1016,6 +1094,7 @@ mod tests {
|
|||||||
torrent: true,
|
torrent: true,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("explicit opaque torrent handoff");
|
.expect("explicit opaque torrent handoff");
|
||||||
assert!(opaque.torrent);
|
assert!(opaque.torrent);
|
||||||
@@ -1034,11 +1113,78 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("legacy magnet handoff");
|
.expect("legacy magnet handoff");
|
||||||
assert!(legacy_magnet.torrent);
|
assert!(legacy_magnet.torrent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_local_torrent_bytes_are_normalized_as_a_single_http_sourced_torrent() {
|
||||||
|
let bytes = b"d4:infod6:lengthi5e4:name4:testee".to_vec();
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
let download = normalize_download(ExtensionRequest {
|
||||||
|
urls: vec!["https://privatebin.example/paste".to_string()],
|
||||||
|
referer: Some("https://privatebin.example/paste".to_string()),
|
||||||
|
silent: true,
|
||||||
|
filename: Some("TerraScape.TORRENT".to_string()),
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: Some(encoded),
|
||||||
|
})
|
||||||
|
.expect("browser-local torrent bytes should be accepted");
|
||||||
|
|
||||||
|
assert!(download.torrent);
|
||||||
|
assert_eq!(download.urls, vec!["https://privatebin.example/paste"]);
|
||||||
|
assert_eq!(download.filename.as_deref(), Some("TerraScape.TORRENT"));
|
||||||
|
assert_eq!(download.torrent_bytes.as_deref(), Some(bytes.as_slice()));
|
||||||
|
assert!(download.torrent_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_local_torrent_bytes_require_valid_bencoded_metadata_and_http_source() {
|
||||||
|
let valid = base64::engine::general_purpose::STANDARD
|
||||||
|
.encode(b"d4:infod6:lengthi5e4:name4:testee");
|
||||||
|
let invalid = base64::engine::general_purpose::STANDARD.encode(b"not a torrent");
|
||||||
|
|
||||||
|
assert!(decode_torrent_bytes(&invalid).is_none());
|
||||||
|
assert!(normalize_download(ExtensionRequest {
|
||||||
|
urls: vec!["blob:https://privatebin.example/attachment".to_string()],
|
||||||
|
referer: None,
|
||||||
|
silent: true,
|
||||||
|
filename: Some("download.torrent".to_string()),
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: Some(valid.clone()),
|
||||||
|
})
|
||||||
|
.is_none());
|
||||||
|
assert!(normalize_download(ExtensionRequest {
|
||||||
|
urls: vec!["https://privatebin.example/paste".to_string()],
|
||||||
|
referer: None,
|
||||||
|
silent: true,
|
||||||
|
filename: Some("download.torrent".to_string()),
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: true,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: Some(valid),
|
||||||
|
})
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||||
let download = normalize_download(ExtensionRequest {
|
let download = normalize_download(ExtensionRequest {
|
||||||
@@ -1066,6 +1212,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid download handoff");
|
.expect("valid download handoff");
|
||||||
|
|
||||||
@@ -1097,6 +1244,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid multi-url handoff");
|
.expect("valid multi-url handoff");
|
||||||
|
|
||||||
@@ -1121,6 +1269,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid selected-link batch");
|
.expect("valid selected-link batch");
|
||||||
|
|
||||||
@@ -1145,6 +1294,7 @@ mod tests {
|
|||||||
torrent: false,
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: Some("Example Gallery".to_string()),
|
batch_name: Some("Example Gallery".to_string()),
|
||||||
|
torrent_bytes_base64: None,
|
||||||
})
|
})
|
||||||
.expect("valid single-link handoff");
|
.expect("valid single-link handoff");
|
||||||
|
|
||||||
@@ -1182,4 +1332,70 @@ mod tests {
|
|||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_filename_strips_path_traversal_and_rejects_empty_or_special_names() {
|
||||||
|
assert_eq!(sanitize_filename("../../etc/passwd"), Some("passwd".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename(r"..\..\Windows\System32\calc.exe"),
|
||||||
|
Some("calc.exe".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename("valid_report.pdf"),
|
||||||
|
Some("valid_report.pdf".to_string())
|
||||||
|
);
|
||||||
|
assert!(sanitize_filename(".").is_none());
|
||||||
|
assert!(sanitize_filename("..").is_none());
|
||||||
|
assert!(sanitize_filename("").is_none());
|
||||||
|
assert!(sanitize_filename(" ").is_none());
|
||||||
|
assert!(sanitize_filename(&"a".repeat(256)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_url_rejects_dangerous_or_unsupported_schemes() {
|
||||||
|
assert!(normalize_url("file:///etc/passwd").is_none());
|
||||||
|
assert!(normalize_url("javascript:alert(1)").is_none());
|
||||||
|
assert!(normalize_url("data:text/html,<h1>test</h1>").is_none());
|
||||||
|
assert!(normalize_url("blob:https://example.com/uuid").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
normalize_url("https://example.com/file.zip"),
|
||||||
|
Some("https://example.com/file.zip".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_url("http://example.com/file.zip"),
|
||||||
|
Some("http://example.com/file.zip".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_url("ftp://example.com/file.zip"),
|
||||||
|
Some("ftp://example.com/file.zip".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_url("sftp://example.com/file.zip"),
|
||||||
|
Some("sftp://example.com/file.zip".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_url("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"),
|
||||||
|
Some("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_origin_url_strictly_matches_scheme_host_and_port() {
|
||||||
|
assert!(same_origin_url(
|
||||||
|
"https://example.com/path1",
|
||||||
|
"https://example.com/path2"
|
||||||
|
));
|
||||||
|
assert!(!same_origin_url(
|
||||||
|
"http://example.com/path",
|
||||||
|
"https://example.com/path"
|
||||||
|
));
|
||||||
|
assert!(!same_origin_url(
|
||||||
|
"https://example.com:8443/path",
|
||||||
|
"https://example.com/path"
|
||||||
|
));
|
||||||
|
assert!(!same_origin_url(
|
||||||
|
"https://other.example/path",
|
||||||
|
"https://example.com/path"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -570,7 +570,7 @@ pub enum ListRowDensity {
|
|||||||
Relaxed,
|
Relaxed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub enum PostQueueAction {
|
pub enum PostQueueAction {
|
||||||
@@ -1011,3 +1011,20 @@ impl DownloadStateEvent {
|
|||||||
(error, error_kind)
|
(error, error_kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct DownloadRemovalJob {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision: u32,
|
||||||
|
pub delete_assets: bool,
|
||||||
|
pub phase: DownloadRemovalPhase,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub enum DownloadRemovalPhase { Pending, Running, Failed, Completed }
|
||||||
|
|||||||
+1291
-382
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
|||||||
|
//! Shared network-target policy and route plumbing.
|
||||||
|
//!
|
||||||
|
//! Hostname resolution is deliberately not part of URL policy. The selected
|
||||||
|
//! route (the OS/TUN resolver, an explicit proxy, or the consumer's own
|
||||||
|
//! resolver) owns that decision. Only literal local targets and reserved local
|
||||||
|
//! names are rejected here.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use reqwest::{ClientBuilder, Proxy, Url};
|
||||||
|
|
||||||
|
pub(crate) const ARIA2_FIRELINK_REVISION: &str = "firelink-native-dns-v1";
|
||||||
|
pub(crate) const ARIA2_DNS_RESOLVER: &str = "native-async";
|
||||||
|
pub(crate) const ARIA2_NETWORK_TARGET_POLICY: &str = "firelink-v1";
|
||||||
|
pub(crate) const ARIA2_NETWORK_TARGET_POLICY_DIGEST: &str =
|
||||||
|
"sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705";
|
||||||
|
|
||||||
|
/// Select Aria2's standard resolver path for a transfer.
|
||||||
|
///
|
||||||
|
/// This is deliberately the production default: hostname resolution remains
|
||||||
|
/// owned by the OS/TUN route and stock Aria2 builds remain usable. Remove the
|
||||||
|
/// optional Firelink-only fields as well so a caller cannot accidentally carry
|
||||||
|
/// alternate-resolver options into a system-resolver request.
|
||||||
|
pub(crate) fn apply_aria2_system_resolver(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
) {
|
||||||
|
options.insert("async-dns".to_string(), serde_json::json!("false"));
|
||||||
|
options.remove("dns-resolver");
|
||||||
|
options.remove("network-target-policy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the system resolver for a transfer on a daemon that has already
|
||||||
|
/// advertised the optional Firelink route contract.
|
||||||
|
///
|
||||||
|
/// The custom daemon's default target policy is route-aware and would still
|
||||||
|
/// reject private answers if the policy were merely omitted. Set it to `none`
|
||||||
|
/// explicitly for the system-resolver route; stock daemons never receive this
|
||||||
|
/// optional field.
|
||||||
|
pub(crate) fn apply_aria2_system_resolver_for_daemon(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
firelink_route_contract_available: bool,
|
||||||
|
) {
|
||||||
|
apply_aria2_system_resolver(options);
|
||||||
|
if firelink_route_contract_available {
|
||||||
|
options.insert(
|
||||||
|
"network-target-policy".to_string(),
|
||||||
|
serde_json::json!("none"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the optional Firelink-patched Aria2 resolver and target policy for a
|
||||||
|
/// transfer. Callers must first attest the daemon capabilities with
|
||||||
|
/// `aria2_route_capability_error`.
|
||||||
|
pub(crate) fn apply_aria2_route_contract(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
) {
|
||||||
|
options.insert("async-dns".to_string(), serde_json::json!("true"));
|
||||||
|
options.insert(
|
||||||
|
"dns-resolver".to_string(),
|
||||||
|
serde_json::json!(ARIA2_DNS_RESOLVER),
|
||||||
|
);
|
||||||
|
options.insert(
|
||||||
|
"network-target-policy".to_string(),
|
||||||
|
serde_json::json!(ARIA2_NETWORK_TARGET_POLICY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the appropriate resolver options for a transfer based on whether the
|
||||||
|
/// running Aria2 daemon advertises the Firelink route contract.
|
||||||
|
///
|
||||||
|
/// When the Firelink route contract is available, transfers MUST use the
|
||||||
|
/// native-async resolver (`dns-resolver=native-async`, `async-dns=true`,
|
||||||
|
/// `network-target-policy=firelink-v1`). This executes the OS resolver
|
||||||
|
/// (`getaddrinfo`) on asynchronous worker threads, fully honoring TUN, VPN,
|
||||||
|
/// and proxy routes (e.g. Shadowrocket, Happ, Sing-box, V2RayN) without
|
||||||
|
/// blocking Aria2's single-threaded event loop or its RPC server.
|
||||||
|
///
|
||||||
|
/// When the route contract is unavailable (stock Aria2 builds), Firelink falls
|
||||||
|
/// back to standard system resolver options (`async-dns=false`) without
|
||||||
|
/// passing custom options that would be rejected by stock binaries.
|
||||||
|
pub(crate) fn apply_aria2_transfer_resolver(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
route_contract_available: bool,
|
||||||
|
) {
|
||||||
|
if route_contract_available {
|
||||||
|
apply_aria2_route_contract(options);
|
||||||
|
} else {
|
||||||
|
apply_aria2_system_resolver_for_daemon(options, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn has_string_capability(version: &serde_json::Value, field: &str, expected: &str) -> bool {
|
||||||
|
version
|
||||||
|
.get(field)
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that an Aria2 daemon exposes the optional Firelink route features.
|
||||||
|
///
|
||||||
|
/// The selected resolver and policy are intentionally not checked here: the
|
||||||
|
/// daemon starts in system-resolver mode and the alternate settings are
|
||||||
|
/// applied only to an individual fallback transfer.
|
||||||
|
pub(crate) fn aria2_route_capability_error(version: &serde_json::Value) -> Option<String> {
|
||||||
|
let async_dns = version
|
||||||
|
.get("enabledFeatures")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.is_some_and(|features| {
|
||||||
|
features
|
||||||
|
.iter()
|
||||||
|
.any(|feature| feature.as_str() == Some("Async DNS"))
|
||||||
|
});
|
||||||
|
if !async_dns {
|
||||||
|
return Some("bundled aria2 does not support asynchronous DNS".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (field, expected, label) in [
|
||||||
|
("firelinkRevision", ARIA2_FIRELINK_REVISION, "Firelink engine revision"),
|
||||||
|
(
|
||||||
|
"firelinkNetworkTargetPolicyDigest",
|
||||||
|
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
"network target policy digest",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if version.get(field).and_then(serde_json::Value::as_str) != Some(expected) {
|
||||||
|
return Some(format!("bundled aria2 has an incompatible {label}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_string_capability(version, "firelinkDnsResolvers", ARIA2_DNS_RESOLVER) {
|
||||||
|
return Some("bundled aria2 does not expose the native DNS resolver".to_string());
|
||||||
|
}
|
||||||
|
if !has_string_capability(
|
||||||
|
version,
|
||||||
|
"firelinkNetworkTargetPolicies",
|
||||||
|
ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
) {
|
||||||
|
return Some("bundled aria2 does not expose the network target policy".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the optional Firelink route settings are active for the
|
||||||
|
/// current transfer/daemon option scope.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<String> {
|
||||||
|
if let Some(error) = aria2_route_capability_error(version) {
|
||||||
|
return Some(error);
|
||||||
|
}
|
||||||
|
if version
|
||||||
|
.get("firelinkDnsResolver")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
!= Some(ARIA2_DNS_RESOLVER)
|
||||||
|
{
|
||||||
|
return Some("bundled aria2 is not using the native DNS resolver".to_string());
|
||||||
|
}
|
||||||
|
if version
|
||||||
|
.get("firelinkNetworkTargetPolicy")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
!= Some(ARIA2_NETWORK_TARGET_POLICY)
|
||||||
|
{
|
||||||
|
return Some("bundled aria2 is not using the network target policy".to_string());
|
||||||
|
}
|
||||||
|
if version
|
||||||
|
.get("firelinkNetworkTargetPolicyEnforced")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
!= Some(true)
|
||||||
|
{
|
||||||
|
return Some("bundled aria2 is not enforcing the network target policy".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the standard RPC response needed by every supported Aria2 build.
|
||||||
|
pub(crate) fn aria2_baseline_error(version: &serde_json::Value) -> Option<String> {
|
||||||
|
if version
|
||||||
|
.get("version")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.is_none_or(|value| value.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Some("bundled aria2 returned an invalid version response".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum NetworkRoute {
|
||||||
|
/// Preserve reqwest's normal environment/OS route selection.
|
||||||
|
Inherited,
|
||||||
|
/// Bypass configured/environment proxies and use the direct OS route.
|
||||||
|
Direct,
|
||||||
|
/// Route the consumer through this configured proxy.
|
||||||
|
Proxy(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkRoute {
|
||||||
|
pub(crate) fn from_proxy(proxy: Option<&str>) -> Self {
|
||||||
|
match proxy.map(str::trim) {
|
||||||
|
None => Self::Inherited,
|
||||||
|
Some(value) if value.is_empty() || value.eq_ignore_ascii_case("none") => Self::Direct,
|
||||||
|
Some(value) => Self::Proxy(value.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn configure_reqwest(
|
||||||
|
&self,
|
||||||
|
builder: ClientBuilder,
|
||||||
|
) -> Result<ClientBuilder, String> {
|
||||||
|
match self {
|
||||||
|
Self::Inherited => Ok(builder),
|
||||||
|
Self::Direct => Ok(builder.no_proxy()),
|
||||||
|
Self::Proxy(value) => {
|
||||||
|
let proxy = Proxy::all(value)
|
||||||
|
.map_err(|error| crate::redact_sensitive_text(&error.to_string()))?;
|
||||||
|
Ok(builder.proxy(proxy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the route into Aria2's all-proxy option without changing the
|
||||||
|
/// target hostname. Aria2 accepts HTTP-family proxy endpoints for normal
|
||||||
|
/// transfers; reqwest and yt-dlp consumers may still retain SOCKS routes.
|
||||||
|
pub(crate) fn aria2_proxy_value(&self) -> Result<Option<String>, String> {
|
||||||
|
match self {
|
||||||
|
Self::Inherited => Ok(None),
|
||||||
|
Self::Direct => Ok(Some(String::new())),
|
||||||
|
Self::Proxy(value) => {
|
||||||
|
let parsed = Url::parse(value).map_err(|error| {
|
||||||
|
crate::redact_sensitive_text(&format!("invalid Aria2 proxy URL: {error}"))
|
||||||
|
})?;
|
||||||
|
if parsed.host_str().is_none_or(str::is_empty) {
|
||||||
|
return Err("invalid Aria2 proxy URL: proxy must include a host".to_string());
|
||||||
|
}
|
||||||
|
let is_socks = parsed.scheme().eq_ignore_ascii_case("socks")
|
||||||
|
|| parsed.scheme().to_ascii_lowercase().starts_with("socks");
|
||||||
|
if is_socks {
|
||||||
|
return Err(
|
||||||
|
"SOCKS system proxies are not supported for normal file downloads because aria2 only accepts HTTP/HTTPS/FTP proxy URLs. Use an HTTP proxy endpoint for normal downloads, or use media downloads where yt-dlp supports SOCKS."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https" | "ftp") {
|
||||||
|
return Err(
|
||||||
|
"Aria2 proxy must use an HTTP, HTTPS, or FTP proxy URL".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Some(value.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the route into yt-dlp's explicit proxy argument. `None`
|
||||||
|
/// means inherit the process/OS route; an empty value deliberately disables
|
||||||
|
/// inherited proxies for an explicit direct route.
|
||||||
|
pub(crate) fn ytdlp_proxy_value(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Inherited => None,
|
||||||
|
Self::Direct => Some(""),
|
||||||
|
Self::Proxy(value) => Some(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Inherited => "inherited",
|
||||||
|
Self::Direct => "direct",
|
||||||
|
Self::Proxy(_) => "configured",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CredentialPolicy {
|
||||||
|
Allow,
|
||||||
|
Reject(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a parsed URL without resolving its hostname.
|
||||||
|
pub(crate) fn validate_url(
|
||||||
|
parsed: &Url,
|
||||||
|
allowed_schemes: &[&str],
|
||||||
|
credentials: CredentialPolicy,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !allowed_schemes
|
||||||
|
.iter()
|
||||||
|
.any(|scheme| parsed.scheme() == *scheme)
|
||||||
|
{
|
||||||
|
return Err("Unsupported URL scheme".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = parsed
|
||||||
|
.host_str()
|
||||||
|
.filter(|host| !host.trim().is_empty())
|
||||||
|
.ok_or_else(|| "SSRF blocked: No host".to_string())?;
|
||||||
|
|
||||||
|
if let CredentialPolicy::Reject(message) = credentials {
|
||||||
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||||
|
return Err(message.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_host(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_and_validate_url(
|
||||||
|
raw: &str,
|
||||||
|
allowed_schemes: &[&str],
|
||||||
|
credentials: CredentialPolicy,
|
||||||
|
) -> Result<Url, String> {
|
||||||
|
let parsed = Url::parse(raw).map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||||
|
validate_url(&parsed, allowed_schemes, credentials)?;
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_local_hostname(host: &str) -> bool {
|
||||||
|
let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||||
|
normalized == "localhost"
|
||||||
|
|| matches!(normalized.as_str(), "local" | "broadcasthost")
|
||||||
|
|| normalized.ends_with(".localhost")
|
||||||
|
|| matches!(
|
||||||
|
normalized.as_str(),
|
||||||
|
"localhost.localdomain"
|
||||||
|
| "localhost6"
|
||||||
|
| "localhost6.localdomain6"
|
||||||
|
| "ip6-localhost"
|
||||||
|
| "ip6-loopback"
|
||||||
|
| "ip6-allnodes"
|
||||||
|
| "ip6-allrouters"
|
||||||
|
)
|
||||||
|
|| normalized.ends_with(".local")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a network hostname without asking the application to resolve it.
|
||||||
|
///
|
||||||
|
/// This is also used for hostname/port pairs embedded in Torrent metadata,
|
||||||
|
/// where no URL parser has normalized legacy numeric IPv4 spellings for us.
|
||||||
|
pub(crate) fn validate_host(host: &str) -> Result<(), String> {
|
||||||
|
if host.is_empty()
|
||||||
|
|| host
|
||||||
|
.chars()
|
||||||
|
.any(|character| character.is_control() || character.is_whitespace())
|
||||||
|
{
|
||||||
|
return Err("SSRF blocked: Invalid host".to_string());
|
||||||
|
}
|
||||||
|
let normalized_host = host.trim_end_matches('.');
|
||||||
|
let bracketed = normalized_host.starts_with('[') || normalized_host.ends_with(']');
|
||||||
|
let normalized_host = match (
|
||||||
|
normalized_host.starts_with('['),
|
||||||
|
normalized_host.ends_with(']'),
|
||||||
|
) {
|
||||||
|
(true, true) => &normalized_host[1..normalized_host.len() - 1],
|
||||||
|
(false, false) => normalized_host,
|
||||||
|
_ => return Err("SSRF blocked: Invalid host".to_string()),
|
||||||
|
};
|
||||||
|
if normalized_host.is_empty() {
|
||||||
|
return Err("SSRF blocked: Invalid host".to_string());
|
||||||
|
}
|
||||||
|
if bracketed && (!normalized_host.contains(':') || parse_literal_ip(normalized_host).is_none())
|
||||||
|
{
|
||||||
|
return Err("SSRF blocked: Invalid host".to_string());
|
||||||
|
}
|
||||||
|
if is_local_hostname(normalized_host)
|
||||||
|
|| parse_literal_ip(normalized_host).is_some_and(is_blocked_network_address)
|
||||||
|
{
|
||||||
|
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||||
|
}
|
||||||
|
if normalized_host.contains(':') && parse_literal_ip(normalized_host).is_none() {
|
||||||
|
return Err("SSRF blocked: Invalid host".to_string());
|
||||||
|
}
|
||||||
|
if !normalized_host.contains(':') && url::Host::parse(normalized_host).is_err() {
|
||||||
|
return Err("SSRF blocked: Invalid host".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_literal_ip(host: &str) -> Option<IpAddr> {
|
||||||
|
let host = host.trim_end_matches('.');
|
||||||
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
if let Some((address, _zone)) = host.split_once("%25") {
|
||||||
|
if let Ok(ip) = address.parse::<IpAddr>() {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL parsers commonly canonicalize legacy IPv4 literals such as 127.1,
|
||||||
|
// decimal IPv4, hexadecimal IPv4, and octal IPv4. Reuse that canonical
|
||||||
|
// parser for raw Torrent node hosts so those spellings cannot bypass the
|
||||||
|
// literal-target policy without performing DNS.
|
||||||
|
let candidate = if host.contains(':') {
|
||||||
|
format!("http://[{host}]/")
|
||||||
|
} else {
|
||||||
|
format!("http://{host}/")
|
||||||
|
};
|
||||||
|
let parsed = Url::parse(&candidate).ok()?;
|
||||||
|
parsed.host_str()?.parse::<IpAddr>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_blocked_network_address(ip: IpAddr) -> bool {
|
||||||
|
if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(ipv4) => {
|
||||||
|
let octets = ipv4.octets();
|
||||||
|
octets[0] == 0
|
||||||
|
|| ipv4.is_private()
|
||||||
|
|| ipv4.is_link_local()
|
||||||
|
|| (octets[0] == 100 && octets[1] & 0xc0 == 0x40)
|
||||||
|
|| octets == [255, 255, 255, 255]
|
||||||
|
}
|
||||||
|
IpAddr::V6(ipv6) => {
|
||||||
|
// Check both IPv4-mapped and deprecated IPv4-compatible forms;
|
||||||
|
// either can encode a local IPv4 destination behind an IPv6
|
||||||
|
// literal.
|
||||||
|
ipv6.to_ipv4_mapped()
|
||||||
|
.or_else(|| ipv6.to_ipv4())
|
||||||
|
.is_some_and(|ipv4| is_blocked_network_address(ipv4.into()))
|
||||||
|
|| (ipv6.segments()[0] & 0xfe00) == 0xfc00
|
||||||
|
|| (ipv6.segments()[0] & 0xffc0) == 0xfe80
|
||||||
|
|| (ipv6.segments()[0] & 0xffc0) == 0xfec0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn validate(raw: &str, schemes: &[&str]) -> Result<(), String> {
|
||||||
|
parse_and_validate_url(raw, schemes, CredentialPolicy::Allow).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_route_capabilities_are_distinct_from_active_options() {
|
||||||
|
let valid = serde_json::json!({
|
||||||
|
"version": "1.37.0",
|
||||||
|
"enabledFeatures": ["Async DNS", "BitTorrent"],
|
||||||
|
"firelinkRevision": ARIA2_FIRELINK_REVISION,
|
||||||
|
"firelinkDnsResolver": "disabled",
|
||||||
|
"firelinkDnsResolvers": [ARIA2_DNS_RESOLVER],
|
||||||
|
"firelinkNetworkTargetPolicy": ARIA2_NETWORK_TARGET_POLICY,
|
||||||
|
"firelinkNetworkTargetPolicies": ["none", ARIA2_NETWORK_TARGET_POLICY],
|
||||||
|
"firelinkNetworkTargetPolicyDigest": ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||||
|
"firelinkNetworkTargetPolicyEnforced": false,
|
||||||
|
});
|
||||||
|
assert_eq!(aria2_route_capability_error(&valid), None);
|
||||||
|
assert!(aria2_route_contract_error(&valid).is_some());
|
||||||
|
|
||||||
|
let mut active = valid.clone();
|
||||||
|
active["firelinkDnsResolver"] = serde_json::json!(ARIA2_DNS_RESOLVER);
|
||||||
|
active["firelinkNetworkTargetPolicyEnforced"] = serde_json::json!(true);
|
||||||
|
assert_eq!(aria2_route_contract_error(&active), None);
|
||||||
|
|
||||||
|
for field in [
|
||||||
|
"firelinkRevision",
|
||||||
|
"firelinkDnsResolvers",
|
||||||
|
"firelinkNetworkTargetPolicies",
|
||||||
|
"firelinkNetworkTargetPolicyDigest",
|
||||||
|
] {
|
||||||
|
let mut invalid = active.clone();
|
||||||
|
invalid.as_object_mut().unwrap().remove(field);
|
||||||
|
assert!(aria2_route_capability_error(&invalid).is_some(), "{field}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wrong_digest = active.clone();
|
||||||
|
wrong_digest["firelinkNetworkTargetPolicyDigest"] = serde_json::json!("sha256:wrong");
|
||||||
|
assert!(aria2_route_contract_error(&wrong_digest).is_some());
|
||||||
|
assert!(aria2_route_contract_error(&serde_json::json!({
|
||||||
|
"enabledFeatures": ["BitTorrent"]
|
||||||
|
}))
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_route_options_are_explicit_per_transfer() {
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
apply_aria2_route_contract(&mut options);
|
||||||
|
assert_eq!(options.get("async-dns"), Some(&serde_json::json!("true")));
|
||||||
|
assert_eq!(
|
||||||
|
options.get("dns-resolver"),
|
||||||
|
Some(&serde_json::json!(ARIA2_DNS_RESOLVER))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.get("network-target-policy"),
|
||||||
|
Some(&serde_json::json!(ARIA2_NETWORK_TARGET_POLICY))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_system_options_remove_optional_route_fields() {
|
||||||
|
let mut options = serde_json::Map::from_iter([
|
||||||
|
("dns-resolver".to_string(), serde_json::json!(ARIA2_DNS_RESOLVER)),
|
||||||
|
(
|
||||||
|
"network-target-policy".to_string(),
|
||||||
|
serde_json::json!(ARIA2_NETWORK_TARGET_POLICY),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
apply_aria2_system_resolver(&mut options);
|
||||||
|
assert_eq!(options.get("async-dns"), Some(&serde_json::json!("false")));
|
||||||
|
assert!(!options.contains_key("dns-resolver"));
|
||||||
|
assert!(!options.contains_key("network-target-policy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_system_options_disable_the_custom_default_policy_only_when_attested() {
|
||||||
|
let mut stock_options = serde_json::Map::new();
|
||||||
|
apply_aria2_system_resolver_for_daemon(&mut stock_options, false);
|
||||||
|
assert!(!stock_options.contains_key("network-target-policy"));
|
||||||
|
|
||||||
|
let mut patched_options = serde_json::Map::new();
|
||||||
|
apply_aria2_system_resolver_for_daemon(&mut patched_options, true);
|
||||||
|
assert_eq!(
|
||||||
|
patched_options.get("network-target-policy"),
|
||||||
|
Some(&serde_json::json!("none"))
|
||||||
|
);
|
||||||
|
assert_eq!(patched_options.get("async-dns"), Some(&serde_json::json!("false")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_transfer_resolver_selects_route_contract_when_available_and_stock_otherwise() {
|
||||||
|
let mut patched_options = serde_json::Map::new();
|
||||||
|
apply_aria2_transfer_resolver(&mut patched_options, true);
|
||||||
|
assert_eq!(
|
||||||
|
patched_options.get("async-dns"),
|
||||||
|
Some(&serde_json::json!("true"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patched_options.get("dns-resolver"),
|
||||||
|
Some(&serde_json::json!(ARIA2_DNS_RESOLVER))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patched_options.get("network-target-policy"),
|
||||||
|
Some(&serde_json::json!(ARIA2_NETWORK_TARGET_POLICY))
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stock_options = serde_json::Map::new();
|
||||||
|
apply_aria2_transfer_resolver(&mut stock_options, false);
|
||||||
|
assert_eq!(
|
||||||
|
stock_options.get("async-dns"),
|
||||||
|
Some(&serde_json::json!("false"))
|
||||||
|
);
|
||||||
|
assert!(!stock_options.contains_key("dns-resolver"));
|
||||||
|
assert!(!stock_options.contains_key("network-target-policy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stock_aria2_baseline_response_is_accepted_without_firelink_fields() {
|
||||||
|
assert_eq!(
|
||||||
|
aria2_baseline_error(&serde_json::json!({
|
||||||
|
"version": "1.37.0",
|
||||||
|
"enabledFeatures": ["Async DNS", "BitTorrent"],
|
||||||
|
})),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(aria2_baseline_error(&serde_json::json!({})).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_literal_local_and_mapped_addresses() {
|
||||||
|
for raw in [
|
||||||
|
"http://127.0.0.1/file",
|
||||||
|
"http://0.0.0.1/file",
|
||||||
|
"http://10.0.0.8/file",
|
||||||
|
"http://100.64.0.1/file",
|
||||||
|
"http://169.254.10.2/file",
|
||||||
|
"http://255.255.255.255/file",
|
||||||
|
"http://[::1]/file",
|
||||||
|
"http://[::ffff:127.0.0.1]/file",
|
||||||
|
"http://[::ffff:169.254.169.254]/file",
|
||||||
|
"http://[fc00::1]/file",
|
||||||
|
"http://[fe80::1]/file",
|
||||||
|
"http://[fec0::1]/file",
|
||||||
|
"http://127.0.0.1./file",
|
||||||
|
// URL parsers commonly canonicalize these legacy IPv4 literal
|
||||||
|
// spellings, but keep the policy test explicit so a parser
|
||||||
|
// upgrade cannot turn them into SSRF bypasses.
|
||||||
|
"http://127.1/file",
|
||||||
|
"http://2130706433/file",
|
||||||
|
"http://0x7f000001/file",
|
||||||
|
"http://0177.0.0.1/file",
|
||||||
|
"http://0/file",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
validate(raw, &["http", "https"]),
|
||||||
|
Err("SSRF blocked: Private/local IP not allowed".to_string()),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_localhost_aliases_without_dns() {
|
||||||
|
for raw in [
|
||||||
|
"http://localhost/file",
|
||||||
|
"http://localhost./file",
|
||||||
|
"http://media.localhost/file",
|
||||||
|
"http://localhost.localdomain/file",
|
||||||
|
"http://localhost6/file",
|
||||||
|
"http://broadcasthost/file",
|
||||||
|
"http://local/file",
|
||||||
|
"http://printer.local/file",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
validate(raw, &["http", "https"]),
|
||||||
|
Err("SSRF blocked: Private/local IP not allowed".to_string()),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_scoped_link_local_literals() {
|
||||||
|
assert!(matches!(
|
||||||
|
validate("http://[fe80::1%25en0]/file", &["http", "https"]),
|
||||||
|
Err(message) if message.contains("SSRF blocked")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_hostname_validation_does_not_require_application_dns() {
|
||||||
|
assert_eq!(
|
||||||
|
validate(
|
||||||
|
"https://this-host-does-not-resolve.invalid/file",
|
||||||
|
&["http", "https"]
|
||||||
|
),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate("https://[2001:db8::1]/file", &["http", "https"]),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_host("2001:db8::1"),
|
||||||
|
Ok(()),
|
||||||
|
"public IPv6 literals must remain usable without DNS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_hosts_reject_legacy_literals_without_resolving_public_names() {
|
||||||
|
for host in ["127.1", "2130706433", "0x7f000001", "0177.0.0.1", "0"] {
|
||||||
|
assert_eq!(
|
||||||
|
validate_host(host),
|
||||||
|
Err("SSRF blocked: Private/local IP not allowed".to_string()),
|
||||||
|
"{host}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(validate_host("node-does-not-resolve.invalid").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_mapping_preserves_direct_and_proxy_choices() {
|
||||||
|
crate::ensure_reqwest_crypto_provider();
|
||||||
|
assert_eq!(NetworkRoute::from_proxy(None), NetworkRoute::Inherited);
|
||||||
|
assert_eq!(NetworkRoute::from_proxy(Some("none")), NetworkRoute::Direct);
|
||||||
|
assert_eq!(NetworkRoute::from_proxy(Some(" ")), NetworkRoute::Direct);
|
||||||
|
assert_eq!(
|
||||||
|
NetworkRoute::from_proxy(Some("http://proxy.example:8080")),
|
||||||
|
NetworkRoute::Proxy("http://proxy.example:8080".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(NetworkRoute::from_proxy(None).ytdlp_proxy_value(), None);
|
||||||
|
assert_eq!(
|
||||||
|
NetworkRoute::from_proxy(Some("none")).ytdlp_proxy_value(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NetworkRoute::from_proxy(Some("http://proxy.example:8080")).ytdlp_proxy_value(),
|
||||||
|
Some("http://proxy.example:8080")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NetworkRoute::from_proxy(Some("none"))
|
||||||
|
.aria2_proxy_value()
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
NetworkRoute::from_proxy(Some("socks5://proxy.example:1080"))
|
||||||
|
.aria2_proxy_value()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(NetworkRoute::from_proxy(Some("http://[invalid"))
|
||||||
|
.aria2_proxy_value()
|
||||||
|
.is_err());
|
||||||
|
assert!(NetworkRoute::from_proxy(Some("file:///tmp/proxy"))
|
||||||
|
.aria2_proxy_value()
|
||||||
|
.is_err());
|
||||||
|
assert!(
|
||||||
|
NetworkRoute::from_proxy(Some("socks5://proxy.example:1080"))
|
||||||
|
.configure_reqwest(reqwest::Client::builder())
|
||||||
|
.and_then(|builder| builder.build().map_err(|error| error.to_string()))
|
||||||
|
.is_ok(),
|
||||||
|
"reqwest-backed metadata must retain supported SOCKS routes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_hosts_reject_unbalanced_ipv6_brackets() {
|
||||||
|
assert!(validate_host("[2001:db8::1").is_err());
|
||||||
|
assert!(validate_host("2001:db8::1]").is_err());
|
||||||
|
assert!(validate_host("[download.example]").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_can_be_rejected_by_the_consumer_policy() {
|
||||||
|
let url = Url::parse("https://user:pass@example.com/file").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
validate_url(
|
||||||
|
&url,
|
||||||
|
&["http", "https"],
|
||||||
|
CredentialPolicy::Reject("credentials are not allowed")
|
||||||
|
),
|
||||||
|
Err("credentials are not allowed".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+582
-191
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
use std::{process::Stdio, time::Duration};
|
||||||
use std::process::Command;
|
use tokio::io::AsyncReadExt;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::ipc::DownloadCategory;
|
use crate::ipc::DownloadCategory;
|
||||||
@@ -8,97 +8,137 @@ use crate::ipc::DownloadCategory;
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||||
crate::properties_window::ensure_main_window(&caller)?;
|
crate::properties_window::ensure_main_window(&caller)?;
|
||||||
match native_system_proxy() {
|
match bounded_native_system_proxy(&SystemProxyCommandRunner, PROXY_DISCOVERY_TIMEOUT).await {
|
||||||
Ok(Some(proxy)) => Ok(Some(proxy)),
|
Ok(Some(proxy)) => Ok(Some(proxy)),
|
||||||
Ok(None) => Ok(proxy_from_environment()),
|
Ok(None) => Ok(proxy_from_environment()),
|
||||||
Err(native_error) => match sysproxy::Sysproxy::get_system_proxy() {
|
Err(native_error) => proxy_from_environment()
|
||||||
Ok(proxy) if proxy.enable => {
|
.map(Some)
|
||||||
if proxy.host.contains('=') {
|
.ok_or_else(|| format!("failed to read system proxy settings: {native_error}")),
|
||||||
Ok(parse_windows_proxy_server(&proxy.host).or_else(proxy_from_environment))
|
}
|
||||||
} else {
|
}
|
||||||
Ok(normalize_sysproxy_address(&proxy.host, proxy.port)
|
|
||||||
.or_else(proxy_from_environment))
|
const PROXY_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
}
|
const PROXY_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(12);
|
||||||
|
const PROXY_COMMAND_OUTPUT_LIMIT: u64 = 64 * 1024;
|
||||||
|
const PROXY_NETWORK_SERVICE_LIMIT: usize = 32;
|
||||||
|
|
||||||
|
async fn bounded_native_system_proxy(
|
||||||
|
runner: &dyn ProxyCommandRunner,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
tokio::time::timeout(timeout, native_system_proxy(runner))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "system proxy discovery timed out".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
trait ProxyCommandRunner: Sync {
|
||||||
|
async fn stdout(&self, program: &str, args: &[String]) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemProxyCommandRunner;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for SystemProxyCommandRunner {
|
||||||
|
async fn stdout(&self, program: &str, args: &[String]) -> Result<String, String> {
|
||||||
|
let mut command = tokio::process::Command::new(program);
|
||||||
|
command
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
command.as_std_mut().creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| format!("{program} is unavailable: {error}"))?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| format!("failed to capture {program} output"))?;
|
||||||
|
let operation = async {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
stdout
|
||||||
|
.take(PROXY_COMMAND_OUTPUT_LIMIT + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("failed to read {program} output: {error}"))?;
|
||||||
|
if bytes.len() as u64 > PROXY_COMMAND_OUTPUT_LIMIT {
|
||||||
|
return Err(format!("{program} output exceeded the safety limit"));
|
||||||
}
|
}
|
||||||
Ok(_) => Ok(proxy_from_environment()),
|
let status = child
|
||||||
Err(error) => proxy_from_environment().map(Some).ok_or_else(|| {
|
.wait()
|
||||||
format!(
|
.await
|
||||||
"failed to read system proxy settings: {native_error}; sysproxy fallback: {error}"
|
.map_err(|error| format!("failed to wait for {program}: {error}"))?;
|
||||||
)
|
if !status.success() {
|
||||||
}),
|
return Err(format!("{program} exited unsuccessfully"));
|
||||||
},
|
}
|
||||||
|
String::from_utf8(bytes).map_err(|_| format!("{program} returned non-UTF-8 output"))
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::timeout(PROXY_COMMAND_TIMEOUT, operation)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{program} timed out"))?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
fallback_windows_proxy().map_err(|_| "failed to read Windows proxy registry".to_string())
|
windows_system_proxy(runner).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
let proxy = sysproxy::Sysproxy::get_system_proxy().map_err(|error| error.to_string())?;
|
macos_system_proxy(runner).await
|
||||||
if !proxy.enable {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(macos_proxy_for_host_port(&proxy.host, proxy.port)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
normalize_sysproxy_address(&proxy.host, proxy.port)
|
|
||||||
.unwrap_or_else(|| format!("http://{}:{}", proxy.host, proxy.port))
|
|
||||||
})
|
|
||||||
.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
let mode =
|
linux_system_proxy(runner).await
|
||||||
command_stdout(Command::new("gsettings").args(["get", "org.gnome.system.proxy", "mode"]))
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
if strip_gsettings_string(&mode) != "manual" {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(linux_gsettings_proxy("https", "http")
|
|
||||||
.or_else(|| linux_gsettings_proxy("http", "http"))
|
|
||||||
.or_else(|| linux_gsettings_proxy("socks", "socks5")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
async fn native_system_proxy(_runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_args(args: &[&str]) -> Vec<String> {
|
||||||
|
args.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(
|
||||||
|
all(not(target_os = "windows"), not(target_os = "linux"), not(target_os = "macos")),
|
||||||
|
allow(dead_code)
|
||||||
|
)]
|
||||||
|
fn is_probe_unavailable(error: &str) -> bool {
|
||||||
|
error.contains("is unavailable") || error.contains("exited unsuccessfully")
|
||||||
|
}
|
||||||
|
|
||||||
fn proxy_from_environment() -> Option<String> {
|
fn proxy_from_environment() -> Option<String> {
|
||||||
[
|
[
|
||||||
"HTTPS_PROXY",
|
("HTTPS_PROXY", "http"),
|
||||||
"https_proxy",
|
("https_proxy", "http"),
|
||||||
"HTTP_PROXY",
|
("HTTP_PROXY", "http"),
|
||||||
"http_proxy",
|
("http_proxy", "http"),
|
||||||
"ALL_PROXY",
|
("ALL_PROXY", "socks5"),
|
||||||
"all_proxy",
|
("all_proxy", "socks5"),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|name| {
|
.find_map(|(name, scheme)| {
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| normalize_proxy_address(&value, "http"))
|
.and_then(|value| normalize_proxy_address(&value, scheme))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
|
|
||||||
let output = command.output()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return Err(std::io::Error::other(format!(
|
|
||||||
"command exited with {}",
|
|
||||||
output.status
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
|
fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
|
||||||
let trimmed = raw.trim().trim_matches('"').trim_end_matches('/');
|
let trimmed = raw.trim().trim_matches('"');
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -114,30 +154,40 @@ fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
parsed.host_str()?;
|
parsed.host_str()?;
|
||||||
Some(candidate)
|
if parsed.port() == Some(0)
|
||||||
}
|
|| !matches!(parsed.path(), "" | "/")
|
||||||
|
|| parsed.query().is_some()
|
||||||
fn normalize_sysproxy_address(host: &str, port: u16) -> Option<String> {
|
|| parsed.fragment().is_some()
|
||||||
let host = host.trim();
|
{
|
||||||
if host.is_empty() {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
Some(candidate.trim_end_matches('/').to_string())
|
||||||
if host.contains("://") {
|
|
||||||
let mut parsed = url::Url::parse(host).ok()?;
|
|
||||||
if parsed.port().is_none() && port != 0 {
|
|
||||||
parsed.set_port(Some(port)).ok()?;
|
|
||||||
}
|
|
||||||
return normalize_proxy_address(parsed.as_str(), "http");
|
|
||||||
}
|
|
||||||
|
|
||||||
if port == 0 {
|
|
||||||
normalize_proxy_address(host, "http")
|
|
||||||
} else {
|
|
||||||
normalize_proxy_address(&format!("{host}:{port}"), "http")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn proxy_from_host_port(host: &str, port: &str, scheme: &str) -> Option<String> {
|
||||||
|
let host = host.trim();
|
||||||
|
let host = match (host.strip_prefix('['), host.strip_suffix(']')) {
|
||||||
|
(Some(without_open), Some(_)) => without_open.strip_suffix(']')?,
|
||||||
|
(None, None) => host,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let port = port.trim().parse::<u16>().ok().filter(|port| *port != 0)?;
|
||||||
|
if host.is_empty()
|
||||||
|
|| host.eq_ignore_ascii_case("(null)")
|
||||||
|
|| host.contains(['/', '@', '?', '#', '(', ')'])
|
||||||
|
|| host.chars().any(char::is_whitespace)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let formatted_host = if host.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||||
|
format!("[{host}]")
|
||||||
|
} else {
|
||||||
|
host.to_string()
|
||||||
|
};
|
||||||
|
normalize_proxy_address(&format!("{scheme}://{formatted_host}:{port}"), scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
fn parse_windows_proxy_server(value: &str) -> Option<String> {
|
fn parse_windows_proxy_server(value: &str) -> Option<String> {
|
||||||
let value = value.trim().trim_matches('"');
|
let value = value.trim().trim_matches('"');
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
@@ -168,24 +218,16 @@ fn parse_windows_proxy_server(value: &str) -> Option<String> {
|
|||||||
https.or(http).or(socks)
|
https.or(http).or(socks)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
|
fn scutil_dict_value<'a>(output: &'a str, key: &str) -> Option<&'a str> {
|
||||||
let services_output =
|
for line in output.lines() {
|
||||||
command_stdout(Command::new("networksetup").arg("-listallnetworkservices")).ok()?;
|
let trimmed = line.trim();
|
||||||
for service in parse_macos_network_services(&services_output) {
|
if let Some((k, v)) = trimmed.split_once(':') {
|
||||||
for (target, scheme) in [
|
if k.trim().eq_ignore_ascii_case(key) {
|
||||||
("securewebproxy", "http"),
|
let val = v.trim();
|
||||||
("webproxy", "http"),
|
if !val.is_empty() {
|
||||||
("socksfirewallproxy", "socks5"),
|
return Some(val);
|
||||||
] {
|
}
|
||||||
let output = command_stdout(
|
|
||||||
Command::new("networksetup").args([format!("-get{target}"), service.clone()]),
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
if let Some(proxy) = parse_macos_networksetup_proxy(&output, scheme)
|
|
||||||
.filter(|proxy| proxy_matches_host_port(proxy, host, port))
|
|
||||||
{
|
|
||||||
return Some(proxy);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,15 +235,78 @@ fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
fn parse_macos_network_services(output: &str) -> Vec<String> {
|
fn parse_macos_scutil_proxy(output: &str) -> Option<String> {
|
||||||
output
|
for (enable_key, proxy_key, port_key, scheme) in [
|
||||||
|
("HTTPSEnable", "HTTPSProxy", "HTTPSPort", "http"),
|
||||||
|
("HTTPEnable", "HTTPProxy", "HTTPPort", "http"),
|
||||||
|
("SOCKSEnable", "SOCKSProxy", "SOCKSPort", "socks5"),
|
||||||
|
] {
|
||||||
|
if scutil_dict_value(output, enable_key) == Some("1") {
|
||||||
|
if let (Some(server), Some(port)) = (
|
||||||
|
scutil_dict_value(output, proxy_key),
|
||||||
|
scutil_dict_value(output, port_key),
|
||||||
|
) {
|
||||||
|
if let Some(proxy) = proxy_from_host_port(server, port, scheme) {
|
||||||
|
return Some(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
|
async fn macos_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
|
if let Ok(scutil_output) = runner.stdout("scutil", &string_args(&["--proxy"])).await {
|
||||||
|
if let Some(proxy) = parse_macos_scutil_proxy(&scutil_output) {
|
||||||
|
return Ok(Some(proxy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let services_output = match runner
|
||||||
|
.stdout("networksetup", &string_args(&["-listallnetworkservices"]))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(error) => {
|
||||||
|
if is_probe_unavailable(&error) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let services = parse_macos_network_services(&services_output)?;
|
||||||
|
for (target, scheme) in [
|
||||||
|
("securewebproxy", "http"),
|
||||||
|
("webproxy", "http"),
|
||||||
|
("socksfirewallproxy", "socks5"),
|
||||||
|
] {
|
||||||
|
for service in &services {
|
||||||
|
let args = vec![format!("-get{target}"), service.clone()];
|
||||||
|
if let Ok(output) = runner.stdout("networksetup", &args).await {
|
||||||
|
if let Some(proxy) = parse_macos_networksetup_proxy(&output, scheme) {
|
||||||
|
return Ok(Some(proxy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
|
fn parse_macos_network_services(output: &str) -> Result<Vec<String>, String> {
|
||||||
|
let services = output
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|line| !line.is_empty())
|
.filter(|line| !line.is_empty())
|
||||||
.filter(|line| !line.starts_with("An asterisk"))
|
.filter(|line| !line.starts_with("An asterisk"))
|
||||||
.filter(|line| !line.starts_with('*'))
|
.filter(|line| !line.starts_with('*'))
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
|
if services.len() > PROXY_NETWORK_SERVICE_LIMIT {
|
||||||
|
return Err("macOS returned too many network services".to_string());
|
||||||
|
}
|
||||||
|
Ok(services)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
@@ -213,15 +318,7 @@ fn parse_macos_networksetup_proxy(output: &str, scheme: &str) -> Option<String>
|
|||||||
}
|
}
|
||||||
let server = macos_networksetup_value(output, "Server:")?;
|
let server = macos_networksetup_value(output, "Server:")?;
|
||||||
let port = macos_networksetup_value(output, "Port:")?;
|
let port = macos_networksetup_value(output, "Port:")?;
|
||||||
normalize_proxy_address(&format!("{scheme}://{server}:{port}"), scheme)
|
proxy_from_host_port(server, port, scheme)
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|
||||||
fn proxy_matches_host_port(proxy: &str, host: &str, port: u16) -> bool {
|
|
||||||
let Ok(parsed) = url::Url::parse(proxy) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
parsed.host_str() == Some(host) && parsed.port() == Some(port)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
@@ -233,17 +330,56 @@ fn macos_networksetup_value<'a>(output: &'a str, key: &str) -> Option<&'a str> {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
fn linux_gsettings_proxy(service: &str, scheme: &str) -> Option<String> {
|
async fn linux_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
|
let mode = match runner
|
||||||
|
.stdout(
|
||||||
|
"gsettings",
|
||||||
|
&string_args(&["get", "org.gnome.system.proxy", "mode"]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mode) => mode,
|
||||||
|
Err(error) => {
|
||||||
|
if is_probe_unavailable(&error) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if strip_gsettings_string(&mode) != "manual" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (service, scheme) in [("https", "http"), ("http", "http"), ("socks", "socks5")] {
|
||||||
|
if let Some(proxy) = linux_gsettings_proxy(runner, service, scheme).await {
|
||||||
|
return Ok(Some(proxy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
async fn linux_gsettings_proxy(
|
||||||
|
runner: &dyn ProxyCommandRunner,
|
||||||
|
service: &str,
|
||||||
|
scheme: &str,
|
||||||
|
) -> Option<String> {
|
||||||
let schema = format!("org.gnome.system.proxy.{service}");
|
let schema = format!("org.gnome.system.proxy.{service}");
|
||||||
let host = command_stdout(Command::new("gsettings").args(["get", &schema, "host"])).ok()?;
|
let host = runner
|
||||||
|
.stdout("gsettings", &string_args(&["get", &schema, "host"]))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
let host = strip_gsettings_string(&host);
|
let host = strip_gsettings_string(&host);
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let port = command_stdout(Command::new("gsettings").args(["get", &schema, "port"])).ok()?;
|
let port = runner
|
||||||
|
.stdout("gsettings", &string_args(&["get", &schema, "port"]))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
let port = port.trim();
|
let port = port.trim();
|
||||||
normalize_proxy_address(&format!("{scheme}://{host}:{port}"), scheme)
|
proxy_from_host_port(&host, port, scheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
@@ -255,52 +391,56 @@ fn strip_gsettings_string(value: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
fn fallback_windows_proxy() -> Result<Option<String>, ()> {
|
async fn windows_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||||
use std::os::windows::process::CommandExt;
|
let output = match runner
|
||||||
use std::process::Command;
|
.stdout(
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
"reg",
|
||||||
|
&string_args(&[
|
||||||
let output = Command::new("reg")
|
"query",
|
||||||
.args(&[
|
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||||
"query",
|
"/v",
|
||||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
"ProxyEnable",
|
||||||
"/v",
|
]),
|
||||||
"ProxyEnable",
|
)
|
||||||
])
|
.await
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
{
|
||||||
.output()
|
Ok(output) => output,
|
||||||
.map_err(|_| ())?;
|
Err(error) => {
|
||||||
|
if is_probe_unavailable(&error) {
|
||||||
if !output.status.success() {
|
return Ok(None);
|
||||||
return Err(());
|
}
|
||||||
}
|
return Err(error);
|
||||||
|
}
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
};
|
||||||
let enabled = registry_value(&stdout, "ProxyEnable")
|
let enabled = registry_value(&output, "ProxyEnable")
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(windows_proxy_enabled);
|
.is_some_and(windows_proxy_enabled);
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = Command::new("reg")
|
let output = match runner
|
||||||
.args(&[
|
.stdout(
|
||||||
"query",
|
"reg",
|
||||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
&string_args(&[
|
||||||
"/v",
|
"query",
|
||||||
"ProxyServer",
|
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||||
])
|
"/v",
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
"ProxyServer",
|
||||||
.output()
|
]),
|
||||||
.map_err(|_| ())?;
|
)
|
||||||
|
.await
|
||||||
if !output.status.success() {
|
{
|
||||||
return Err(());
|
Ok(output) => output,
|
||||||
}
|
Err(error) => {
|
||||||
|
if is_probe_unavailable(&error) {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
return Ok(None);
|
||||||
Ok(registry_value(&stdout, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value)))
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(registry_value(&output, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
@@ -341,10 +481,78 @@ fn registry_value(output: &str, name: &str) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proxy_tests {
|
mod proxy_tests {
|
||||||
use super::{
|
use super::{
|
||||||
normalize_proxy_address, normalize_sysproxy_address, parse_macos_network_services,
|
bounded_native_system_proxy, normalize_proxy_address, parse_macos_network_services,
|
||||||
parse_macos_networksetup_proxy, parse_windows_proxy_server, proxy_matches_host_port,
|
parse_macos_networksetup_proxy, parse_macos_scutil_proxy, parse_windows_proxy_server,
|
||||||
registry_value, strip_gsettings_string, windows_proxy_enabled,
|
proxy_from_host_port, registry_value, strip_gsettings_string, windows_proxy_enabled,
|
||||||
|
ProxyCommandRunner, SystemProxyCommandRunner,
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
struct MockProxyCommandRunner;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
struct SlowProxyCommandRunner;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
struct ScutilProxyCommandRunner;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for MockProxyCommandRunner {
|
||||||
|
async fn stdout(&self, program: &str, args: &[String]) -> Result<String, String> {
|
||||||
|
match program {
|
||||||
|
"scutil" => {
|
||||||
|
assert_eq!(args, &["--proxy"]);
|
||||||
|
Ok("<dictionary> {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n".to_string())
|
||||||
|
}
|
||||||
|
"networksetup" => match args
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.as_slice()
|
||||||
|
{
|
||||||
|
["-listallnetworkservices"] => Ok("Wi-Fi\nEthernet\n".to_string()),
|
||||||
|
["-getsecurewebproxy", "Wi-Fi"] => {
|
||||||
|
Ok("Enabled: No\nServer: ignored.example\nPort: 443\n".to_string())
|
||||||
|
}
|
||||||
|
["-getsecurewebproxy", "Ethernet"] => {
|
||||||
|
Ok("Enabled: Yes\nServer: secure.example\nPort: 8443\n".to_string())
|
||||||
|
}
|
||||||
|
["-getwebproxy", _] | ["-getsocksfirewallproxy", _] => {
|
||||||
|
panic!("lower-priority proxy was queried after HTTPS succeeded")
|
||||||
|
}
|
||||||
|
_ => Err("unexpected command".to_string()),
|
||||||
|
},
|
||||||
|
_ => Err("unexpected command".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for ScutilProxyCommandRunner {
|
||||||
|
async fn stdout(&self, program: &str, args: &[String]) -> Result<String, String> {
|
||||||
|
assert_eq!(program, "scutil");
|
||||||
|
assert_eq!(args, &["--proxy"]);
|
||||||
|
Ok(r#"<dictionary> {
|
||||||
|
HTTPSEnable : 1
|
||||||
|
HTTPSPort : 8443
|
||||||
|
HTTPSProxy : scutil.example
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for SlowProxyCommandRunner {
|
||||||
|
async fn stdout(&self, _program: &str, _args: &[String]) -> Result<String, String> {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_bare_proxy_addresses() {
|
fn normalizes_bare_proxy_addresses() {
|
||||||
@@ -357,6 +565,33 @@ mod proxy_tests {
|
|||||||
Some("socks5://127.0.0.1:1080")
|
Some("socks5://127.0.0.1:1080")
|
||||||
);
|
);
|
||||||
assert_eq!(normalize_proxy_address("file:///tmp/proxy", "http"), None);
|
assert_eq!(normalize_proxy_address("file:///tmp/proxy", "http"), None);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_proxy_address("http://proxy.test/secret", "http"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_proxy_address("http://proxy.test?token=secret", "http"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_proxy_address("http://proxy.test:0", "http"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_native_proxy_hosts_without_accepting_structural_injection() {
|
||||||
|
assert_eq!(
|
||||||
|
proxy_from_host_port("2001:db8::1", "8080", "http").as_deref(),
|
||||||
|
Some("http://[2001:db8::1]:8080")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
proxy_from_host_port("proxy.test/path", "8080", "http"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
proxy_from_host_port("user@proxy.test", "8080", "http"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(proxy_from_host_port("[2001:db8::1", "8080", "http"), None);
|
||||||
|
assert_eq!(proxy_from_host_port("proxy.test", "0", "http"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -375,22 +610,6 @@ mod proxy_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_sysproxy_host_without_duplicating_ports() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_sysproxy_address("http://proxy.local", 8080).as_deref(),
|
|
||||||
Some("http://proxy.local:8080")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_sysproxy_address("http://proxy.local:9000", 8080).as_deref(),
|
|
||||||
Some("http://proxy.local:9000")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_sysproxy_address("proxy.local", 8080).as_deref(),
|
|
||||||
Some("http://proxy.local:8080")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_macos_proxy_outputs_with_scheme() {
|
fn parses_macos_proxy_outputs_with_scheme() {
|
||||||
let services = r#"
|
let services = r#"
|
||||||
@@ -400,7 +619,7 @@ Wi-Fi
|
|||||||
Thunderbolt Bridge
|
Thunderbolt Bridge
|
||||||
"#;
|
"#;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_macos_network_services(services),
|
parse_macos_network_services(services).unwrap(),
|
||||||
vec!["Wi-Fi".to_string(), "Thunderbolt Bridge".to_string()]
|
vec!["Wi-Fi".to_string(), "Thunderbolt Bridge".to_string()]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,21 +633,55 @@ Authenticated Proxy Enabled: 0
|
|||||||
parse_macos_networksetup_proxy(proxy, "socks5").as_deref(),
|
parse_macos_networksetup_proxy(proxy, "socks5").as_deref(),
|
||||||
Some("socks5://127.0.0.1:1080")
|
Some("socks5://127.0.0.1:1080")
|
||||||
);
|
);
|
||||||
assert!(proxy_matches_host_port(
|
|
||||||
"socks5://127.0.0.1:1080",
|
|
||||||
"127.0.0.1",
|
|
||||||
1080
|
|
||||||
));
|
|
||||||
assert!(!proxy_matches_host_port(
|
|
||||||
"socks5://127.0.0.1:1080",
|
|
||||||
"127.0.0.1",
|
|
||||||
1081
|
|
||||||
));
|
|
||||||
|
|
||||||
let disabled = proxy.replace("Enabled: Yes", "Enabled: No");
|
let disabled = proxy.replace("Enabled: Yes", "Enabled: No");
|
||||||
assert_eq!(parse_macos_networksetup_proxy(&disabled, "socks5"), None);
|
assert_eq!(parse_macos_networksetup_proxy(&disabled, "socks5"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounds_native_command_runtime_and_output() {
|
||||||
|
let runner = SystemProxyCommandRunner;
|
||||||
|
let oversized = format!("print('x' * {})", super::PROXY_COMMAND_OUTPUT_LIMIT + 1);
|
||||||
|
let error = runner
|
||||||
|
.stdout("python3", &["-c".to_string(), oversized])
|
||||||
|
.await
|
||||||
|
.expect_err("oversized output must fail closed");
|
||||||
|
assert!(error.contains("safety limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn selects_https_across_services_before_lower_priority_proxies() {
|
||||||
|
assert_eq!(
|
||||||
|
super::macos_system_proxy(&MockProxyCommandRunner)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("http://secure.example:8443")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounds_the_complete_native_proxy_discovery_lifecycle() {
|
||||||
|
let error = bounded_native_system_proxy(&SlowProxyCommandRunner, Duration::from_millis(10))
|
||||||
|
.await
|
||||||
|
.expect_err("the complete native probe must have one bounded deadline");
|
||||||
|
assert_eq!(error, "system proxy discovery timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_macos_network_service_enumeration() {
|
||||||
|
let output = (0..100)
|
||||||
|
.map(|index| format!("Service {index}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert_eq!(
|
||||||
|
parse_macos_network_services(&output).unwrap_err(),
|
||||||
|
"macOS returned too many network services"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_gsettings_string_quotes() {
|
fn strips_gsettings_string_quotes() {
|
||||||
assert_eq!(strip_gsettings_string("'manual'\n"), "manual");
|
assert_eq!(strip_gsettings_string("'manual'\n"), "manual");
|
||||||
@@ -453,6 +706,132 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings
|
|||||||
assert!(!windows_proxy_enabled("0X0"));
|
assert!(!windows_proxy_enabled("0X0"));
|
||||||
assert!(windows_proxy_enabled("0X1"));
|
assert!(windows_proxy_enabled("0X1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn selects_active_scutil_proxy_before_networksetup() {
|
||||||
|
assert_eq!(
|
||||||
|
super::macos_system_proxy(&ScutilProxyCommandRunner)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("http://scutil.example:8443")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_macos_scutil_proxy_outputs() {
|
||||||
|
let scutil_https = r#"<dictionary> {
|
||||||
|
HTTPEnable : 1
|
||||||
|
HTTPPort : 8080
|
||||||
|
HTTPProxy : 127.0.0.1
|
||||||
|
HTTPSEnable : 1
|
||||||
|
HTTPSPort : 8443
|
||||||
|
HTTPSProxy : secure.local
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_macos_scutil_proxy(scutil_https).as_deref(),
|
||||||
|
Some("http://secure.local:8443")
|
||||||
|
);
|
||||||
|
|
||||||
|
let scutil_socks = r#"<dictionary> {
|
||||||
|
HTTPEnable : 0
|
||||||
|
HTTPSEnable : 0
|
||||||
|
SOCKSEnable : 1
|
||||||
|
SOCKSPort : 1080
|
||||||
|
SOCKSProxy : 127.0.0.1
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_macos_scutil_proxy(scutil_socks).as_deref(),
|
||||||
|
Some("socks5://127.0.0.1:1080")
|
||||||
|
);
|
||||||
|
|
||||||
|
let scutil_ipv6 = r#"<dictionary> {
|
||||||
|
HTTPEnable : 1
|
||||||
|
HTTPPort : 8080
|
||||||
|
HTTPProxy : 2001:db8::1
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_macos_scutil_proxy(scutil_ipv6).as_deref(),
|
||||||
|
Some("http://[2001:db8::1]:8080")
|
||||||
|
);
|
||||||
|
|
||||||
|
let scutil_disabled = r#"<dictionary> {
|
||||||
|
HTTPEnable : 0
|
||||||
|
HTTPSEnable : 0
|
||||||
|
SOCKSEnable : 0
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
assert_eq!(parse_macos_scutil_proxy(scutil_disabled), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockWindowsMissingRegRunner;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for MockWindowsMissingRegRunner {
|
||||||
|
async fn stdout(&self, _program: &str, _args: &[String]) -> Result<String, String> {
|
||||||
|
Err("reg exited unsuccessfully".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockWindowsEnabledRegRunner;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for MockWindowsEnabledRegRunner {
|
||||||
|
async fn stdout(&self, program: &str, args: &[String]) -> Result<String, String> {
|
||||||
|
assert_eq!(program, "reg");
|
||||||
|
if args.iter().any(|a| a == "ProxyEnable") {
|
||||||
|
Ok(" ProxyEnable REG_DWORD 0x1\n".to_string())
|
||||||
|
} else if args.iter().any(|a| a == "ProxyServer") {
|
||||||
|
Ok(" ProxyServer REG_SZ 127.0.0.1:8080\n".to_string())
|
||||||
|
} else {
|
||||||
|
Err("reg exited unsuccessfully".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn windows_system_proxy_returns_none_when_value_is_missing() {
|
||||||
|
assert_eq!(
|
||||||
|
super::windows_system_proxy(&MockWindowsMissingRegRunner)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn windows_system_proxy_returns_configured_proxy() {
|
||||||
|
assert_eq!(
|
||||||
|
super::windows_system_proxy(&MockWindowsEnabledRegRunner)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("http://127.0.0.1:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockLinuxUnavailableGsettingsRunner;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProxyCommandRunner for MockLinuxUnavailableGsettingsRunner {
|
||||||
|
async fn stdout(&self, _program: &str, _args: &[String]) -> Result<String, String> {
|
||||||
|
Err("gsettings is unavailable: not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn linux_system_proxy_returns_none_when_gsettings_is_unavailable() {
|
||||||
|
assert_eq!(
|
||||||
|
super::linux_system_proxy(&MockLinuxUnavailableGsettingsRunner)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -680,6 +1059,9 @@ pub fn get_supported_media_domains() -> Vec<String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn is_supported_media(url: String) -> bool {
|
pub fn is_supported_media(url: String) -> bool {
|
||||||
if let Ok(parsed_url) = reqwest::Url::parse(&url) {
|
if let Ok(parsed_url) = reqwest::Url::parse(&url) {
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(host) = parsed_url.host_str() {
|
if let Some(host) = parsed_url.host_str() {
|
||||||
let host_lower = host.to_lowercase();
|
let host_lower = host.to_lowercase();
|
||||||
for domain in SUPPORTED_DOMAINS.iter() {
|
for domain in SUPPORTED_DOMAINS.iter() {
|
||||||
@@ -694,7 +1076,7 @@ pub fn is_supported_media(url: String) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::get_file_category;
|
use super::{get_file_category, is_supported_media};
|
||||||
use crate::ipc::DownloadCategory;
|
use crate::ipc::DownloadCategory;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -708,4 +1090,13 @@ mod tests {
|
|||||||
DownloadCategory::Movies
|
DownloadCategory::Movies
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_http_urls_are_supported_media_routes() {
|
||||||
|
assert!(is_supported_media(
|
||||||
|
"https://youtube.com/watch?v=video".to_string()
|
||||||
|
));
|
||||||
|
assert!(!is_supported_media("ftp://youtube.com/video".to_string()));
|
||||||
|
assert!(!is_supported_media("sftp://youtube.com/video".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-9
@@ -2,6 +2,79 @@ use std::ffi::OsString;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Return a stable identity for an existing directory.
|
||||||
|
///
|
||||||
|
/// Canonical paths alone are not a sufficient ownership fence on Windows,
|
||||||
|
/// where Aria2 may report different casing or separators for the same
|
||||||
|
/// directory. Pair canonicalization with the platform's filesystem identity
|
||||||
|
/// so callers compare the object a path resolves to instead of its spelling.
|
||||||
|
pub fn directory_identity(path: &Path) -> io::Result<String> {
|
||||||
|
let canonical = std::fs::canonicalize(path)?;
|
||||||
|
let metadata = std::fs::metadata(&canonical)?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotADirectory,
|
||||||
|
"path is not a directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
return Ok(format!("{}:{}", metadata.dev(), metadata.ino()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
return windows_directory_identity(&canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok(canonical.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn windows_directory_identity(path: &Path) -> io::Result<String> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
|
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||||
|
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
|
||||||
|
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||||
|
};
|
||||||
|
|
||||||
|
let wide_path = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let handle = unsafe {
|
||||||
|
CreateFileW(
|
||||||
|
wide_path.as_ptr(),
|
||||||
|
0,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||||
|
std::ptr::null(),
|
||||||
|
OPEN_EXISTING,
|
||||||
|
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if handle == INVALID_HANDLE_VALUE {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut metadata = BY_HANDLE_FILE_INFORMATION::default();
|
||||||
|
let succeeded = unsafe { GetFileInformationByHandle(handle, &mut metadata) != 0 };
|
||||||
|
let error = (!succeeded).then(io::Error::last_os_error);
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(handle);
|
||||||
|
}
|
||||||
|
match error {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(format_file_identity(&metadata)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return a stable filesystem identity for an existing Windows file without
|
/// Return a stable filesystem identity for an existing Windows file without
|
||||||
/// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with
|
/// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with
|
||||||
/// delete sharing so inspection does not unnecessarily block normal cleanup
|
/// delete sharing so inspection does not unnecessarily block normal cleanup
|
||||||
@@ -13,8 +86,8 @@ pub fn file_identity(path: &Path) -> Option<String> {
|
|||||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
use windows_sys::Win32::Storage::FileSystem::{
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_NORMAL,
|
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_NORMAL,
|
||||||
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
|
||||||
OPEN_EXISTING,
|
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||||
};
|
};
|
||||||
|
|
||||||
let wide_path = path
|
let wide_path = path
|
||||||
@@ -32,7 +105,7 @@ pub fn file_identity(path: &Path) -> Option<String> {
|
|||||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
OPEN_EXISTING,
|
OPEN_EXISTING,
|
||||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -46,12 +119,32 @@ pub fn file_identity(path: &Path) -> Option<String> {
|
|||||||
let _ = CloseHandle(handle);
|
let _ = CloseHandle(handle);
|
||||||
succeeded
|
succeeded
|
||||||
};
|
};
|
||||||
result.then(|| {
|
result.then(|| format_file_identity(&metadata))
|
||||||
format!(
|
}
|
||||||
"{}:{}:{}",
|
|
||||||
metadata.dwVolumeSerialNumber, metadata.nFileIndexHigh, metadata.nFileIndexLow
|
/// Return the identity of the already-open Windows file handle. This keeps a
|
||||||
)
|
/// replacement check tied to the same file that was hashed instead of
|
||||||
})
|
/// reopening the path and trusting a second path lookup.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn file_identity_for_handle(file: &std::fs::File) -> Option<String> {
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
|
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut metadata = BY_HANDLE_FILE_INFORMATION::default();
|
||||||
|
let succeeded = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut metadata) != 0 };
|
||||||
|
succeeded.then(|| format_file_identity(&metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn format_file_identity(
|
||||||
|
metadata: &windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
metadata.dwVolumeSerialNumber, metadata.nFileIndexHigh, metadata.nFileIndexLow
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ATOMIC_TEMP_PREFIX: &str = ".firelink-atomic-";
|
const ATOMIC_TEMP_PREFIX: &str = ".firelink-atomic-";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const PROPERTIES_SESSION_HISTORY_EXHAUSTED: &str =
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PropertiesWindowRegistry {
|
pub struct PropertiesWindowRegistry {
|
||||||
state: Mutex<RegistryState>,
|
state: Mutex<RegistryState>,
|
||||||
|
window_creation: tokio::sync::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -63,6 +64,10 @@ struct PropertiesWindowActionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PropertiesWindowRegistry {
|
impl PropertiesWindowRegistry {
|
||||||
|
async fn lock_window_creation(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||||
|
self.window_creation.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn remember_size(
|
pub(crate) fn remember_size(
|
||||||
&self,
|
&self,
|
||||||
window_label: &str,
|
window_label: &str,
|
||||||
@@ -394,13 +399,18 @@ fn validate_properties_request_id(request_id: u64) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_download_properties_window(
|
pub async fn open_download_properties_window(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
db: tauri::State<'_, crate::db::DbState>,
|
db: tauri::State<'_, crate::db::DbState>,
|
||||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
// WebviewWindowBuilder::build can deadlock on Windows when it runs in a
|
||||||
|
// synchronous command or event handler because WebView2 initialization
|
||||||
|
// needs the native event loop to keep pumping. This command is async so
|
||||||
|
// Tauri executes the blocking construction away from the renderer/native
|
||||||
|
// command callback that initiated it.
|
||||||
if caller.label() != MAIN_WINDOW_LABEL {
|
if caller.label() != MAIN_WINDOW_LABEL {
|
||||||
return Err("Only the main window can open Properties windows".to_string());
|
return Err("Only the main window can open Properties windows".to_string());
|
||||||
}
|
}
|
||||||
@@ -409,6 +419,11 @@ pub fn open_download_properties_window(
|
|||||||
return Err("Download no longer exists".to_string());
|
return Err("Download no longer exists".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Async command invocations can overlap before Tauri registers a newly
|
||||||
|
// created native window. Serialize the lookup/build/cleanup transaction
|
||||||
|
// so a duplicate request cannot remove the registry entry of the request
|
||||||
|
// that successfully created the window.
|
||||||
|
let _window_creation_guard = registry.lock_window_creation().await;
|
||||||
let label = registry.allocate(&id)?;
|
let label = registry.allocate(&id)?;
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
// Visibility belongs to the native window owner, not to the renderer
|
// Visibility belongs to the native window owner, not to the renderer
|
||||||
@@ -439,16 +454,24 @@ pub fn open_download_properties_window(
|
|||||||
.visible(false)
|
.visible(false)
|
||||||
// A hidden WebView2 must not request focus during construction. The
|
// A hidden WebView2 must not request focus during construction. The
|
||||||
// native reveal path focuses it after the window is visible.
|
// native reveal path focuses it after the window is visible.
|
||||||
.focused(false)
|
.focused(false);
|
||||||
.transparent(true);
|
// Native elevation follows the window shape on macOS. On Windows, Tao enables
|
||||||
|
// an undecorated shadow that leaves an opaque native frame outside the rounded
|
||||||
|
// renderer surface at the corners, so shadow is disabled. Linux does not implement
|
||||||
|
// this API, so its boundary remains the renderer's theme-aware contour.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let builder = builder.transparent(true).shadow(true);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let builder = builder.transparent(true).shadow(false);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let builder = builder.transparent(false).shadow(false);
|
||||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||||
let builder = builder.decorations(false);
|
let builder = builder.decorations(false);
|
||||||
let build_result = builder.build();
|
let build_result = builder.build();
|
||||||
if let Err(error) = build_result {
|
if let Err(error) = build_result {
|
||||||
// Two rapid main-window requests can race between the native lookup
|
// The native builder can report an error after registering a window.
|
||||||
// above and builder creation. If the first request won, retain the
|
// Prefer that registered native owner over discarding its registry
|
||||||
// registry entry and focus its window instead of treating the second
|
// entry and leaving the child inaccessible.
|
||||||
// request as a failed open.
|
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
@@ -590,12 +613,13 @@ pub fn validate_properties_window_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn close_download_properties_window(
|
pub async fn close_download_properties_window(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let _window_creation_guard = registry.lock_window_creation().await;
|
||||||
let label = caller.label();
|
let label = caller.label();
|
||||||
let registered_id = if label == MAIN_WINDOW_LABEL {
|
let registered_id = if label == MAIN_WINDOW_LABEL {
|
||||||
registry.window_for_download(&id)?.map(|_| id.clone())
|
registry.window_for_download(&id)?.map(|_| id.clone())
|
||||||
@@ -621,7 +645,7 @@ pub fn close_download_properties_window(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn properties_window_registry_remove_for_download(
|
pub async fn properties_window_registry_remove_for_download(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||||
@@ -630,6 +654,7 @@ pub fn properties_window_registry_remove_for_download(
|
|||||||
if caller.label() != MAIN_WINDOW_LABEL {
|
if caller.label() != MAIN_WINDOW_LABEL {
|
||||||
return Err("Only the main window can remove a Properties window".to_string());
|
return Err("Only the main window can remove a Properties window".to_string());
|
||||||
}
|
}
|
||||||
|
let _window_creation_guard = registry.lock_window_creation().await;
|
||||||
if let Some(label) = registry.remove_download(&id)? {
|
if let Some(label) = registry.remove_download(&id)? {
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
// This command is used after the download has already been
|
// This command is used after the download has already been
|
||||||
@@ -672,6 +697,17 @@ mod tests {
|
|||||||
assert_ne!(registry.allocate("download-a").unwrap(), first);
|
assert_ne!(registry.allocate("download-a").unwrap(), first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_creation_lock_is_exclusive() {
|
||||||
|
let registry = PropertiesWindowRegistry::default();
|
||||||
|
let guard = registry.lock_window_creation().await;
|
||||||
|
|
||||||
|
assert!(registry.window_creation.try_lock().is_err());
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
assert!(registry.window_creation.try_lock().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remembered_size_uses_logical_units_and_survives_window_cleanup() {
|
fn remembered_size_uses_logical_units_and_survives_window_cleanup() {
|
||||||
let registry = PropertiesWindowRegistry::default();
|
let registry = PropertiesWindowRegistry::default();
|
||||||
|
|||||||
+308
-270
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
|||||||
|
//! Durable removal intent is independent of renderer download snapshots. Completed
|
||||||
|
//! jobs remain as tombstones, so an old save can never recreate a deleted UUID.
|
||||||
|
use crate::ipc::{DownloadAssetRemovalPolicy, DownloadRemovalJob, DownloadRemovalPhase as Phase};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
static WORKER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
|
fn jobs(connection: &Connection) -> Result<Vec<DownloadRemovalJob>, String> {
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare("SELECT data FROM download_removal_jobs ORDER BY rowid")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.map(|row| {
|
||||||
|
serde_json::from_str(&row.map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(connection: &Connection, job: &DownloadRemovalJob) -> Result<(), String> {
|
||||||
|
connection.execute("INSERT INTO download_removal_jobs(id,data) VALUES(?1,?2) ON CONFLICT(id) DO UPDATE SET data=excluded.data",
|
||||||
|
params![job.id, serde_json::to_string(job).map_err(|e| e.to_string())?]).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_job(app: &tauri::AppHandle, id: &str) -> Result<bool, String> {
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let connection = db.lock()?;
|
||||||
|
let exists: bool = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ensure_not_removing(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||||
|
if has_job(app, id)? {
|
||||||
|
Err("Download removal is pending or requires retry".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_download_removals(
|
||||||
|
caller: tauri::WebviewWindow,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<Vec<DownloadRemovalJob>, String> {
|
||||||
|
crate::properties_window::ensure_main_window(&caller)?;
|
||||||
|
jobs(&*app.state::<crate::db::DbState>().lock()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn submit_download_removals(
|
||||||
|
caller: tauri::WebviewWindow,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
ids: Vec<String>,
|
||||||
|
delete_assets: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
crate::properties_window::ensure_main_window(&caller)?;
|
||||||
|
// Fence each admission before recording intent, retaining all existing rows
|
||||||
|
// and ownership records until physical cleanup has actually succeeded.
|
||||||
|
let result = async {
|
||||||
|
let state = app.state::<crate::AppState>();
|
||||||
|
for id in ids {
|
||||||
|
let _guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
|
let job = {
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let connection = db.lock()?;
|
||||||
|
let existing: Option<String> = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT data FROM download_removal_jobs WHERE id=?1",
|
||||||
|
[&id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if existing.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let exists: bool = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM downloads WHERE id=?1)",
|
||||||
|
[&id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !exists {
|
||||||
|
return Err("Download is not durably saved".into());
|
||||||
|
}
|
||||||
|
let job = DownloadRemovalJob {
|
||||||
|
id: id.clone(),
|
||||||
|
revision: 1,
|
||||||
|
delete_assets,
|
||||||
|
phase: Phase::Pending,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
save(&connection, &job)?;
|
||||||
|
job
|
||||||
|
};
|
||||||
|
state.queue_manager.remove_from_pending(&id).await;
|
||||||
|
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||||
|
if state.queue_manager.is_waiting_to_seed(&id) {
|
||||||
|
state.queue_manager.release_seed_tracking(&id);
|
||||||
|
}
|
||||||
|
let _ = app.emit("download-removal", &job);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
kick(&app);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn resume_download_removals(
|
||||||
|
caller: tauri::WebviewWindow,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
crate::properties_window::ensure_main_window(&caller)?;
|
||||||
|
kick(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn retry_download_removal(
|
||||||
|
caller: tauri::WebviewWindow,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
crate::properties_window::ensure_main_window(&caller)?;
|
||||||
|
{
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let connection = db.lock()?;
|
||||||
|
let mut job = jobs(&connection)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|job| job.id == id)
|
||||||
|
.ok_or("Removal job not found")?;
|
||||||
|
if job.phase != Phase::Failed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
job.revision = job.revision.saturating_add(1);
|
||||||
|
job.phase = Phase::Pending;
|
||||||
|
job.error = None;
|
||||||
|
save(&connection, &job)?;
|
||||||
|
let _ = app.emit("download-removal", &job);
|
||||||
|
}
|
||||||
|
kick(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kick(app: &tauri::AppHandle) {
|
||||||
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let _worker = WORKER.lock().await;
|
||||||
|
// Filesystem guards include synchronous platform APIs. Run the entire
|
||||||
|
// cleanup on a blocking thread, with async RPC/timers using the runtime.
|
||||||
|
let runtime = tokio::runtime::Handle::current();
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || runtime.block_on(run(app))).await;
|
||||||
|
if !matches!(result, Ok(Ok(()))) {
|
||||||
|
log::error!("download removal worker stopped; durable jobs retained for recovery");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
loop {
|
||||||
|
let next = {
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let connection = db.lock()?;
|
||||||
|
jobs(&connection)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|job| matches!(job.phase, Phase::Pending | Phase::Running))
|
||||||
|
};
|
||||||
|
let Some(mut job) = next else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
job.revision = job.revision.saturating_add(1);
|
||||||
|
job.phase = Phase::Running;
|
||||||
|
let saved = app
|
||||||
|
.state::<crate::db::DbState>()
|
||||||
|
.lock()
|
||||||
|
.and_then(|connection| save(&connection, &job));
|
||||||
|
if let Err(error) = saved {
|
||||||
|
emit_persistence_failure(&app, &mut job);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let _ = app.emit("download-removal", &job);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = crate::remove_download_inner(
|
||||||
|
app.clone(),
|
||||||
|
app.state::<crate::AppState>(),
|
||||||
|
job.id.clone(),
|
||||||
|
job.delete_assets,
|
||||||
|
Some(false),
|
||||||
|
Some(DownloadAssetRemovalPolicy::PermanentIfUnfinished),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
job.revision = job.revision.saturating_add(1);
|
||||||
|
let committed = (|| -> Result<(), String> {
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let mut connection = db.lock()?;
|
||||||
|
let tx = connection.transaction().map_err(|e| e.to_string())?;
|
||||||
|
if result.is_ok() {
|
||||||
|
tx.execute("DELETE FROM download_ownership WHERE id=?1", [&job.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("DELETE FROM download_owned_paths WHERE id=?1", [&job.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("DELETE FROM download_removal_paths WHERE id=?1", [&job.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("DELETE FROM download_removal_assets WHERE id=?1", [&job.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("DELETE FROM downloads WHERE id=?1", [&job.id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
job.phase = Phase::Completed;
|
||||||
|
job.error = None;
|
||||||
|
} else {
|
||||||
|
job.phase = Phase::Failed;
|
||||||
|
// Native errors can contain private paths. Keep only actionable,
|
||||||
|
// safe UI guidance in the durable record and public event.
|
||||||
|
job.error = Some("Removal could not finish. Close programs using the files, check drive access and permissions, then retry removal.".into());
|
||||||
|
}
|
||||||
|
save(&tx, &job)?;
|
||||||
|
tx.commit().map_err(|e| e.to_string())
|
||||||
|
})();
|
||||||
|
if let Err(error) = committed {
|
||||||
|
emit_persistence_failure(&app, &mut job);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"download removal [id={} phase={:?} elapsed_ms={}]",
|
||||||
|
job.id,
|
||||||
|
job.phase,
|
||||||
|
started.elapsed().as_millis()
|
||||||
|
);
|
||||||
|
let _ = app.emit("download-removal", &job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_persistence_failure(app: &tauri::AppHandle, job: &mut DownloadRemovalJob) {
|
||||||
|
job.phase = Phase::Failed;
|
||||||
|
job.error = Some(
|
||||||
|
"Removal could not be saved. Check disk space and drive access, then retry removal.".into(),
|
||||||
|
);
|
||||||
|
let _ = app.emit("download-removal", &*job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept in a private table, never in shared IPC job data: paths and filesystem
|
||||||
|
// identities are authorization evidence, not diagnostic or presentation data.
|
||||||
|
type AssetManifest = std::collections::BTreeMap<std::path::PathBuf, String>;
|
||||||
|
|
||||||
|
fn snapshot_assets(roots: &[std::path::PathBuf]) -> Result<AssetManifest, String> {
|
||||||
|
let mut pending = roots.to_vec();
|
||||||
|
let mut manifest = AssetManifest::new();
|
||||||
|
while let Some(path) = pending.pop() {
|
||||||
|
if manifest.contains_key(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
Err(_) => return Err("Could not inspect removal assets".into()),
|
||||||
|
};
|
||||||
|
if crate::metadata_is_link_or_reparse(&metadata) || crate::path_has_symlink_component(&path)
|
||||||
|
{
|
||||||
|
return Err("Removal asset contains a symbolic link or reparse point".into());
|
||||||
|
}
|
||||||
|
let identity = crate::target_identity(&path, &metadata);
|
||||||
|
if identity.starts_with("windows-path:") || identity == "portable" {
|
||||||
|
return Err("Could not establish removal asset identity".into());
|
||||||
|
}
|
||||||
|
let signature = if metadata.is_dir() {
|
||||||
|
// Directory mtime changes as its children are removed; identity and
|
||||||
|
// birth time remain stable across partial cleanup and restart.
|
||||||
|
pending.extend(
|
||||||
|
std::fs::read_dir(&path)
|
||||||
|
.map_err(|_| "Could not inspect removal directory")?
|
||||||
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|_| "Could not inspect removal entry")?,
|
||||||
|
);
|
||||||
|
format!("dir:{identity}:{:?}", metadata.created().ok())
|
||||||
|
} else if metadata.is_file() {
|
||||||
|
format!(
|
||||||
|
"file:{identity}:{:?}:{}:{}",
|
||||||
|
metadata.created().ok(),
|
||||||
|
metadata.len(),
|
||||||
|
crate::target_modified(&metadata)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err("Removal asset is not a regular file or directory".into());
|
||||||
|
};
|
||||||
|
manifest.insert(path, signature);
|
||||||
|
}
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_manifest(expected: &AssetManifest, current: &AssetManifest) -> Result<(), String> {
|
||||||
|
// Missing entries are expected after interrupted cleanup. Newly created or
|
||||||
|
// replaced entries never inherit authorization from the old path owner.
|
||||||
|
if current
|
||||||
|
.iter()
|
||||||
|
.any(|(path, signature)| expected.get(path) != Some(signature))
|
||||||
|
{
|
||||||
|
return Err("Removal assets changed since cleanup began".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fence_assets(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
id: &str,
|
||||||
|
roots: &[std::path::PathBuf],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let current = snapshot_assets(roots)?;
|
||||||
|
let db = app.state::<crate::db::DbState>();
|
||||||
|
let connection = db.lock()?;
|
||||||
|
let previous: Option<String> = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT data FROM download_removal_assets WHERE id=?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
validate_manifest(
|
||||||
|
&serde_json::from_str(&previous).map_err(|_| "Invalid removal asset manifest")?,
|
||||||
|
¤t,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_removal_assets(id,data) VALUES(?1,?2)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
serde_json::to_string(¤t).map_err(|e| e.to_string())?
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn interrupted_cleanup_rejects_replacement_and_new_files() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let root = directory.path().canonicalize().unwrap();
|
||||||
|
let a = root.join("a");
|
||||||
|
let b = root.join("b");
|
||||||
|
std::fs::write(&a, b"original").unwrap();
|
||||||
|
std::fs::write(&b, b"original").unwrap();
|
||||||
|
let roots = vec![root.clone()];
|
||||||
|
let manifest = snapshot_assets(&roots).unwrap();
|
||||||
|
std::fs::remove_file(&a).unwrap();
|
||||||
|
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok());
|
||||||
|
let replacement = root.join("replacement");
|
||||||
|
std::fs::write(&replacement, b"replacement").unwrap();
|
||||||
|
std::fs::rename(&replacement, &a).unwrap();
|
||||||
|
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||||
|
std::fs::remove_file(&a).unwrap();
|
||||||
|
std::fs::write(directory.path().join("new"), b"unrelated").unwrap();
|
||||||
|
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn retry_allows_permission_repair_but_rejects_content_changes() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let file = directory.path().canonicalize().unwrap().join("file");
|
||||||
|
std::fs::write(&file, b"original").unwrap();
|
||||||
|
let roots = vec![file.clone()];
|
||||||
|
let manifest = snapshot_assets(&roots).unwrap();
|
||||||
|
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok());
|
||||||
|
std::fs::write(&file, b"changed content").unwrap();
|
||||||
|
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn removal_manifest_does_not_follow_links() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let root = directory.path().canonicalize().unwrap();
|
||||||
|
std::os::unix::fs::symlink(&root, root.join("link")).unwrap();
|
||||||
|
assert!(snapshot_assets(&[root]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,18 @@ use std::time::Duration;
|
|||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
fn minute_of_day(value: &str) -> Option<u32> {
|
fn minute_of_day(value: &str) -> Option<u32> {
|
||||||
let (hour, minute) = value.split_once(':')?;
|
let bytes = value.as_bytes();
|
||||||
let hour = hour.parse::<u32>().ok()?;
|
if bytes.len() != 5
|
||||||
let minute = minute.parse::<u32>().ok()?;
|
|| bytes[2] != b':'
|
||||||
|
|| !bytes[0].is_ascii_digit()
|
||||||
|
|| !bytes[1].is_ascii_digit()
|
||||||
|
|| !bytes[3].is_ascii_digit()
|
||||||
|
|| !bytes[4].is_ascii_digit()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hour = u32::from(bytes[0] - b'0') * 10 + u32::from(bytes[1] - b'0');
|
||||||
|
let minute = u32::from(bytes[3] - b'0') * 10 + u32::from(bytes[4] - b'0');
|
||||||
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
|
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +263,10 @@ mod tests {
|
|||||||
fn rejects_invalid_scheduler_times() {
|
fn rejects_invalid_scheduler_times() {
|
||||||
assert_eq!(minute_of_day("24:00"), None);
|
assert_eq!(minute_of_day("24:00"), None);
|
||||||
assert_eq!(minute_of_day("12:60"), None);
|
assert_eq!(minute_of_day("12:60"), None);
|
||||||
|
assert_eq!(minute_of_day("1:02"), None);
|
||||||
|
assert_eq!(minute_of_day("01:2"), None);
|
||||||
|
assert_eq!(minute_of_day(" 01:02"), None);
|
||||||
|
assert_eq!(minute_of_day("01:02 "), None);
|
||||||
assert_eq!(minute_of_day("bad"), None);
|
assert_eq!(minute_of_day("bad"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+178
-1
@@ -454,6 +454,9 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||||
|
sanitize_integer_setting(state, "minimumNormalDownloadSpeedKiB", |value| value.as_u64().is_some());
|
||||||
|
sanitize_integer_setting(state, "lastCustomSpeedLimitKiB", |value| value.as_u64().is_some());
|
||||||
|
sanitize_allowed_string(state, "lastCustomSpeedLimitUnit", &["KB/s", "MB/s"]);
|
||||||
sanitize_integer_setting(state, "proxyPort", |value| {
|
sanitize_integer_setting(state, "proxyPort", |value| {
|
||||||
value
|
value
|
||||||
.as_u64()
|
.as_u64()
|
||||||
@@ -483,19 +486,85 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
for key in [
|
for key in [
|
||||||
|
"categorySubfoldersEnabled",
|
||||||
|
"logsEnabled",
|
||||||
"isSidebarVisible",
|
"isSidebarVisible",
|
||||||
|
"isFoldersCollapsed",
|
||||||
|
"schedulerRunning",
|
||||||
|
"retryNotFoundErrors",
|
||||||
|
"adaptiveMirrorSelection",
|
||||||
|
"showNotifications",
|
||||||
|
"playCompletionSound",
|
||||||
|
"autoAddClipboardLinks",
|
||||||
|
"showDockBadge",
|
||||||
|
"showMenuBarIcon",
|
||||||
"torrentEnableDht",
|
"torrentEnableDht",
|
||||||
"torrentEnableDht6",
|
"torrentEnableDht6",
|
||||||
"torrentEnablePex",
|
"torrentEnablePex",
|
||||||
"torrentEnableLpd",
|
"torrentEnableLpd",
|
||||||
"torrentSeparateSeedSlots",
|
"torrentSeparateSeedSlots",
|
||||||
"torrentIpv6Enabled",
|
"torrentIpv6Enabled",
|
||||||
|
"askWhereToSaveEachFile",
|
||||||
|
"rememberLastUsedDownloadDirectory",
|
||||||
|
"preventsSleepWhileDownloading",
|
||||||
|
"preventsDisplaySleepWhileDownloading",
|
||||||
|
"autoCheckUpdates",
|
||||||
|
"keychainAccessGranted",
|
||||||
] {
|
] {
|
||||||
sanitize_boolean_setting(state, key);
|
sanitize_boolean_setting(state, key);
|
||||||
}
|
}
|
||||||
for key in ["proxyHost", "customUserAgent"] {
|
for key in [
|
||||||
|
"proxyHost",
|
||||||
|
"customUserAgent",
|
||||||
|
"globalSpeedLimit",
|
||||||
|
"torrentOverallUploadLimit",
|
||||||
|
"baseDownloadFolder",
|
||||||
|
"schedulerLastStartKey",
|
||||||
|
"schedulerLastStopKey",
|
||||||
|
] {
|
||||||
sanitize_string_setting(state, key);
|
sanitize_string_setting(state, key);
|
||||||
}
|
}
|
||||||
|
if let Some(presets) = state.get("speedLimitPresetValues") {
|
||||||
|
if !presets.is_array() {
|
||||||
|
state.remove("speedLimitPresetValues");
|
||||||
|
} else if let Some(presets_arr) = state.get_mut("speedLimitPresetValues").and_then(Value::as_array_mut) {
|
||||||
|
presets_arr.retain(|v| v.as_f64().is_some_and(|f| f.is_finite() && f > 0.0));
|
||||||
|
if presets_arr.is_empty() {
|
||||||
|
state.remove("speedLimitPresetValues");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(roots) = state.get("approvedDownloadRoots") {
|
||||||
|
if !roots.is_array() {
|
||||||
|
state.remove("approvedDownloadRoots");
|
||||||
|
} else if let Some(roots_arr) = state.get_mut("approvedDownloadRoots").and_then(Value::as_array_mut) {
|
||||||
|
roots_arr.retain(|v| v.as_str().is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(active_ids) = state.get("schedulerActiveDownloadIds") {
|
||||||
|
if !active_ids.is_array() {
|
||||||
|
state.remove("schedulerActiveDownloadIds");
|
||||||
|
} else if let Some(ids_arr) = state.get_mut("schedulerActiveDownloadIds").and_then(Value::as_array_mut) {
|
||||||
|
ids_arr.retain(|v| v.as_str().is_some_and(|id| !id.trim().is_empty()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !state
|
||||||
|
.get("schedulerActiveDownloadIds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|ids| !ids.is_empty())
|
||||||
|
{
|
||||||
|
state.insert("schedulerRunning".to_string(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
if let Some(overrides) = state.get("categoryDirectoryOverrides") {
|
||||||
|
if !overrides.is_object() {
|
||||||
|
state.remove("categoryDirectoryOverrides");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(subfolders) = state.get("categorySubfolders") {
|
||||||
|
if !subfolders.is_object() {
|
||||||
|
state.remove("categorySubfolders");
|
||||||
|
}
|
||||||
|
}
|
||||||
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
|
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
|
||||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
|
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
|
||||||
});
|
});
|
||||||
@@ -590,6 +659,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
"postQueueAction",
|
"postQueueAction",
|
||||||
&["none", "sleep", "restart", "shutdown"],
|
&["none", "sleep", "restart", "shutdown"],
|
||||||
);
|
);
|
||||||
|
for key in ["enabled", "stopTimeEnabled", "everyday"] {
|
||||||
|
sanitize_boolean_setting(scheduler, key);
|
||||||
|
}
|
||||||
|
for key in ["startTime", "stopTime"] {
|
||||||
|
sanitize_string_setting(scheduler, key);
|
||||||
|
}
|
||||||
|
if let Some(days) = scheduler.get("selectedDays") {
|
||||||
|
if !days.is_array() {
|
||||||
|
scheduler.remove("selectedDays");
|
||||||
|
} else if let Some(days_arr) = scheduler.get_mut("selectedDays").and_then(Value::as_array_mut) {
|
||||||
|
days_arr.retain(|v| v.as_u64().is_some_and(|n| n <= 6));
|
||||||
|
if days_arr.is_empty() {
|
||||||
|
scheduler.remove("selectedDays");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(queue_ids) = scheduler.get("selectedQueueIds") {
|
||||||
|
if !queue_ids.is_array() {
|
||||||
|
scheduler.remove("selectedQueueIds");
|
||||||
|
} else if let Some(queue_ids_arr) = scheduler.get_mut("selectedQueueIds").and_then(Value::as_array_mut) {
|
||||||
|
queue_ids_arr.retain(|v| v.as_str().is_some_and(|s| !s.trim().is_empty()));
|
||||||
|
if queue_ids_arr.is_empty() {
|
||||||
|
scheduler.remove("selectedQueueIds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
||||||
@@ -762,6 +857,19 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
settings.last_custom_speed_limit_ki_b = settings.last_custom_speed_limit_ki_b.clamp(1, 10_485_760);
|
||||||
|
settings.speed_limit_preset_values.retain(|v| v.is_finite() && *v > 0.0);
|
||||||
|
if settings.speed_limit_preset_values.is_empty() {
|
||||||
|
settings.speed_limit_preset_values = default_settings().speed_limit_preset_values;
|
||||||
|
}
|
||||||
|
settings.scheduler.selected_days.retain(|d| (0..=6).contains(d));
|
||||||
|
if settings.scheduler.selected_days.is_empty() {
|
||||||
|
settings.scheduler.selected_days = default_settings().scheduler.selected_days;
|
||||||
|
}
|
||||||
|
settings.scheduler.selected_queue_ids.retain(|q| !q.trim().is_empty());
|
||||||
|
if settings.scheduler.selected_queue_ids.is_empty() {
|
||||||
|
settings.scheduler.selected_queue_ids = default_settings().scheduler.selected_queue_ids;
|
||||||
|
}
|
||||||
if !matches!(
|
if !matches!(
|
||||||
settings.last_custom_speed_limit_unit.as_str(),
|
settings.last_custom_speed_limit_unit.as_str(),
|
||||||
"KB/s" | "MB/s"
|
"KB/s" | "MB/s"
|
||||||
@@ -1396,6 +1504,75 @@ mod tests {
|
|||||||
assert!(settings.is_sidebar_visible);
|
assert!(settings.is_sidebar_visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_malformed_speed_and_scheduler_settings_without_error() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"minimumNormalDownloadSpeedKiB": "very-fast",
|
||||||
|
"lastCustomSpeedLimitKiB": -50,
|
||||||
|
"lastCustomSpeedLimitUnit": "TB/s",
|
||||||
|
"speedLimitPresetValues": ["not-a-number", -1.0, 0.0],
|
||||||
|
"approvedDownloadRoots": 12345,
|
||||||
|
"scheduler": {
|
||||||
|
"enabled": "yes",
|
||||||
|
"postQueueAction": "explode",
|
||||||
|
"selectedDays": [99, "monday"],
|
||||||
|
"selectedQueueIds": ["", " "]
|
||||||
|
},
|
||||||
|
"schedulerRunning": "active",
|
||||||
|
"schedulerActiveDownloadIds": "none"
|
||||||
|
},
|
||||||
|
"version": 6
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||||
|
assert_eq!(settings.last_custom_speed_limit_ki_b, 1024);
|
||||||
|
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||||
|
assert_eq!(settings.speed_limit_preset_values, default_settings().speed_limit_preset_values);
|
||||||
|
assert_eq!(settings.approved_download_roots, default_settings().approved_download_roots);
|
||||||
|
assert!(!settings.scheduler.enabled);
|
||||||
|
assert_eq!(settings.scheduler.post_queue_action, crate::ipc::PostQueueAction::None);
|
||||||
|
assert_eq!(settings.scheduler.selected_days, default_settings().scheduler.selected_days);
|
||||||
|
assert_eq!(settings.scheduler.selected_queue_ids, default_settings().scheduler.selected_queue_ids);
|
||||||
|
assert!(!settings.scheduler_running);
|
||||||
|
assert!(settings.scheduler_active_download_ids.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_empty_scheduler_active_download_ids() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"schedulerRunning": true,
|
||||||
|
"schedulerActiveDownloadIds": ["", " ", "download-1", 42]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings.scheduler_active_download_ids,
|
||||||
|
vec!["download-1".to_string()]
|
||||||
|
);
|
||||||
|
assert!(settings.scheduler_running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_restore_a_running_scheduler_without_active_download_ids() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"schedulerRunning": true,
|
||||||
|
"schedulerActiveDownloadIds": ["", " ", 42]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert!(!settings.scheduler_running);
|
||||||
|
assert!(settings.scheduler_active_download_ids.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_valid_torrent_network_settings() {
|
fn preserves_valid_torrent_network_settings() {
|
||||||
let stored = json!({
|
let stored = json!({
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ pub enum StorageMode {
|
|||||||
|
|
||||||
impl StorageMode {
|
impl StorageMode {
|
||||||
pub fn detect() -> Self {
|
pub fn detect() -> Self {
|
||||||
|
// Packaged smoke runs must never migrate or mutate the installed app's
|
||||||
|
// database. The harness creates an isolated root before launching us.
|
||||||
|
if std::env::var("FIRELINK_SMOKE_TEST").as_deref() == Ok("1") {
|
||||||
|
if let Some(root) = std::env::var_os("FIRELINK_SMOKE_STORAGE_ROOT").filter(|root| !root.is_empty()) {
|
||||||
|
let root = PathBuf::from(root);
|
||||||
|
assert!(root.is_absolute() && root.is_dir(), "invalid smoke storage root");
|
||||||
|
return Self::Portable { root };
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(executable) = std::env::current_exe().ok() else {
|
let Some(executable) = std::env::current_exe().ok() else {
|
||||||
return Self::Standard;
|
return Self::Standard;
|
||||||
};
|
};
|
||||||
|
|||||||
+151
-2
@@ -9,6 +9,7 @@ use tokio::io::AsyncReadExt;
|
|||||||
use crate::ipc::{TorrentFile, TorrentMetadata};
|
use crate::ipc::{TorrentFile, TorrentMetadata};
|
||||||
|
|
||||||
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
|
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
const MAX_TORRENT_DHT_NODES: usize = 256;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ParsedTorrent {
|
pub struct ParsedTorrent {
|
||||||
@@ -382,6 +383,7 @@ pub fn sanitize_torrent_bytes_for_aria2(bytes: &[u8]) -> Result<(Vec<u8>, Vec<St
|
|||||||
BencodeValue::Dict(value) => value,
|
BencodeValue::Dict(value) => value,
|
||||||
_ => return Err("torrent root is not a dictionary".to_string()),
|
_ => return Err("torrent root is not a dictionary".to_string()),
|
||||||
};
|
};
|
||||||
|
validate_torrent_tracker_metadata(bytes)?;
|
||||||
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
|
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
|
||||||
let mut sanitized = root;
|
let mut sanitized = root;
|
||||||
sanitized.remove(b"url-list".as_slice());
|
sanitized.remove(b"url-list".as_slice());
|
||||||
@@ -416,6 +418,12 @@ fn bounded_uri(value: &str, schemes: &[&str]) -> Option<String> {
|
|||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
crate::network::validate_url(
|
||||||
|
&parsed,
|
||||||
|
schemes,
|
||||||
|
crate::network::CredentialPolicy::Allow,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
Some(parsed.to_string())
|
Some(parsed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +494,61 @@ fn torrent_tracker_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> b
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn torrent_nodes_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> bool {
|
||||||
|
let Some(nodes) = root.get(b"nodes".as_slice()) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let BencodeValue::List(nodes) = nodes else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if nodes.len() > MAX_TORRENT_DHT_NODES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.iter().all(|node| {
|
||||||
|
let BencodeValue::List(parts) = node else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let BencodeValue::Bytes(host) = &parts[0] else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if host.is_empty() || host.len() > crate::queue::MAX_TORRENT_NETWORK_VALUE_LENGTH {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(host) = std::str::from_utf8(host) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if crate::network::validate_host(host).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(&parts[1], BencodeValue::Integer(port) if (1..=u16::MAX as i64).contains(port))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the tracker fields before handing original metainfo to Aria2.
|
||||||
|
/// `torrent_details_from_bytes` intentionally omits malformed tracker values
|
||||||
|
/// from its display projection, but Aria2 consumes the original bencode and
|
||||||
|
/// would otherwise still see those values.
|
||||||
|
pub fn validate_torrent_tracker_metadata(bytes: &[u8]) -> Result<(), String> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let root = match Parser::new(bytes).parse()? {
|
||||||
|
BencodeValue::Dict(value) => value,
|
||||||
|
_ => return Err("torrent root is not a dictionary".to_string()),
|
||||||
|
};
|
||||||
|
if torrent_tracker_metadata_is_safe(&root) && torrent_nodes_metadata_is_safe(&root) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("torrent metadata contains an invalid network destination".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
|
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -506,8 +569,25 @@ fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>,
|
|||||||
let value = String::from_utf8(bytes.clone())
|
let value = String::from_utf8(bytes.clone())
|
||||||
.map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?;
|
.map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?;
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
let uri = bounded_uri(value, &["http", "https"])
|
if value.len() > 2_048 || value.chars().any(char::is_control) {
|
||||||
.ok_or_else(|| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?;
|
return Err("torrent url-list contains an invalid HTTP(S) web seed".to_string());
|
||||||
|
}
|
||||||
|
let parsed = url::Url::parse(value)
|
||||||
|
.map_err(|_| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https")
|
||||||
|
|| parsed.host_str().is_none_or(str::is_empty)
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("torrent url-list contains an invalid HTTP(S) web seed".to_string());
|
||||||
|
}
|
||||||
|
crate::network::validate_url(
|
||||||
|
&parsed,
|
||||||
|
&["http", "https"],
|
||||||
|
crate::network::CredentialPolicy::Allow,
|
||||||
|
)?;
|
||||||
|
let uri = parsed.to_string();
|
||||||
if !normalized.contains(&uri) {
|
if !normalized.contains(&uri) {
|
||||||
normalized.push(uri);
|
normalized.push(uri);
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1425,21 @@ pub async fn remove_managed_torrent<R: tauri::Runtime>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn torrent_with_root_value(key: &[u8], value: BencodeValue) -> Vec<u8> {
|
||||||
|
let info = BencodeValue::Dict(BTreeMap::from([
|
||||||
|
(b"length".to_vec(), BencodeValue::Integer(5)),
|
||||||
|
(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec())),
|
||||||
|
]));
|
||||||
|
let root = BencodeValue::Dict(BTreeMap::from([
|
||||||
|
(b"info".to_vec(), info),
|
||||||
|
(key.to_vec(), value),
|
||||||
|
]));
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
encode(&root, &mut bytes);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_single_file_torrent_and_hashes_info_dictionary() {
|
fn parses_single_file_torrent_and_hashes_info_dictionary() {
|
||||||
@@ -1529,6 +1624,60 @@ mod tests {
|
|||||||
.expect("web-seed-bearing torrent metadata should parse"));
|
.expect("web-seed-bearing torrent metadata should parse"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracker_metadata_syntax_rejects_malformed_values_before_aria2() {
|
||||||
|
assert!(validate_torrent_tracker_metadata(
|
||||||
|
b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(validate_torrent_tracker_metadata(
|
||||||
|
b"d8:announce25:http://127.0.0.1/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(validate_torrent_tracker_metadata(
|
||||||
|
b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
assert!(sanitize_torrent_bytes_for_aria2(
|
||||||
|
b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_network_metadata_rejects_local_nodes_and_seeds_without_dns() {
|
||||||
|
for host in [
|
||||||
|
b"127.1".as_slice(),
|
||||||
|
b"2130706433".as_slice(),
|
||||||
|
b"[::ffff:127.0.0.1]".as_slice(),
|
||||||
|
b"localhost".as_slice(),
|
||||||
|
] {
|
||||||
|
let bytes = torrent_with_root_value(
|
||||||
|
b"nodes",
|
||||||
|
BencodeValue::List(vec![BencodeValue::List(vec![
|
||||||
|
BencodeValue::Bytes(host.to_vec()),
|
||||||
|
BencodeValue::Integer(6881),
|
||||||
|
])]),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_torrent_tracker_metadata(&bytes).is_err(),
|
||||||
|
"{host:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let public_node = torrent_with_root_value(
|
||||||
|
b"nodes",
|
||||||
|
BencodeValue::List(vec![BencodeValue::List(vec![
|
||||||
|
BencodeValue::Bytes(b"node-does-not-resolve.invalid".to_vec()),
|
||||||
|
BencodeValue::Integer(6881),
|
||||||
|
])]),
|
||||||
|
);
|
||||||
|
assert!(validate_torrent_tracker_metadata(&public_node).is_ok());
|
||||||
|
|
||||||
|
let local_seed = b"d4:infod6:lengthi5e4:name4:teste8:url-list17:http://127.0.0.1/ee";
|
||||||
|
assert!(parse_torrent_bytes(local_seed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_cache_temporary_names_are_strictly_recognized() {
|
fn canonical_cache_temporary_names_are_strictly_recognized() {
|
||||||
assert!(is_canonical_torrent_temp_file(
|
assert!(is_canonical_torrent_temp_file(
|
||||||
|
|||||||
+1084
-94
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Firelink",
|
"productName": "Firelink",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"identifier": "com.nimbold.firelink",
|
"identifier": "com.nimbold.firelink",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "node scripts/before-tauri-build.js",
|
"beforeBuildCommand": "node scripts/before-tauri-build.js",
|
||||||
|
"beforeBundleCommand": "node scripts/before-tauri-bundle.js",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -36,7 +37,6 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"engine-dist/": "engine-dist/",
|
|
||||||
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||||
},
|
},
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
"decorations": false
|
"decorations": false,
|
||||||
|
"shadow": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"shadow": false
|
"shadow": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,3 +123,73 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
|
|||||||
);
|
);
|
||||||
assert!(is_permanent_network_error("HTTP 403 Forbidden"));
|
assert!(is_permanent_network_error("HTTP 403 Forbidden"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
struct TorrentPeerOptionsArgs {
|
||||||
|
id: String,
|
||||||
|
max_peers: Option<i64>,
|
||||||
|
peer_speed_limit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
struct TorrentFileSelectionArgs {
|
||||||
|
id: String,
|
||||||
|
selected_indices: Option<Vec<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BuggyTorrentPeerOptionsArgs {
|
||||||
|
id: String,
|
||||||
|
max_peers: Option<i64>,
|
||||||
|
peer_speed_limit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BuggyTorrentFileSelectionArgs {
|
||||||
|
id: String,
|
||||||
|
selected_indices: Option<Vec<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipc_snake_case_deserialization_contract_preserves_torrent_arguments() {
|
||||||
|
let peer_payload = serde_json::json!({
|
||||||
|
"id": "dl-123",
|
||||||
|
"max_peers": 42,
|
||||||
|
"peer_speed_limit": "2M"
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fixed snake_case contract receives and preserves the frontend's arguments:
|
||||||
|
let fixed_peer: TorrentPeerOptionsArgs = serde_json::from_value(peer_payload.clone())
|
||||||
|
.expect("snake_case deserializer should parse torrent peer options");
|
||||||
|
assert_eq!(fixed_peer.id, "dl-123");
|
||||||
|
assert_eq!(fixed_peer.max_peers, Some(42));
|
||||||
|
assert_eq!(fixed_peer.peer_speed_limit.as_deref(), Some("2M"));
|
||||||
|
|
||||||
|
// The buggy default camelCase contract dropped the arguments to None silently:
|
||||||
|
let buggy_peer: BuggyTorrentPeerOptionsArgs = serde_json::from_value(peer_payload)
|
||||||
|
.expect("camelCase deserializer parses but silently drops snake_case keys");
|
||||||
|
assert_eq!(buggy_peer.id, "dl-123");
|
||||||
|
assert_eq!(buggy_peer.max_peers, None);
|
||||||
|
assert_eq!(buggy_peer.peer_speed_limit, None);
|
||||||
|
|
||||||
|
let selection_payload = serde_json::json!({
|
||||||
|
"id": "dl-456",
|
||||||
|
"selected_indices": [1, 3, 5]
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fixed snake_case contract receives and preserves selected file indices:
|
||||||
|
let fixed_selection: TorrentFileSelectionArgs = serde_json::from_value(selection_payload.clone())
|
||||||
|
.expect("snake_case deserializer should parse selected indices");
|
||||||
|
assert_eq!(fixed_selection.id, "dl-456");
|
||||||
|
assert_eq!(fixed_selection.selected_indices, Some(vec![1, 3, 5]));
|
||||||
|
|
||||||
|
// The buggy default camelCase contract dropped selected indices to None (selecting all files):
|
||||||
|
let buggy_selection: BuggyTorrentFileSelectionArgs = serde_json::from_value(selection_payload)
|
||||||
|
.expect("camelCase deserializer parses but silently drops snake_case keys");
|
||||||
|
assert_eq!(buggy_selection.id, "dl-456");
|
||||||
|
assert_eq!(buggy_selection.selected_indices, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use firelink_lib::queue::{
|
use firelink_lib::queue::{
|
||||||
Aria2RecreateOutcome, Aria2RefreshOutcome, Aria2ResolverMode, QueueManager, QueuedTask,
|
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
|
||||||
SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -27,15 +27,7 @@ struct CountingSpawner {
|
|||||||
torrent_peer_options_release: tokio::sync::Notify,
|
torrent_peer_options_release: tokio::sync::Notify,
|
||||||
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
||||||
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
|
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
|
||||||
add_resolver_modes: std::sync::Mutex<Vec<Aria2ResolverMode>>,
|
add_transfer_context: std::sync::Mutex<Vec<(Option<String>, Option<String>, Option<i32>)>>,
|
||||||
add_transfer_context: std::sync::Mutex<
|
|
||||||
Vec<(
|
|
||||||
Aria2ResolverMode,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
Option<i32>,
|
|
||||||
)>,
|
|
||||||
>,
|
|
||||||
block_speed_limit: std::sync::atomic::AtomicBool,
|
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||||
speed_limit_started: tokio::sync::Notify,
|
speed_limit_started: tokio::sync::Notify,
|
||||||
speed_limit_release: tokio::sync::Notify,
|
speed_limit_release: tokio::sync::Notify,
|
||||||
@@ -220,7 +212,6 @@ impl CountingSpawner {
|
|||||||
torrent_peer_options_release: tokio::sync::Notify::new(),
|
torrent_peer_options_release: tokio::sync::Notify::new(),
|
||||||
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
||||||
add_peer_options: std::sync::Mutex::new(Vec::new()),
|
add_peer_options: std::sync::Mutex::new(Vec::new()),
|
||||||
add_resolver_modes: std::sync::Mutex::new(Vec::new()),
|
|
||||||
add_transfer_context: std::sync::Mutex::new(Vec::new()),
|
add_transfer_context: std::sync::Mutex::new(Vec::new()),
|
||||||
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||||
speed_limit_started: tokio::sync::Notify::new(),
|
speed_limit_started: tokio::sync::Notify::new(),
|
||||||
@@ -305,12 +296,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
|||||||
payload.torrent_max_peers,
|
payload.torrent_max_peers,
|
||||||
payload.torrent_peer_speed_limit.clone(),
|
payload.torrent_peer_speed_limit.clone(),
|
||||||
));
|
));
|
||||||
self.add_resolver_modes
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push(payload.aria2_resolver_mode);
|
|
||||||
self.add_transfer_context.lock().unwrap().push((
|
self.add_transfer_context.lock().unwrap().push((
|
||||||
payload.aria2_resolver_mode,
|
|
||||||
payload.headers.clone(),
|
payload.headers.clone(),
|
||||||
payload.proxy.clone(),
|
payload.proxy.clone(),
|
||||||
payload.connections,
|
payload.connections,
|
||||||
@@ -2297,16 +2283,14 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn resolver_failure_uses_one_system_fallback_without_retry_budget() {
|
async fn resolver_failure_retries_without_entering_blocking_system_dns() {
|
||||||
use firelink_lib::queue::PendingOutcome;
|
use firelink_lib::queue::PendingOutcome;
|
||||||
|
|
||||||
let (mgr, spawner) = make_manager(1);
|
let (mgr, spawner) = make_manager(1);
|
||||||
let manager = Arc::new(mgr);
|
let manager = Arc::new(mgr);
|
||||||
manager.set_aria2_async_dns_supported(true);
|
|
||||||
let mut task = aria2_task("resolver-fallback");
|
let mut task = aria2_task("resolver-fallback");
|
||||||
task.payload.max_tries = Some(0);
|
task.payload.max_tries = Some(1);
|
||||||
task.payload.headers = Some("X-Test: retained".to_string());
|
task.payload.headers = Some("X-Test: retained".to_string());
|
||||||
task.payload.proxy = Some("http://127.0.0.1:8123".to_string());
|
|
||||||
manager.push(task).await.unwrap();
|
manager.push(task).await.unwrap();
|
||||||
|
|
||||||
let dispatcher = {
|
let dispatcher = {
|
||||||
@@ -2341,32 +2325,19 @@ async fn resolver_failure_uses_one_system_fallback_without_retry_budget() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("resolver failure should re-add once with the system resolver");
|
.expect("resolver failure should re-add once on the non-blocking resolver");
|
||||||
assert_eq!(
|
|
||||||
*spawner.add_resolver_modes.lock().unwrap(),
|
|
||||||
vec![Aria2ResolverMode::Automatic, Aria2ResolverMode::System]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*spawner.add_transfer_context.lock().unwrap(),
|
*spawner.add_transfer_context.lock().unwrap(),
|
||||||
vec![
|
vec![
|
||||||
(
|
(Some("X-Test: retained".to_string()), None, None),
|
||||||
Aria2ResolverMode::Automatic,
|
(Some("X-Test: retained".to_string()), None, None),
|
||||||
Some("X-Test: retained".to_string()),
|
|
||||||
Some("http://127.0.0.1:8123".to_string()),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Aria2ResolverMode::System,
|
|
||||||
Some("X-Test: retained".to_string()),
|
|
||||||
Some("http://127.0.0.1:8123".to_string()),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
// A second resolver failure is now on the system mode. With max_tries=0
|
// The configured retry budget is exhausted after one non-blocking retry.
|
||||||
// it must terminate instead of switching back or consuming another add.
|
// A repeated DNS error must terminate without entering system DNS or
|
||||||
|
// scheduling another add.
|
||||||
manager
|
manager
|
||||||
.handle_aria2_event(
|
.handle_aria2_event(
|
||||||
"gid-2",
|
"gid-2",
|
||||||
|
|||||||
@@ -94,3 +94,35 @@ async fn production_rpc_client_preserves_http_gateway_context() {
|
|||||||
);
|
);
|
||||||
stop_server(shutdown, task).await;
|
stop_server(shutdown, task).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn production_rpc_client_bypasses_environment_proxy() {
|
||||||
|
let app = Router::new().route("/jsonrpc", post(successful_rpc));
|
||||||
|
let (address, shutdown, task) = start_server(app).await;
|
||||||
|
|
||||||
|
// Even if an invalid or hostile HTTP proxy is set in the environment,
|
||||||
|
// loopback JSON-RPC calls must bypass the proxy and connect directly to loopback.
|
||||||
|
struct EnvGuard(&'static str, Option<String>);
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.1 {
|
||||||
|
Some(val) => std::env::set_var(self.0, val),
|
||||||
|
None => std::env::remove_var(self.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _guard = EnvGuard("HTTP_PROXY", std::env::var("HTTP_PROXY").ok());
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://192.0.2.1:8080");
|
||||||
|
|
||||||
|
let result = rpc_call(
|
||||||
|
address.port(),
|
||||||
|
"test-secret",
|
||||||
|
"aria2.getVersion",
|
||||||
|
json!([{"include": "version"}]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("RPC client must bypass HTTP_PROXY and succeed over loopback");
|
||||||
|
|
||||||
|
assert_eq!(result, json!({"version": "test"}));
|
||||||
|
stop_server(shutdown, task).await;
|
||||||
|
}
|
||||||
|
|||||||
+86
-32
@@ -42,14 +42,22 @@ import { formatDownloadBytes } from './utils/downloadProgress';
|
|||||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||||
import { createSidebarResizeSession } from './utils/sidebarResize';
|
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||||
|
import {
|
||||||
|
resolveFallbackFilter,
|
||||||
|
shouldRestoreSidebarRevealFocus,
|
||||||
|
shouldRestoreSidebarToggleFocus
|
||||||
|
} from './utils/sidebarFocus';
|
||||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||||
import {
|
import {
|
||||||
beginSchedulerControl,
|
beginSchedulerControl,
|
||||||
consumeSchedulerHandoffIds,
|
consumeSchedulerHandoffIds,
|
||||||
handoffSupersededSchedulerIds,
|
handoffSupersededSchedulerIds,
|
||||||
isSchedulerControlCurrent
|
isSchedulerControlCurrent,
|
||||||
|
registerPostActionCanceller
|
||||||
} from './utils/schedulerControl';
|
} from './utils/schedulerControl';
|
||||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||||
|
import { useWindowFocusState } from './utils/windowFocus';
|
||||||
|
import { useWindowMaximizedState } from './utils/windowMaximized';
|
||||||
|
|
||||||
const loadSettingsView = () => import('./components/SettingsView');
|
const loadSettingsView = () => import('./components/SettingsView');
|
||||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||||
@@ -181,6 +189,8 @@ const playCompletionChime = async () => {
|
|||||||
function App() {
|
function App() {
|
||||||
const { i18n, t } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
|
const isWindowActive = useWindowFocusState();
|
||||||
|
const isWindowMaximized = useWindowMaximizedState();
|
||||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||||
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
||||||
const [coreReady, setCoreReady] = useState(false);
|
const [coreReady, setCoreReady] = useState(false);
|
||||||
@@ -202,7 +212,9 @@ function App() {
|
|||||||
});
|
});
|
||||||
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
|
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
|
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
|
||||||
const restoreSidebarFocusRef = useRef(false);
|
const restoreSidebarFocusRef = useRef(false);
|
||||||
|
const restoreRevealFocusRef = useRef(false);
|
||||||
|
|
||||||
const theme = useSettingsStore(state => state.theme);
|
const theme = useSettingsStore(state => state.theme);
|
||||||
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||||
@@ -244,6 +256,18 @@ function App() {
|
|||||||
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
||||||
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
||||||
const downloads = useDownloadStore(state => state.downloads);
|
const downloads = useDownloadStore(state => state.downloads);
|
||||||
|
const queues = useDownloadStore(state => state.queues);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fallback = resolveFallbackFilter(
|
||||||
|
filter,
|
||||||
|
queues.map(queue => queue.id),
|
||||||
|
queues.length > 0,
|
||||||
|
);
|
||||||
|
if (fallback !== filter) {
|
||||||
|
setFilter(fallback as SidebarFilter);
|
||||||
|
}
|
||||||
|
}, [filter, queues]);
|
||||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||||
const queuedCount = downloads.filter(download =>
|
const queuedCount = downloads.filter(download =>
|
||||||
download.status === 'queued' || download.status === 'staged'
|
download.status === 'queued' || download.status === 'staged'
|
||||||
@@ -262,6 +286,8 @@ function App() {
|
|||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||||
const pendingPostActionTimer = useRef<number | null>(null);
|
const pendingPostActionTimer = useRef<number | null>(null);
|
||||||
|
const pendingPostActionToastId = useRef<string | null>(null);
|
||||||
|
const pendingForceActionToastId = useRef<string | null>(null);
|
||||||
const startupResumeStarted = useRef(false);
|
const startupResumeStarted = useRef(false);
|
||||||
const startupInputReady = useRef(false);
|
const startupInputReady = useRef(false);
|
||||||
const extensionProcessing = useRef(createSerialTaskQueue());
|
const extensionProcessing = useRef(createSerialTaskQueue());
|
||||||
@@ -308,7 +334,15 @@ function App() {
|
|||||||
window.clearTimeout(pendingPostActionTimer.current);
|
window.clearTimeout(pendingPostActionTimer.current);
|
||||||
pendingPostActionTimer.current = null;
|
pendingPostActionTimer.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
if (pendingPostActionToastId.current !== null) {
|
||||||
|
removeToast(pendingPostActionToastId.current);
|
||||||
|
pendingPostActionToastId.current = null;
|
||||||
|
}
|
||||||
|
if (pendingForceActionToastId.current !== null) {
|
||||||
|
removeToast(pendingForceActionToastId.current);
|
||||||
|
pendingForceActionToastId.current = null;
|
||||||
|
}
|
||||||
|
}, [removeToast]);
|
||||||
|
|
||||||
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
||||||
const update = frontendReadyUpdate.current
|
const update = frontendReadyUpdate.current
|
||||||
@@ -341,13 +375,15 @@ function App() {
|
|||||||
|
|
||||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||||
let timerId: number | null = null;
|
let timerId: number | null = null;
|
||||||
let toastId: string | null = null;
|
|
||||||
const showForceActionToast = () => {
|
const showForceActionToast = () => {
|
||||||
let forceToastId: string | null = null;
|
if (pendingForceActionToastId.current !== null) {
|
||||||
|
removeToast(pendingForceActionToastId.current);
|
||||||
|
pendingForceActionToastId.current = null;
|
||||||
|
}
|
||||||
const proceed = () => {
|
const proceed = () => {
|
||||||
if (forceToastId !== null) {
|
if (pendingForceActionToastId.current !== null) {
|
||||||
removeToast(forceToastId);
|
removeToast(pendingForceActionToastId.current);
|
||||||
forceToastId = null;
|
pendingForceActionToastId.current = null;
|
||||||
}
|
}
|
||||||
invoke('perform_system_action', { action, force: true }).catch(error => {
|
invoke('perform_system_action', { action, force: true }).catch(error => {
|
||||||
console.error('Forced scheduled post action failed:', error);
|
console.error('Forced scheduled post action failed:', error);
|
||||||
@@ -358,7 +394,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
forceToastId = addToast({
|
pendingForceActionToastId.current = addToast({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@@ -391,16 +427,8 @@ function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const cancel = () => {
|
|
||||||
clearPendingPostActionTimer();
|
|
||||||
timerId = null;
|
|
||||||
if (toastId !== null) {
|
|
||||||
removeToast(toastId);
|
|
||||||
toastId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
toastId = addToast({
|
const toastId = addToast({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onDismiss: clearPendingPostActionTimer,
|
onDismiss: clearPendingPostActionTimer,
|
||||||
@@ -410,18 +438,19 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-2 py-1"
|
className="app-button px-2 py-1"
|
||||||
onClick={cancel}
|
onClick={clearPendingPostActionTimer}
|
||||||
>
|
>
|
||||||
{t($ => $.actions.cancel)}
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
pendingPostActionToastId.current = toastId;
|
||||||
|
|
||||||
timerId = window.setTimeout(() => {
|
timerId = window.setTimeout(() => {
|
||||||
if (toastId !== null) {
|
if (pendingPostActionToastId.current === toastId) {
|
||||||
removeToast(toastId);
|
removeToast(toastId);
|
||||||
toastId = null;
|
pendingPostActionToastId.current = null;
|
||||||
}
|
}
|
||||||
if (pendingPostActionTimer.current === timerId) {
|
if (pendingPostActionTimer.current === timerId) {
|
||||||
pendingPostActionTimer.current = null;
|
pendingPostActionTimer.current = null;
|
||||||
@@ -466,24 +495,43 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSidebarVisible) return;
|
if (!isSidebarVisible) {
|
||||||
if (restoreSidebarFocusRef.current) {
|
if (restoreSidebarFocusRef.current) {
|
||||||
restoreSidebarFocusRef.current = false;
|
restoreSidebarFocusRef.current = false;
|
||||||
sidebarRevealRef.current?.focus({ preventScroll: true });
|
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (restoreRevealFocusRef.current) {
|
||||||
|
restoreRevealFocusRef.current = false;
|
||||||
|
sidebarToggleRef.current?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}, [isSidebarVisible]);
|
}, [isSidebarVisible]);
|
||||||
|
|
||||||
const handleSidebarToggle = () => {
|
const handleSidebarToggle = () => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
if (isSidebarVisible) {
|
if (isSidebarVisible) {
|
||||||
const activeElement = document.activeElement;
|
restoreSidebarFocusRef.current = shouldRestoreSidebarRevealFocus(
|
||||||
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
|
activeElement,
|
||||||
&& Boolean(activeElement.closest('.app-sidebar-shell'));
|
document.querySelector('.app-sidebar-shell'),
|
||||||
|
);
|
||||||
|
restoreRevealFocusRef.current = false;
|
||||||
|
} else {
|
||||||
|
restoreRevealFocusRef.current = shouldRestoreSidebarToggleFocus(
|
||||||
|
activeElement,
|
||||||
|
sidebarRevealRef.current,
|
||||||
|
);
|
||||||
|
restoreSidebarFocusRef.current = false;
|
||||||
}
|
}
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return clearPendingPostActionTimer;
|
const unregister = registerPostActionCanceller(clearPendingPostActionTimer);
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
clearPendingPostActionTimer();
|
||||||
|
};
|
||||||
}, [clearPendingPostActionTimer]);
|
}, [clearPendingPostActionTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -492,7 +540,7 @@ function App() {
|
|||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
|
let disposePersistence: (() => void) | null = null;
|
||||||
let active = true;
|
let active = true;
|
||||||
let exitRequested = false;
|
let exitRequested = false;
|
||||||
let exiting = false;
|
let exiting = false;
|
||||||
@@ -731,6 +779,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await initializeDownloadState();
|
await initializeDownloadState();
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
disposeListeners();
|
disposeListeners();
|
||||||
cleanupListeners = null;
|
cleanupListeners = null;
|
||||||
@@ -758,7 +807,8 @@ function App() {
|
|||||||
unlistenExit = null;
|
unlistenExit = null;
|
||||||
unlistenSettingsHydration?.();
|
unlistenSettingsHydration?.();
|
||||||
mainWindowSizePersistence.dispose();
|
mainWindowSizePersistence.dispose();
|
||||||
disposePersistence();
|
disposePersistence?.();
|
||||||
|
disposePersistence = null;
|
||||||
};
|
};
|
||||||
}, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]);
|
}, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]);
|
||||||
|
|
||||||
@@ -1176,7 +1226,7 @@ function App() {
|
|||||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
<div data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||||
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||||
} ${
|
} ${
|
||||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||||
@@ -1209,6 +1259,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
selectedFilter={filter}
|
selectedFilter={filter}
|
||||||
|
toggleButtonRef={sidebarToggleRef}
|
||||||
onToggleSidebar={handleSidebarToggle}
|
onToggleSidebar={handleSidebarToggle}
|
||||||
onSelectFilter={(f) => {
|
onSelectFilter={(f) => {
|
||||||
setFilter(f);
|
setFilter(f);
|
||||||
@@ -1234,7 +1285,10 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={sidebarRevealRef}
|
ref={sidebarRevealRef}
|
||||||
onClick={toggleSidebar}
|
data-tauri-drag-region="false"
|
||||||
|
onPointerDown={event => event.stopPropagation()}
|
||||||
|
onMouseDown={event => event.stopPropagation()}
|
||||||
|
onClick={handleSidebarToggle}
|
||||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||||
title={t($ => $.actions.showSidebar)}
|
title={t($ => $.actions.showSidebar)}
|
||||||
aria-label={t($ => $.actions.showSidebar)}
|
aria-label={t($ => $.actions.showSidebar)}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DownloadRemovalPhase } from "./DownloadRemovalPhase";
|
||||||
|
|
||||||
|
export type DownloadRemovalJob = { id: string, revision: number, deleteAssets: boolean, phase: DownloadRemovalPhase, error: string | null, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type DownloadRemovalPhase = "pending" | "running" | "failed" | "completed";
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||||
|
|
||||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, };
|
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, torrent_path?: string, };
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog';
|
|||||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isMediaUrl, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
||||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||||
import {
|
import {
|
||||||
expandTilde,
|
expandTilde,
|
||||||
@@ -215,11 +215,18 @@ export const AddDownloadsModal = () => {
|
|||||||
if (!row.isTorrent) continue;
|
if (!row.isTorrent) continue;
|
||||||
activeDraftIds.add(row.torrentCacheId || row.id);
|
activeDraftIds.add(row.torrentCacheId || row.id);
|
||||||
activeDraftIds.add(`${row.id}-${row.generation}`);
|
activeDraftIds.add(`${row.id}-${row.generation}`);
|
||||||
|
const requestContext = pendingAddRequestContexts[normalizeComparableUrl(row.sourceUrl)];
|
||||||
|
if (requestContext?.torrentPath
|
||||||
|
&& requestContext.torrentCacheId
|
||||||
|
&& requestContext.torrentPath === row.torrentPath
|
||||||
|
&& requestContext?.torrentCacheId === row.torrentCacheId) {
|
||||||
|
cachedTorrentDraftIdsRef.current.add(requestContext.torrentCacheId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
|
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
|
||||||
.filter(id => !activeDraftIds.has(id));
|
.filter(id => !activeDraftIds.has(id));
|
||||||
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
|
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
|
||||||
}, [cleanupDraftTorrentCache, parsedItems]);
|
}, [cleanupDraftTorrentCache, parsedItems, pendingAddRequestContexts]);
|
||||||
|
|
||||||
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
|
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
|
||||||
|
|
||||||
@@ -228,7 +235,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const modalRef = useModalFocus(isAddModalOpen);
|
const modalRef = useModalFocus(isAddModalOpen);
|
||||||
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
|
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
|
||||||
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
|
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
|
||||||
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
|
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<string | number, string>>({});
|
||||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||||
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
|
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -322,15 +329,28 @@ export const AddDownloadsModal = () => {
|
|||||||
const requestContextForUrl = (url: string) =>
|
const requestContextForUrl = (url: string) =>
|
||||||
pendingAddRequestContexts[normalizeComparableUrl(url)];
|
pendingAddRequestContexts[normalizeComparableUrl(url)];
|
||||||
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
|
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
|
||||||
const headersForRow = (sourceUrl: string) => {
|
const headersForRow = (sourceUrl: string, isMedia = false) => {
|
||||||
if (headersManuallyEditedRef.current) return headers.trim();
|
if (headersManuallyEditedRef.current) return headers.trim();
|
||||||
const context = requestContextForUrl(sourceUrl);
|
const context = requestContextForUrl(sourceUrl);
|
||||||
|
const media = isMedia || context?.media === true || isMediaUrl(sourceUrl);
|
||||||
|
if (media) {
|
||||||
|
const raw = context ? extensionHeaders(context) : (hasExtensionRequestContext ? '' : headers.trim());
|
||||||
|
return raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(line => {
|
||||||
|
const separator = line.indexOf(':');
|
||||||
|
return separator > 0 && !headerNameHasCredentialMaterial(line.slice(0, separator));
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
if (context) return extensionHeaders(context).trim();
|
if (context) return extensionHeaders(context).trim();
|
||||||
return hasExtensionRequestContext ? '' : headers.trim();
|
return hasExtensionRequestContext ? '' : headers.trim();
|
||||||
};
|
};
|
||||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
|
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl, isMedia = false) => {
|
||||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||||
const context = requestContextForUrl(sourceUrl);
|
const context = requestContextForUrl(sourceUrl);
|
||||||
|
if (isMedia || context?.media === true || isMediaUrl(sourceUrl)) return '';
|
||||||
const scopedCookies = cookieScopeForUrl(context, targetUrl);
|
const scopedCookies = cookieScopeForUrl(context, targetUrl);
|
||||||
if (scopedCookies) return scopedCookies;
|
if (scopedCookies) return scopedCookies;
|
||||||
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
|
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
|
||||||
@@ -440,7 +460,9 @@ export const AddDownloadsModal = () => {
|
|||||||
pendingAddHeaders
|
pendingAddHeaders
|
||||||
].filter(Boolean).join('\n'));
|
].filter(Boolean).join('\n'));
|
||||||
headersManuallyEditedRef.current = false;
|
headersManuallyEditedRef.current = false;
|
||||||
setCookies(initialContext?.cookies || pendingAddCookies);
|
const isSingleInitialMedia = initialContext?.media === true
|
||||||
|
|| (initialUrlLines.length === 1 && isMediaUrl(initialUrlLines[0]));
|
||||||
|
setCookies(isSingleInitialMedia ? '' : (initialContext?.cookies || pendingAddCookies));
|
||||||
cookiesManuallyEditedRef.current = false;
|
cookiesManuallyEditedRef.current = false;
|
||||||
setMirrors('');
|
setMirrors('');
|
||||||
setIsQueueMenuOpen(false);
|
setIsQueueMenuOpen(false);
|
||||||
@@ -564,6 +586,16 @@ export const AddDownloadsModal = () => {
|
|||||||
Object.entries(pendingAddRequestContexts)
|
Object.entries(pendingAddRequestContexts)
|
||||||
.map(([url, context]) => [url, context.version])
|
.map(([url, context]) => [url, context.version])
|
||||||
);
|
);
|
||||||
|
const requestTorrentPaths = Object.fromEntries(
|
||||||
|
Object.entries(pendingAddRequestContexts)
|
||||||
|
.filter(([, context]) => Boolean(context.torrentPath))
|
||||||
|
.map(([url, context]) => [url, context.torrentPath as string])
|
||||||
|
);
|
||||||
|
const requestTorrentCacheIds = Object.fromEntries(
|
||||||
|
Object.entries(pendingAddRequestContexts)
|
||||||
|
.filter(([, context]) => Boolean(context.torrentCacheId))
|
||||||
|
.map(([url, context]) => [url, context.torrentCacheId as string])
|
||||||
|
);
|
||||||
setParsedItems(current => {
|
setParsedItems(current => {
|
||||||
const selectedBySourceUrl = Object.fromEntries(
|
const selectedBySourceUrl = Object.fromEntries(
|
||||||
current.map(row => [row.sourceUrl, row.selected !== false])
|
current.map(row => [row.sourceUrl, row.selected !== false])
|
||||||
@@ -583,7 +615,9 @@ export const AddDownloadsModal = () => {
|
|||||||
requestContextVersions,
|
requestContextVersions,
|
||||||
playlistExpansions,
|
playlistExpansions,
|
||||||
selectedBySourceUrl,
|
selectedBySourceUrl,
|
||||||
forcedTorrentUrls
|
forcedTorrentUrls,
|
||||||
|
requestTorrentPaths,
|
||||||
|
requestTorrentCacheIds
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -723,7 +757,11 @@ export const AddDownloadsModal = () => {
|
|||||||
url: row.sourceUrl,
|
url: row.sourceUrl,
|
||||||
cookieBrowser: browserArg,
|
cookieBrowser: browserArg,
|
||||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||||
username: useAuth ? username.trim() || null : login?.username || null,
|
username: useAuth
|
||||||
|
? username.trim() || null
|
||||||
|
: typeof keychainPassword === 'string' && keychainPassword.trim()
|
||||||
|
? login?.username || null
|
||||||
|
: null,
|
||||||
password: useAuth ? password || null : keychainPassword,
|
password: useAuth ? password || null : keychainPassword,
|
||||||
headers: rowHeaders || null,
|
headers: rowHeaders || null,
|
||||||
cookies: rowCookies || null,
|
cookies: rowCookies || null,
|
||||||
@@ -827,7 +865,11 @@ export const AddDownloadsModal = () => {
|
|||||||
const meta = await invoke('fetch_metadata', {
|
const meta = await invoke('fetch_metadata', {
|
||||||
url: row.sourceUrl,
|
url: row.sourceUrl,
|
||||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||||
username: useAuth ? username.trim() || null : login?.username || null,
|
username: useAuth
|
||||||
|
? username.trim() || null
|
||||||
|
: typeof keychainPassword === 'string' && keychainPassword.trim()
|
||||||
|
? login?.username || null
|
||||||
|
: null,
|
||||||
password: useAuth ? password || null : keychainPassword,
|
password: useAuth ? password || null : keychainPassword,
|
||||||
headers: headersForRow(contextUrl) || null,
|
headers: headersForRow(contextUrl) || null,
|
||||||
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
|
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
|
||||||
@@ -869,8 +911,6 @@ export const AddDownloadsModal = () => {
|
|||||||
const metadataBlockedReason = [
|
const metadataBlockedReason = [
|
||||||
'SSRF blocked: Invalid URL',
|
'SSRF blocked: Invalid URL',
|
||||||
'SSRF blocked: No host',
|
'SSRF blocked: No host',
|
||||||
'SSRF blocked: DNS resolution failed',
|
|
||||||
'SSRF blocked: No DNS records',
|
|
||||||
'SSRF blocked: Private/local IP not allowed'
|
'SSRF blocked: Private/local IP not allowed'
|
||||||
].some(prefix => errorMessage.startsWith(prefix))
|
].some(prefix => errorMessage.startsWith(prefix))
|
||||||
? 'unsafe-url' as const
|
? 'unsafe-url' as const
|
||||||
@@ -1005,6 +1045,11 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to select folder:", e);
|
console.error("Failed to select folder:", e);
|
||||||
|
addToast({
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1172,7 +1217,7 @@ export const AddDownloadsModal = () => {
|
|||||||
++folderPickerRequestRef.current;
|
++folderPickerRequestRef.current;
|
||||||
let finalLocation = saveLocation;
|
let finalLocation = saveLocation;
|
||||||
let useSharedDestination = isSaveLocationManual;
|
let useSharedDestination = isSaveLocationManual;
|
||||||
const destinationOverrides: Record<number, string> = {};
|
const destinationOverrides: Record<string | number, string> = {};
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||||
@@ -1195,6 +1240,7 @@ export const AddDownloadsModal = () => {
|
|||||||
if (selected && typeof selected === 'string') {
|
if (selected && typeof selected === 'string') {
|
||||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||||
destinationOverrides[index] = approvedPath;
|
destinationOverrides[index] = approvedPath;
|
||||||
|
destinationOverrides[item.id] = approvedPath;
|
||||||
const currentSettings = useSettingsStore.getState();
|
const currentSettings = useSettingsStore.getState();
|
||||||
if (currentSettings.rememberLastUsedDownloadDirectory) {
|
if (currentSettings.rememberLastUsedDownloadDirectory) {
|
||||||
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
|
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
|
||||||
@@ -1207,6 +1253,11 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to select folder:", e);
|
console.error("Failed to select folder:", e);
|
||||||
|
addToast({
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
pendingLastUsedDownloadDirectoryRef.current = null;
|
pendingLastUsedDownloadDirectoryRef.current = null;
|
||||||
isSubmittingRef.current = false;
|
isSubmittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -1251,7 +1302,7 @@ export const AddDownloadsModal = () => {
|
|||||||
);
|
);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: item.id,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
|
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
@@ -1260,7 +1311,7 @@ export const AddDownloadsModal = () => {
|
|||||||
});
|
});
|
||||||
} else if (hasBatchConflict) {
|
} else if (hasBatchConflict) {
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: item.id,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
|
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
@@ -1307,7 +1358,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id)
|
const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id)
|
||||||
&& !isTransferLocked(filenameMatch.status);
|
&& !isTransferLocked(filenameMatch.status);
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: item.id,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) },
|
reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) },
|
||||||
resolution: canReplace ? 'replace' : 'rename',
|
resolution: canReplace ? 'replace' : 'rename',
|
||||||
@@ -1362,7 +1413,7 @@ export const AddDownloadsModal = () => {
|
|||||||
: false;
|
: false;
|
||||||
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
|
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: item.id,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: {
|
reason: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -1410,7 +1461,7 @@ export const AddDownloadsModal = () => {
|
|||||||
resolution: 'rename' | 'replace' | 'skip';
|
resolution: 'rename' | 'replace' | 'skip';
|
||||||
replaceFingerprint?: string;
|
replaceFingerprint?: string;
|
||||||
}[],
|
}[],
|
||||||
destinationOverrides: Record<number, string> = {}
|
destinationOverrides: Record<string | number, string> = {}
|
||||||
) => {
|
) => {
|
||||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
||||||
item.selected === false ? null : item
|
item.selected === false ? null : item
|
||||||
@@ -1420,10 +1471,14 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
if (resolutions) {
|
if (resolutions) {
|
||||||
for (const res of resolutions) {
|
for (const res of resolutions) {
|
||||||
const idx = parseInt(res.id);
|
const idx = itemsToAdd.findIndex((candidate, index) =>
|
||||||
|
candidate !== null && (candidate.id === res.id || String(index) === res.id)
|
||||||
|
);
|
||||||
|
if (idx === -1) continue;
|
||||||
const item = itemsToAdd[idx];
|
const item = itemsToAdd[idx];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
const conflict = conflicts.find(c => c.id === res.id);
|
const conflict = conflicts.find(c => c.id === res.id);
|
||||||
|
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[idx];
|
||||||
|
|
||||||
if (res.resolution === 'skip') {
|
if (res.resolution === 'skip') {
|
||||||
itemsToAdd[idx] = null;
|
itemsToAdd[idx] = null;
|
||||||
@@ -1436,7 +1491,7 @@ export const AddDownloadsModal = () => {
|
|||||||
finalFile,
|
finalFile,
|
||||||
finalLocation,
|
finalLocation,
|
||||||
useSharedDestination,
|
useSharedDestination,
|
||||||
destinationOverrides[idx],
|
itemOverride,
|
||||||
item.isTorrent === true
|
item.isTorrent === true
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1449,11 +1504,12 @@ export const AddDownloadsModal = () => {
|
|||||||
const candidateFile = candidate.isMedia
|
const candidateFile = candidate.isMedia
|
||||||
? mediaFileNameForSelectedFormat(candidate.file, candidate)
|
? mediaFileNameForSelectedFormat(candidate.file, candidate)
|
||||||
: canonicalizeDownloadFileName(candidate.file);
|
: canonicalizeDownloadFileName(candidate.file);
|
||||||
|
const candidateOverride = destinationOverrides[candidate.id] ?? destinationOverrides[candidateIndex];
|
||||||
const candidateLocation = await destinationForFile(
|
const candidateLocation = await destinationForFile(
|
||||||
candidateFile,
|
candidateFile,
|
||||||
finalLocation,
|
finalLocation,
|
||||||
useSharedDestination,
|
useSharedDestination,
|
||||||
destinationOverrides[candidateIndex],
|
candidateOverride,
|
||||||
candidate.isTorrent === true
|
candidate.isTorrent === true
|
||||||
);
|
);
|
||||||
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
|
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
|
||||||
@@ -1516,7 +1572,7 @@ export const AddDownloadsModal = () => {
|
|||||||
finalFile,
|
finalFile,
|
||||||
finalLocation,
|
finalLocation,
|
||||||
useSharedDestination,
|
useSharedDestination,
|
||||||
destinationOverrides[idx],
|
itemOverride,
|
||||||
item.isTorrent === true
|
item.isTorrent === true
|
||||||
);
|
);
|
||||||
const store = useDownloadStore.getState();
|
const store = useDownloadStore.getState();
|
||||||
@@ -1524,7 +1580,7 @@ export const AddDownloadsModal = () => {
|
|||||||
? store.downloads.find(download => download.id === conflict.existingDownloadId)
|
? store.downloads.find(download => download.id === conflict.existingDownloadId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const currentSettings = useSettingsStore.getState();
|
const currentSettings = useSettingsStore.getState();
|
||||||
if (!existingItem && !conflict?.existingDownloadId) {
|
if (!existingItem) {
|
||||||
for (const download of store.downloads) {
|
for (const download of store.downloads) {
|
||||||
const destination = download.destination ||
|
const destination = download.destination ||
|
||||||
await resolveCategoryDestination(currentSettings, download.category);
|
await resolveCategoryDestination(currentSettings, download.category);
|
||||||
@@ -1543,58 +1599,89 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem && isTransferLocked(existingItem.status)) {
|
if (existingItem && isTransferLocked(existingItem.status)) {
|
||||||
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
|
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingItem) {
|
if (!existingItem) {
|
||||||
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
|
let diskTargetKind: string | null = null;
|
||||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
let diskTargetFingerprint: string | undefined;
|
||||||
}
|
let diskTargetOwner: string | undefined;
|
||||||
itemsToAdd[idx] = {
|
try {
|
||||||
...item,
|
const targetInfo = await invoke('inspect_download_target', {
|
||||||
replaceExistingFingerprint: res.replaceFingerprint
|
path: await resolveDownloadFilePath(itemLocation, finalFile)
|
||||||
};
|
});
|
||||||
continue;
|
diskTargetKind = targetInfo.kind;
|
||||||
}
|
diskTargetFingerprint = targetInfo.fingerprint;
|
||||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
diskTargetOwner = targetInfo.ownedBy;
|
||||||
const mediaFormatChanged = item.isMedia
|
} catch (e) {
|
||||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
console.error("Failed to check if file exists on disk:", e);
|
||||||
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
}
|
||||||
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
|
||||||
// Completed replacements must remove the old file so the
|
|
||||||
// new transfer cannot be treated as an already-complete
|
|
||||||
// aria2 target. A torrent replacement also needs a fresh
|
|
||||||
// identity because its cached metadata is keyed by the
|
|
||||||
// new row ID and its output contract differs from a normal
|
|
||||||
// file transfer. Unfinished ordinary rows use the in-place
|
|
||||||
// path to preserve their resumable assets and progress.
|
|
||||||
await store.removeDownload(existingItem.id, true, false);
|
|
||||||
} else {
|
|
||||||
const contextUrl = requestContextUrlForRow(item);
|
|
||||||
const replaced = await store.replaceDownload(existingItem.id, {
|
|
||||||
url: item.downloadUrl,
|
|
||||||
username: useAuth ? username.trim() : undefined,
|
|
||||||
password: useAuth ? password.trim() : undefined,
|
|
||||||
headers: headersForRow(contextUrl) || undefined,
|
|
||||||
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
|
|
||||||
mirrors: mirrors.trim() || undefined,
|
|
||||||
lastError: undefined
|
|
||||||
}, pendingAction);
|
|
||||||
if (!replaced) {
|
|
||||||
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
|
|
||||||
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The existing row was updated in place; do not create a
|
if (diskTargetKind === 'regularFile' && diskTargetFingerprint && !diskTargetOwner) {
|
||||||
// second identity for the same filename.
|
itemsToAdd[idx] = {
|
||||||
itemsToAdd[idx] = null;
|
...item,
|
||||||
updatedCount += 1;
|
replaceExistingFingerprint: diskTargetFingerprint
|
||||||
continue;
|
};
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (diskTargetKind === 'missing' || !diskTargetKind) {
|
||||||
|
itemsToAdd[idx] = {
|
||||||
|
...item,
|
||||||
|
replaceExistingFingerprint: undefined
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.replaceFingerprint && diskTargetFingerprint === res.replaceFingerprint) {
|
||||||
|
itemsToAdd[idx] = {
|
||||||
|
...item,
|
||||||
|
replaceExistingFingerprint: res.replaceFingerprint
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||||
|
}
|
||||||
|
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||||
|
const mediaFormatChanged = item.isMedia
|
||||||
|
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||||
|
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||||
|
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||||
|
// Completed replacements must remove the old file so the
|
||||||
|
// new transfer cannot be treated as an already-complete
|
||||||
|
// aria2 target. A torrent replacement also needs a fresh
|
||||||
|
// identity because its cached metadata is keyed by the
|
||||||
|
// new row ID and its output contract differs from a normal
|
||||||
|
// file transfer. Unfinished ordinary rows use the in-place
|
||||||
|
// path to preserve their resumable assets and progress.
|
||||||
|
await store.removeDownload(existingItem.id, true, false);
|
||||||
|
} else {
|
||||||
|
const contextUrl = requestContextUrlForRow(item);
|
||||||
|
const replaced = await store.replaceDownload(existingItem.id, {
|
||||||
|
url: item.downloadUrl,
|
||||||
|
username: useAuth ? username.trim() : undefined,
|
||||||
|
password: useAuth ? password.trim() : undefined,
|
||||||
|
headers: headersForRow(contextUrl, item.isMedia) || undefined,
|
||||||
|
cookies: cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
|
||||||
|
mirrors: mirrors.trim() || undefined,
|
||||||
|
lastError: undefined
|
||||||
|
}, pendingAction);
|
||||||
|
if (!replaced) {
|
||||||
|
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
|
||||||
|
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The existing row was updated in place; do not create a
|
||||||
|
// second identity for the same filename.
|
||||||
|
itemsToAdd[idx] = null;
|
||||||
|
updatedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
@@ -1617,12 +1704,14 @@ export const AddDownloadsModal = () => {
|
|||||||
} else if (!isMagnetUrl(item.sourceUrl)) {
|
} else if (!isMagnetUrl(item.sourceUrl)) {
|
||||||
// Keep a safe fallback for rows restored from an older draft
|
// Keep a safe fallback for rows restored from an older draft
|
||||||
// shape that did not retain the preview cache identity.
|
// shape that did not retain the preview cache identity.
|
||||||
|
const proxy = await getProxyArgs(useSettingsStore.getState());
|
||||||
const torrentData = await invoke('inspect_torrent', {
|
const torrentData = await invoke('inspect_torrent', {
|
||||||
source: item.sourceUrl,
|
source: item.sourceUrl,
|
||||||
id,
|
id,
|
||||||
cache: true,
|
cache: true,
|
||||||
headers: headersForRow(contextUrl) || undefined,
|
proxy: proxy ?? undefined,
|
||||||
cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined,
|
headers: headersForRow(contextUrl, item.isMedia) || undefined,
|
||||||
|
cookies: cookiesForRow(contextUrl, item.sourceUrl, item.isMedia) || undefined,
|
||||||
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
|
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
|
||||||
torrent: true
|
torrent: true
|
||||||
});
|
});
|
||||||
@@ -1634,6 +1723,7 @@ export const AddDownloadsModal = () => {
|
|||||||
: canonicalizeDownloadFileName(item.file);
|
: canonicalizeDownloadFileName(item.file);
|
||||||
let formatSelector = mediaFormatSelectorForRow(item);
|
let formatSelector = mediaFormatSelectorForRow(item);
|
||||||
const category = categoryForFileName(finalFile, item.isTorrent === true);
|
const category = categoryForFileName(finalFile, item.isTorrent === true);
|
||||||
|
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[itemIndex];
|
||||||
const added = await addDownload({
|
const added = await addDownload({
|
||||||
id,
|
id,
|
||||||
url: item.downloadUrl,
|
url: item.downloadUrl,
|
||||||
@@ -1650,18 +1740,18 @@ export const AddDownloadsModal = () => {
|
|||||||
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
|
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
|
||||||
? sftpHostKeyMd.trim() || undefined
|
? sftpHostKeyMd.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined,
|
headers: item.isTorrent ? undefined : headersForRow(contextUrl, item.isMedia) || undefined,
|
||||||
checksum: checksumEnabled && checksumValue.trim()
|
checksum: checksumEnabled && checksumValue.trim()
|
||||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||||
: undefined,
|
: undefined,
|
||||||
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined,
|
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
|
||||||
mirrors: mirrors.trim() || undefined,
|
mirrors: mirrors.trim() || undefined,
|
||||||
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
|
destination: useSharedDestination || saveInDedicatedFolder || itemOverride
|
||||||
? await destinationForFile(
|
? await destinationForFile(
|
||||||
finalFile,
|
finalFile,
|
||||||
finalLocation,
|
finalLocation,
|
||||||
useSharedDestination,
|
useSharedDestination,
|
||||||
destinationOverrides[itemIndex],
|
itemOverride,
|
||||||
item.isTorrent === true
|
item.isTorrent === true
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -6,20 +6,11 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
|||||||
|
|
||||||
export const DeleteConfirmationModal: React.FC = () => {
|
export const DeleteConfirmationModal: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore();
|
const { deleteModalState, closeDeleteModal, requestRemovals, downloads } = useDownloadStore();
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
|
||||||
const modalRef = useModalFocus(deleteModalState.isOpen);
|
const modalRef = useModalFocus(deleteModalState.isOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deleteModalState.isOpen) {
|
if (!deleteModalState.isOpen) return;
|
||||||
setIsRemoving(false);
|
|
||||||
setErrorMessage('');
|
|
||||||
}
|
|
||||||
}, [deleteModalState.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deleteModalState.isOpen || isRemoving) return;
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
|
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -28,7 +19,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
}, [closeDeleteModal, deleteModalState.isOpen, isRemoving]);
|
}, [closeDeleteModal, deleteModalState.isOpen]);
|
||||||
|
|
||||||
if (!deleteModalState.isOpen) return null;
|
if (!deleteModalState.isOpen) return null;
|
||||||
|
|
||||||
@@ -43,35 +34,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRemoving(true);
|
await requestRemovals(ids, deleteFile);
|
||||||
setErrorMessage('');
|
|
||||||
let succeeded = 0;
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
await removeDownload(
|
|
||||||
id,
|
|
||||||
deleteFile,
|
|
||||||
false,
|
|
||||||
deleteFile ? 'permanentIfUnfinished' : undefined
|
|
||||||
);
|
|
||||||
succeeded += 1;
|
|
||||||
} catch (error) {
|
|
||||||
failures.push(String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
|
|
||||||
succeeded,
|
|
||||||
failed: failures.length,
|
|
||||||
detail: failures[0],
|
|
||||||
}));
|
|
||||||
setIsRemoving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsRemoving(false);
|
|
||||||
closeDeleteModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromList = () => removeMany(false);
|
const handleRemoveFromList = () => removeMany(false);
|
||||||
@@ -87,7 +50,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget && !isRemoving) handleCancel();
|
if (event.target === event.currentTarget) handleCancel();
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -116,27 +79,23 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
{t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
|
{t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={isRemoving}
|
|
||||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t($ => $.actions.cancel)}
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveFromList}
|
onClick={handleRemoveFromList}
|
||||||
disabled={isRemoving}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t($ => $.dialogs.removeDownload.remove)}
|
{t($ => $.dialogs.removeDownload.remove)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteFile}
|
onClick={handleDeleteFile}
|
||||||
disabled={isRemoving}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t($ => $.dialogs.removeDownload.deleteFile)}
|
{t($ => $.dialogs.removeDownload.deleteFile)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useDownloadStore } from "../store/useDownloadStore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
|
||||||
@@ -50,6 +51,7 @@ interface DownloadItemProps {
|
|||||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||||
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
|
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||||
|
onRowKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, download: DownloadItemType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||||
@@ -74,9 +76,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onMoveInQueue,
|
onMoveInQueue,
|
||||||
onQueueDragStart,
|
onQueueDragStart,
|
||||||
onClick,
|
onClick,
|
||||||
|
onRowKeyDown,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
|
const removal = useDownloadStore(state => state.removalJobs[download.id]);
|
||||||
|
const removing = !!removal && removal.phase !== "failed" && removal.phase !== "completed";
|
||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||||
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
||||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -85,7 +90,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const [isActionHovered, setIsActionHovered] = React.useState(false);
|
const [isActionHovered, setIsActionHovered] = React.useState(false);
|
||||||
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
||||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||||
const waitingForPeers = isTorrentWaitingForPeers({
|
const waitingForPeers = !removal && isTorrentWaitingForPeers({
|
||||||
isTorrent: download.isTorrent,
|
isTorrent: download.isTorrent,
|
||||||
status: download.status,
|
status: download.status,
|
||||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||||
@@ -93,9 +98,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
connectedPeers: liveProgress?.active_connections,
|
connectedPeers: liveProgress?.active_connections,
|
||||||
connectedSeeders: liveProgress?.num_seeders,
|
connectedSeeders: liveProgress?.num_seeders,
|
||||||
});
|
});
|
||||||
const allocationVisible = download.isTorrent !== true
|
const allocationVisible = !removal && isAllocationPhaseVisible(allocationPending, download.status);
|
||||||
&& isAllocationPhaseVisible(allocationPending, download.status);
|
const hasRowActions = !removal && download.status !== 'completed';
|
||||||
const hasRowActions = download.status !== 'completed';
|
|
||||||
const isBulkSelection = isSelected && selectedDownloadCount > 1;
|
const isBulkSelection = isSelected && selectedDownloadCount > 1;
|
||||||
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
|
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
|
||||||
? selectedActionCounts.pause
|
? selectedActionCounts.pause
|
||||||
@@ -214,7 +218,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
status: download.status,
|
status: download.status,
|
||||||
});
|
});
|
||||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||||
const displaySpeed = allocationVisible
|
const displaySpeed = removal || allocationVisible
|
||||||
? '-'
|
? '-'
|
||||||
: download.status === 'seeding'
|
: download.status === 'seeding'
|
||||||
? liveProgress?.upload_speed ?? '-'
|
? liveProgress?.upload_speed ?? '-'
|
||||||
@@ -223,7 +227,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? t($ => $.downloads.values.processing)
|
? t($ => $.downloads.values.processing)
|
||||||
: '-';
|
: '-';
|
||||||
const displayEta = allocationVisible
|
const displayEta = removal || allocationVisible
|
||||||
? '-'
|
? '-'
|
||||||
: download.status === 'seeding'
|
: download.status === 'seeding'
|
||||||
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
||||||
@@ -246,14 +250,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
})();
|
})();
|
||||||
const downloadStatusLabel = allocationVisible
|
const downloadStatusLabel = removal
|
||||||
|
? t($ => removal.phase === 'failed' ? $.downloads.removal.error : $.downloads.removal.removing)
|
||||||
|
: allocationVisible
|
||||||
? t($ => $.downloads.status.allocatingFiles)
|
? t($ => $.downloads.status.allocatingFiles)
|
||||||
: waitingForPeers
|
: waitingForPeers
|
||||||
? t($ => $.downloads.status.waitingForPeers)
|
? t($ => $.downloads.status.waitingForPeers)
|
||||||
: t($ => $.downloads.status[download.status]);
|
: t($ => $.downloads.status[download.status]);
|
||||||
const visibleErrorStatusLabel = download.credentialsRequired === true
|
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
|
||||||
? t($ => $.properties.credentialsRequired)
|
|
||||||
: download.lastErrorKind === 'nameResolution'
|
|
||||||
? download.status === 'retrying' && download.lastResolverFallback === true
|
? download.status === 'retrying' && download.lastResolverFallback === true
|
||||||
? t($ => $.downloads.errors.nameResolutionRetrying)
|
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||||
: download.status === 'failed'
|
: download.status === 'failed'
|
||||||
@@ -344,14 +348,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
<div className="download-cell-content download-status-content">
|
<div className="download-cell-content download-status-content">
|
||||||
<div
|
<div
|
||||||
className="download-progress-track"
|
className="download-progress-track"
|
||||||
aria-label={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
aria-label={allocationVisible || waitingForPeers || removing ? downloadStatusLabel : undefined}
|
||||||
aria-busy={allocationVisible ? true : undefined}
|
aria-busy={allocationVisible || removing ? true : undefined}
|
||||||
aria-valuetext={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
aria-valuetext={allocationVisible || waitingForPeers || removing ? downloadStatusLabel : undefined}
|
||||||
role={allocationVisible || waitingForPeers ? 'progressbar' : undefined}
|
role={allocationVisible || waitingForPeers || removing ? 'progressbar' : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`download-progress-fill ${
|
className={`download-progress-fill ${
|
||||||
allocationVisible ? 'allocating' :
|
allocationVisible || removing ? 'allocating' :
|
||||||
download.status === 'paused' ? 'paused' :
|
download.status === 'paused' ? 'paused' :
|
||||||
download.status === 'seeding' ? 'seeding' :
|
download.status === 'seeding' ? 'seeding' :
|
||||||
download.status === 'processing' ? 'processing' :
|
download.status === 'processing' ? 'processing' :
|
||||||
@@ -360,7 +364,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||||
download.status === 'retrying' ? 'retrying' : ''
|
download.status === 'retrying' ? 'retrying' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ width: allocationVisible ? undefined : `${displayFraction * 100}%` }}
|
style={{ width: allocationVisible || removing ? undefined : `${displayFraction * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -371,7 +375,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'failed'
|
download.status === 'failed'
|
||||||
|| download.status === 'retrying'
|
|| download.status === 'retrying'
|
||||||
|| download.lastErrorKind === 'destinationAccess'
|
|| download.lastErrorKind === 'destinationAccess'
|
||||||
|| download.credentialsRequired === true
|
|
||||||
)
|
)
|
||||||
? download.lastError
|
? download.lastError
|
||||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||||
@@ -385,7 +388,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
: downloadStatusLabel
|
: downloadStatusLabel
|
||||||
}
|
}
|
||||||
className={`download-status flex items-center gap-1.5 ${
|
className={`download-status flex items-center gap-1.5 ${
|
||||||
allocationVisible ? 'download-status-downloading' :
|
removal?.phase === 'failed' ? 'download-status-failed' :
|
||||||
|
removing || allocationVisible ? 'download-status-downloading' :
|
||||||
download.status === 'paused' ? 'download-status-paused' :
|
download.status === 'paused' ? 'download-status-paused' :
|
||||||
download.status === 'seeding' ? 'download-status-seeding' :
|
download.status === 'seeding' ? 'download-status-seeding' :
|
||||||
download.status === 'failed' ? 'download-status-failed' :
|
download.status === 'failed' ? 'download-status-failed' :
|
||||||
@@ -397,7 +401,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{allocationVisible ? (
|
{removal ? (
|
||||||
|
<>
|
||||||
|
{removing && <RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />}
|
||||||
|
<span role="status" className="truncate" title={removal.phase === 'failed' ? t($ => $.downloads.removal.failed) : undefined}>{downloadStatusLabel}</span>
|
||||||
|
{removal.phase === 'failed' && <button className="app-button shrink-0" onClick={event => { event.stopPropagation(); void useDownloadStore.getState().retryRemoval(download.id); }}>{t($ => $.downloads.removal.retry)}</button>}
|
||||||
|
</>
|
||||||
|
) : allocationVisible ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
|
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
|
||||||
<span className="truncate">{downloadStatusLabel}</span>
|
<span className="truncate">{downloadStatusLabel}</span>
|
||||||
@@ -487,14 +497,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
||||||
className="app-icon-button main-control-button"
|
className="app-icon-button main-control-button"
|
||||||
title={resumeSelectionCount === null
|
title={resumeSelectionCount === null
|
||||||
? download.credentialsRequired === true
|
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||||
? t($ => $.properties.retryWithoutCredentials)
|
|
||||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
|
||||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
aria-label={resumeSelectionCount === null
|
aria-label={resumeSelectionCount === null
|
||||||
? download.credentialsRequired === true
|
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||||
? t($ => $.properties.retryWithoutCredentials)
|
|
||||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
|
||||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
>
|
>
|
||||||
<Play size={14} fill="currentColor" />
|
<Play size={14} fill="currentColor" />
|
||||||
@@ -525,7 +531,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX || rect.left;
|
||||||
|
const y = e.clientY || rect.bottom + 4;
|
||||||
|
setContextMenu({ x, y, id: download.id });
|
||||||
}}
|
}}
|
||||||
className="app-icon-button main-control-button"
|
className="app-icon-button main-control-button"
|
||||||
title={t($ => $.downloads.actions.options)}
|
title={t($ => $.downloads.actions.options)}
|
||||||
@@ -569,7 +578,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
// capture the pointer and suppress the click that applies Cmd/Ctrl or
|
// capture the pointer and suppress the click that applies Cmd/Ctrl or
|
||||||
// Shift selection.
|
// Shift selection.
|
||||||
if (
|
if (
|
||||||
isQueueReorderable &&
|
!removal && isQueueReorderable &&
|
||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
!event.ctrlKey &&
|
!event.ctrlKey &&
|
||||||
@@ -581,7 +590,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={(e) => onClick(e, download)}
|
onClick={(e) => onClick(e, download)}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (
|
if (
|
||||||
isQueueReorderable &&
|
!removal && isQueueReorderable &&
|
||||||
event.altKey &&
|
event.altKey &&
|
||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
!event.ctrlKey &&
|
!event.ctrlKey &&
|
||||||
@@ -591,11 +600,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
onRowKeyDown?.(event, download);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
if (removal) return;
|
||||||
|
const isKeyboard = (e.clientX === 0 && e.clientY === 0) || (e.button === 0 && e.detail === 0);
|
||||||
|
let x = e.clientX;
|
||||||
|
let y = e.clientY;
|
||||||
|
if (isKeyboard && rowRef.current) {
|
||||||
|
const rect = rowRef.current.getBoundingClientRect();
|
||||||
|
x = rect.left + 40;
|
||||||
|
y = rect.top + rect.height / 2;
|
||||||
|
}
|
||||||
|
setContextMenu({ x, y, id: download.id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -739,6 +739,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
persistColumnWidths(widths);
|
persistColumnWidths(widths);
|
||||||
persistColumnOrder(order);
|
persistColumnOrder(order);
|
||||||
persistColumnAlignments(alignments);
|
persistColumnAlignments(alignments);
|
||||||
|
setQueueSortConfig(null);
|
||||||
setColumnMenu(null);
|
setColumnMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -749,6 +750,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
};
|
};
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
if (contextMenuRef.current || columnMenuRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setColumnMenu(null);
|
setColumnMenu(null);
|
||||||
}
|
}
|
||||||
@@ -1666,18 +1671,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}
|
}
|
||||||
}, [queueReorderableDownloads, queueReorderingEnabled]);
|
}, [queueReorderableDownloads, queueReorderingEnabled]);
|
||||||
|
|
||||||
|
const removalJobs = useDownloadStore(state => state.removalJobs);
|
||||||
const selectedDownloads = useMemo(
|
const selectedDownloads = useMemo(
|
||||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||||
[filteredDownloads, selectedIds]
|
[filteredDownloads, selectedIds]
|
||||||
);
|
);
|
||||||
const selectedActionCounts = useMemo(
|
const selectedActionCounts = useMemo(
|
||||||
() => countDownloadActions(selectedDownloads),
|
() => countDownloadActions(selectedDownloads.filter(download => !removalJobs[download.id])),
|
||||||
[selectedDownloads]
|
[selectedDownloads, removalJobs]
|
||||||
);
|
);
|
||||||
const hasStartableDownloads = downloads.some(download =>
|
const hasStartableDownloads = downloads.some(download =>
|
||||||
download.status === 'queued' || canStartDownload(download.status)
|
!removalJobs[download.id] && (download.status === 'queued' || canStartDownload(download.status))
|
||||||
);
|
);
|
||||||
const hasPausableDownloads = downloads.some(download => canPauseDownload(download.status));
|
const hasPausableDownloads = downloads.some(download => !removalJobs[download.id] && canPauseDownload(download.status));
|
||||||
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
||||||
const downloadSummary = useMemo(
|
const downloadSummary = useMemo(
|
||||||
() => summarizeDownloads(summaryDownloads, progressMap),
|
() => summarizeDownloads(summaryDownloads, progressMap),
|
||||||
@@ -1752,8 +1758,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}, [sortedDownloads]);
|
}, [sortedDownloads]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setColumnMenu(null);
|
||||||
setQueueSortConfig(null);
|
setQueueSortConfig(null);
|
||||||
}, [filter, isQueueFilter]);
|
}, [filter, isQueueFilter]);
|
||||||
|
|
||||||
|
const writeToClipboard = useCallback(async (text: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await writeClipboardText(text);
|
||||||
|
} catch {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||||
if (suppressQueueClickRef.current) {
|
if (suppressQueueClickRef.current) {
|
||||||
clearQueueClickSuppression();
|
clearQueueClickSuppression();
|
||||||
@@ -1789,6 +1806,82 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
setContextMenu(menu);
|
setContextMenu(menu);
|
||||||
}, [clampMenuPosition]);
|
}, [clampMenuPosition]);
|
||||||
|
|
||||||
|
const handleRowKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>, item: DownloadItem) => {
|
||||||
|
if (e.target instanceof Element && e.target.closest('button, a, input, textarea, select')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const row = queueRowForId(item.id);
|
||||||
|
const rect = row?.getBoundingClientRect();
|
||||||
|
const x = rect ? rect.left + 40 : 100;
|
||||||
|
const y = rect ? rect.top + rect.height / 2 : 100;
|
||||||
|
handleContextMenu({ x, y, id: item.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownloadDoubleClick(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const nextSelection = updateDownloadSelection({
|
||||||
|
orderedIds: sortedDownloadsRef.current.map(d => d.id),
|
||||||
|
selectedIds: selectedIdsRef.current,
|
||||||
|
lastSelectedId: lastSelectedIdRef.current,
|
||||||
|
targetId: item.id,
|
||||||
|
extendRange: e.shiftKey,
|
||||||
|
toggle: true,
|
||||||
|
});
|
||||||
|
setSelectedIds(nextSelection.selectedIds);
|
||||||
|
setLastSelectedId(nextSelection.lastSelectedId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')
|
||||||
|
) {
|
||||||
|
const items = sortedDownloadsRef.current;
|
||||||
|
const currentIndex = items.findIndex(d => d.id === item.id);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
let targetIndex = currentIndex;
|
||||||
|
if (e.key === 'ArrowDown') targetIndex = Math.min(items.length - 1, currentIndex + 1);
|
||||||
|
else if (e.key === 'ArrowUp') targetIndex = Math.max(0, currentIndex - 1);
|
||||||
|
else if (e.key === 'Home') targetIndex = 0;
|
||||||
|
else if (e.key === 'End') targetIndex = items.length - 1;
|
||||||
|
|
||||||
|
if (targetIndex !== currentIndex) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const targetItem = items[targetIndex];
|
||||||
|
const nextSelection = updateDownloadSelection({
|
||||||
|
orderedIds: items.map(d => d.id),
|
||||||
|
selectedIds: selectedIdsRef.current,
|
||||||
|
lastSelectedId: lastSelectedIdRef.current,
|
||||||
|
targetId: targetItem.id,
|
||||||
|
extendRange: e.shiftKey,
|
||||||
|
toggle: false,
|
||||||
|
});
|
||||||
|
setSelectedIds(nextSelection.selectedIds);
|
||||||
|
setLastSelectedId(nextSelection.lastSelectedId);
|
||||||
|
const targetElement = queueRowForId(targetItem.id);
|
||||||
|
targetElement?.focus({ preventScroll: false });
|
||||||
|
targetElement?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [handleContextMenu, handleDownloadDoubleClick]);
|
||||||
|
|
||||||
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
||||||
if (
|
if (
|
||||||
queueDragStateRef.current ||
|
queueDragStateRef.current ||
|
||||||
@@ -1821,15 +1914,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}, [moveInQueue, showInteractionError, t]);
|
}, [moveInQueue, showInteractionError, t]);
|
||||||
|
|
||||||
const handleSort = (column: DownloadSortColumn) => {
|
const handleSort = (column: DownloadSortColumn) => {
|
||||||
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
|
|
||||||
current?.column === column
|
|
||||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
|
||||||
: { column, direction: 'asc' };
|
|
||||||
|
|
||||||
if (isQueueFilter) {
|
if (isQueueFilter) {
|
||||||
setQueueSortConfig(update);
|
setQueueSortConfig(current => {
|
||||||
|
if (current?.column !== column) {
|
||||||
|
return { column, direction: 'asc' };
|
||||||
|
}
|
||||||
|
if (current.direction === 'asc') {
|
||||||
|
return { column, direction: 'desc' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setSortConfig(current => update(current));
|
setSortConfig(current =>
|
||||||
|
current?.column === column
|
||||||
|
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||||
|
: { column, direction: 'asc' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1878,15 +1978,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
try {
|
try {
|
||||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
let resumeWithoutCredentials = false;
|
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||||
if (current.credentialsRequired === true) {
|
|
||||||
resumeWithoutCredentials = window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
|
||||||
if (!resumeWithoutCredentials) return;
|
|
||||||
}
|
|
||||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id, {
|
|
||||||
resumeWithoutCredentials
|
|
||||||
});
|
|
||||||
if (!resumed) {
|
if (!resumed) {
|
||||||
|
// A configured site login opens the keychain consent modal instead of
|
||||||
|
// starting a credentialless request. That is a pending user decision,
|
||||||
|
// not a backend rejection, so do not show a second misleading error.
|
||||||
|
if (useSettingsStore.getState().showKeychainModal) return;
|
||||||
const latest = useDownloadStore.getState().downloads.find(
|
const latest = useDownloadStore.getState().downloads.find(
|
||||||
download => download.id === item.id
|
download => download.id === item.id
|
||||||
);
|
);
|
||||||
@@ -1901,7 +1998,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
|
|
||||||
const getCurrentSelectedDownloads = useCallback(() => {
|
const getCurrentSelectedDownloads = useCallback(() => {
|
||||||
const selected = selectedIdsRef.current;
|
const selected = selectedIdsRef.current;
|
||||||
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id));
|
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id) && !useDownloadStore.getState().removalJobs[download.id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePauseSelected = useCallback(async () => {
|
const handlePauseSelected = useCallback(async () => {
|
||||||
@@ -1929,42 +2026,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const handleResumeSelected = useCallback(() => {
|
const handleResumeSelected = useCallback(() => {
|
||||||
const ids = Array.from(selectedIdsRef.current);
|
const ids = Array.from(selectedIdsRef.current);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
|
void startSelected(ids).catch(error => {
|
||||||
const credentialMarkedIds = selected
|
|
||||||
.filter(download => download.credentialsRequired === true && canStartDownload(download.status))
|
|
||||||
.map(download => download.id);
|
|
||||||
if (credentialMarkedIds.length > 0
|
|
||||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
|
||||||
// Continue ordinary selected resumes. Credential-marked rows remain
|
|
||||||
// fail-closed and can be handled individually after the user supplies
|
|
||||||
// credentials or confirms a credentialless retry.
|
|
||||||
const credentialMarkedIdSet = new Set(credentialMarkedIds);
|
|
||||||
const ordinaryIds = ids.filter(id => !credentialMarkedIdSet.has(id));
|
|
||||||
if (ordinaryIds.length === 0) return;
|
|
||||||
void startSelected(ordinaryIds).catch(error => {
|
|
||||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void startSelected(ids, {
|
|
||||||
resumeWithoutCredentialsIds: credentialMarkedIds
|
|
||||||
}).catch(error => {
|
|
||||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||||
});
|
});
|
||||||
}, [showInteractionError, startSelected, t]);
|
}, [showInteractionError, startSelected, t]);
|
||||||
|
|
||||||
const handleStartAll = useCallback(() => {
|
const handleStartAll = useCallback(() => {
|
||||||
const credentialMarkedIds = useDownloadStore.getState().downloads
|
void startAll().catch(error => {
|
||||||
.filter(download =>
|
|
||||||
download.credentialsRequired === true
|
|
||||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
|
||||||
)
|
|
||||||
.map(download => download.id);
|
|
||||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
|
||||||
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
|
||||||
void startAll({
|
|
||||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
|
||||||
}).catch(error => {
|
|
||||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||||
});
|
});
|
||||||
}, [showInteractionError, startAll, t]);
|
}, [showInteractionError, startAll, t]);
|
||||||
@@ -2334,6 +2402,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
onMoveInQueue={handleMoveInQueue}
|
onMoveInQueue={handleMoveInQueue}
|
||||||
onQueueDragStart={stableHandleQueueDragStart}
|
onQueueDragStart={stableHandleQueueDragStart}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
|
onRowKeyDown={handleRowKeyDown}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
||||||
@@ -2378,6 +2447,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
|
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
|
||||||
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
|
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = columnMenuRef.current;
|
||||||
|
if (!menu) return;
|
||||||
|
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
const nextIdx = e.key === 'ArrowDown'
|
||||||
|
? (activeIdx + 1) % buttons.length
|
||||||
|
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
|
||||||
|
buttons[nextIdx]?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
|
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
|
||||||
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
|
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
|
||||||
@@ -2426,6 +2510,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
left: contextMenuPosition?.x,
|
left: contextMenuPosition?.x,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = contextMenuRef.current;
|
||||||
|
if (!menu) return;
|
||||||
|
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
const nextIdx = e.key === 'ArrowDown'
|
||||||
|
? (activeIdx + 1) % buttons.length
|
||||||
|
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
|
||||||
|
buttons[nextIdx]?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectedIds.size > 1 ? (() => {
|
{selectedIds.size > 1 ? (() => {
|
||||||
const selectedDownloads = Array.from(selectedIds)
|
const selectedDownloads = Array.from(selectedIds)
|
||||||
@@ -2489,7 +2588,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
.map(id => downloads.find(d => d.id === id)?.url)
|
.map(id => downloads.find(d => d.id === id)?.url)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
navigator.clipboard.writeText(urls).catch(error => {
|
writeToClipboard(urls).catch(error => {
|
||||||
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
|
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -2596,7 +2695,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
navigator.clipboard.writeText(contextItem.url).catch(error => {
|
writeToClipboard(contextItem.url).catch(error => {
|
||||||
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
|
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -2611,7 +2710,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
try {
|
try {
|
||||||
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
|
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
|
||||||
await writeClipboardText(magnet);
|
await writeToClipboard(magnet);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
|
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
|
||||||
}
|
}
|
||||||
@@ -2632,7 +2731,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(fullPath);
|
await writeToClipboard(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
|
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREA
|
|||||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||||
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
||||||
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
|
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
|
||||||
|
import { copyTorrentFilePath } from '../utils/torrentFilePath';
|
||||||
|
import { useWindowFocusState } from '../utils/windowFocus';
|
||||||
|
import { useWindowMaximizedState } from '../utils/windowMaximized';
|
||||||
import { WindowControls } from './WindowControls';
|
import { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
@@ -201,6 +204,8 @@ const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string
|
|||||||
|
|
||||||
export const PropertiesWindowApp = () => {
|
export const PropertiesWindowApp = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isWindowActive = useWindowFocusState();
|
||||||
|
const isWindowMaximized = useWindowMaximizedState();
|
||||||
const translationRef = useRef(t);
|
const translationRef = useRef(t);
|
||||||
translationRef.current = t;
|
translationRef.current = t;
|
||||||
const currentWindow = useMemo(() => getCurrentWindow(), []);
|
const currentWindow = useMemo(() => getCurrentWindow(), []);
|
||||||
@@ -1134,6 +1139,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
<main
|
<main
|
||||||
className={windowShellClassName}
|
className={windowShellClassName}
|
||||||
style={windowShellStyle}
|
style={windowShellStyle}
|
||||||
|
data-window-active={isWindowActive ? 'true' : 'false'}
|
||||||
|
data-window-maximized={isWindowMaximized ? 'true' : 'false'}
|
||||||
aria-labelledby="properties-window-title"
|
aria-labelledby="properties-window-title"
|
||||||
>
|
>
|
||||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||||
@@ -1147,17 +1154,17 @@ export const PropertiesWindowApp = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
const editingEnabled = !snapshot.removalPhase && pendingAction === null && isEditableStatus(snapshot.status);
|
||||||
const liveNormalSpeedEnabled = pendingAction === null
|
const liveNormalSpeedEnabled = !snapshot.removalPhase && pendingAction === null
|
||||||
&& snapshot.isMedia !== true
|
&& snapshot.isMedia !== true
|
||||||
&& snapshot.isTorrent !== true
|
&& snapshot.isTorrent !== true
|
||||||
&& isLiveNormalSpeedStatus(snapshot.status);
|
&& isLiveNormalSpeedStatus(snapshot.status);
|
||||||
const liveTorrentOptionsEnabled = pendingAction === null
|
const liveTorrentOptionsEnabled = !snapshot.removalPhase && pendingAction === null
|
||||||
&& snapshot.isTorrent === true
|
&& snapshot.isTorrent === true
|
||||||
&& isLiveTorrentControlStatus(snapshot.status);
|
&& isLiveTorrentControlStatus(snapshot.status);
|
||||||
const liveTorrentSpeedEnabled = liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status);
|
const liveTorrentSpeedEnabled = !snapshot.removalPhase && liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status);
|
||||||
const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status);
|
const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status);
|
||||||
const torrentMoveAvailable = ['paused', 'completed', 'failed'].includes(snapshot.status);
|
const torrentMoveAvailable = !snapshot.removalPhase && ['paused', 'completed', 'failed'].includes(snapshot.status);
|
||||||
const progress = getPropertiesProgress(snapshot);
|
const progress = getPropertiesProgress(snapshot);
|
||||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||||
const footerActions = getPropertiesFooterActions({
|
const footerActions = getPropertiesFooterActions({
|
||||||
@@ -1174,12 +1181,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
connectedPeers: snapshot.torrentConnectedPeers,
|
connectedPeers: snapshot.torrentConnectedPeers,
|
||||||
connectedSeeders: snapshot.torrentConnectedSeeders,
|
connectedSeeders: snapshot.torrentConnectedSeeders,
|
||||||
});
|
});
|
||||||
const allocationPending = snapshot.isTorrent !== true
|
const allocationPending = !snapshot.removalPhase && isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status);
|
||||||
&& isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status);
|
|
||||||
const total = snapshot.size || (snapshot.totalBytes === undefined
|
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||||
? t($ => $.addDownloads.unknownSize)
|
? t($ => $.addDownloads.unknownSize)
|
||||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||||
const statusLabel = allocationPending
|
const statusLabel = snapshot.removalPhase
|
||||||
|
? t($ => snapshot.removalPhase === 'failed' ? $.downloads.removal.error : $.downloads.removal.removing)
|
||||||
|
: allocationPending
|
||||||
? t($ => $.downloads.status.allocatingFiles)
|
? t($ => $.downloads.status.allocatingFiles)
|
||||||
: waitingForPeers
|
: waitingForPeers
|
||||||
? t($ => $.downloads.status.waitingForPeers)
|
? t($ => $.downloads.status.waitingForPeers)
|
||||||
@@ -1223,17 +1231,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
snapshot.queuePosition,
|
snapshot.queuePosition,
|
||||||
position => t($ => $.properties.queuePosition, { position }),
|
position => t($ => $.properties.queuePosition, { position }),
|
||||||
);
|
);
|
||||||
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
const indeterminate = allocationPending || snapshot.removalPhase === "pending" || snapshot.removalPhase === "running";
|
||||||
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
const progressPercent = indeterminate ? '—' : `${Math.round(progress * 100)}%`;
|
||||||
const lifecycleLabel = snapshot.credentialsRequired === true
|
const statusTone = indeterminate ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||||
? t($ => $.properties.retryWithoutCredentials)
|
const lifecycleLabel = lifecycleAction === 'pause'
|
||||||
: lifecycleAction === 'pause'
|
? t($ => $.downloads.actions.pause)
|
||||||
? t($ => $.downloads.actions.pause)
|
: lifecycleAction === 'resume'
|
||||||
: lifecycleAction === 'resume'
|
? t($ => $.downloads.actions.resume)
|
||||||
? t($ => $.downloads.actions.resume)
|
: lifecycleAction === 'retry'
|
||||||
: lifecycleAction === 'retry'
|
? t($ => $.downloads.actions.retry)
|
||||||
? t($ => $.downloads.actions.retry)
|
: t($ => $.downloads.actions.start);
|
||||||
: t($ => $.downloads.actions.start);
|
|
||||||
const tabLabel = (tab: PropertiesTab) => {
|
const tabLabel = (tab: PropertiesTab) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'overview': return t($ => $.properties.tabs.overview);
|
case 'overview': return t($ => $.properties.tabs.overview);
|
||||||
@@ -1250,6 +1257,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
<main
|
<main
|
||||||
className={windowShellClassName}
|
className={windowShellClassName}
|
||||||
style={windowShellStyle}
|
style={windowShellStyle}
|
||||||
|
data-window-active={isWindowActive ? 'true' : 'false'}
|
||||||
|
data-window-maximized={isWindowMaximized ? 'true' : 'false'}
|
||||||
aria-labelledby="properties-window-title"
|
aria-labelledby="properties-window-title"
|
||||||
>
|
>
|
||||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||||
@@ -1278,16 +1287,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
|
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resumeWithoutCredentials = (lifecycleAction === 'resume' || lifecycleAction === 'retry')
|
void requestAction('pause-resume');
|
||||||
&& snapshot.credentialsRequired === true;
|
|
||||||
if (resumeWithoutCredentials
|
|
||||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void requestAction(
|
|
||||||
'pause-resume',
|
|
||||||
resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
|
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
|
||||||
@@ -1314,28 +1314,28 @@ export const PropertiesWindowApp = () => {
|
|||||||
<div
|
<div
|
||||||
className="properties-window-progress-track"
|
className="properties-window-progress-track"
|
||||||
aria-label={t($ => $.properties.progress)}
|
aria-label={t($ => $.properties.progress)}
|
||||||
aria-busy={allocationPending}
|
aria-busy={indeterminate}
|
||||||
aria-valuetext={allocationPending ? statusLabel : undefined}
|
aria-valuetext={indeterminate ? statusLabel : undefined}
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin={allocationPending ? undefined : 0}
|
aria-valuemin={indeterminate ? undefined : 0}
|
||||||
aria-valuemax={allocationPending ? undefined : 100}
|
aria-valuemax={indeterminate ? undefined : 100}
|
||||||
aria-valuenow={allocationPending ? undefined : Math.round(progress * 100)}
|
aria-valuenow={indeterminate ? undefined : Math.round(progress * 100)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`properties-window-progress-fill ${allocationPending ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
|
className={`properties-window-progress-fill ${indeterminate ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
|
||||||
style={{ width: allocationPending ? undefined : `${progress * 100}%` }}
|
style={{ width: indeterminate ? undefined : `${progress * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="properties-window-progress-percent">{progressPercent}</span>
|
<span className="properties-window-progress-percent">{progressPercent}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="properties-window-metrics" dir="ltr">
|
<div className="properties-window-metrics" dir="ltr">
|
||||||
<div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
<div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{indeterminate ? '—' : snapshot.speed || '—'}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{indeterminate ? '—' : snapshot.eta || '—'}</strong></div></div>
|
||||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||||
{isTorrent && <>
|
{isTorrent && <>
|
||||||
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, snapshot.appearance.locale)}</strong></div></div>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
|
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
|
||||||
@@ -1458,7 +1458,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
|
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
|
||||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] p-2" dir="auto" title={file.relativePath}><div className="flex items-center gap-1.5 min-w-0"><span className="truncate flex-1 min-w-0">{file.relativePath}</span><button type="button" className="app-icon-button shrink-0 opacity-70 hover:opacity-100 focus-visible:opacity-100" aria-label={t($ => $.downloadTable.copyFilePath)} title={t($ => $.downloadTable.copyFilePath)} onClick={event => { event.preventDefault(); event.stopPropagation(); void copyTorrentFilePath(file.relativePath, writeClipboardText).then(() => setNotice(t($ => $.logs.copied))).catch(() => setErrorMessage(t($ => $.downloadTable.copyPathFailed))); }}><Copy size={12} aria-hidden="true" /></button></div></td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
||||||
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
|
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
|
||||||
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
|
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
|
||||||
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||||
@@ -1595,7 +1595,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<PropertiesField
|
<PropertiesField
|
||||||
label={t($ => $.properties.torrentPeerSpeedLimit)}
|
label={t($ => $.properties.torrentPeerSpeedLimit)}
|
||||||
controlId="properties-options-peer-speed-limit"
|
controlId="properties-options-peer-speed-limit"
|
||||||
hint={t($ => $.properties.torrentPeerOptionsSavedHint)}
|
hint={t($ => $.properties.torrentPeerSpeedLimitHint)}
|
||||||
meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
||||||
>
|
>
|
||||||
@@ -1704,7 +1704,6 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
{activeTab === 'advanced' && <div className="space-y-4">
|
{activeTab === 'advanced' && <div className="space-y-4">
|
||||||
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
||||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
|
||||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||||
<div><span className="text-text-muted">{connectionHeaderLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
<div><span className="text-text-muted">{connectionHeaderLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||||
@@ -1723,7 +1722,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
|
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
|
||||||
{isPromptFooter ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.keepEditing)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{footerActions.includes('discardChanges') && <><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.window.close)}</button></div></div>}
|
{isPromptFooter ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={!!snapshot.removalPhase || pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.keepEditing)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{footerActions.includes('discardChanges') && <><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={!!snapshot.removalPhase || pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.window.close)}</button></div></div>}
|
||||||
</div>}
|
</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
queueName: queue?.name,
|
queueName: queue?.name,
|
||||||
windowChrome,
|
windowChrome,
|
||||||
allocationPending: store.allocationPendingIds.has(downloadId),
|
allocationPending: store.allocationPendingIds.has(downloadId),
|
||||||
|
removalPhase: store.removalJobs[downloadId]?.phase,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -416,6 +417,9 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const item = store.downloads.find(download => download.id === request.downloadId);
|
const item = store.downloads.find(download => download.id === request.downloadId);
|
||||||
if (!item) throw new Error('Download no longer exists');
|
if (!item) throw new Error('Download no longer exists');
|
||||||
|
|
||||||
|
if (useDownloadStore.getState().removalJobs[request.downloadId]) {
|
||||||
|
throw new Error(i18n.t($ => $.downloads.removal.pending));
|
||||||
|
}
|
||||||
switch (request.action) {
|
switch (request.action) {
|
||||||
case 'apply-properties': {
|
case 'apply-properties': {
|
||||||
await assertCurrentAction(request);
|
await assertCurrentAction(request);
|
||||||
@@ -538,15 +542,12 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
throw new Error('The download did not reach a paused or terminal state');
|
throw new Error('The download did not reach a paused or terminal state');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resumeWithoutCredentials = typeof request.payload === 'object'
|
const resumed = await store.resumeDownload(request.downloadId);
|
||||||
&& request.payload !== null
|
|
||||||
&& 'resumeWithoutCredentials' in request.payload
|
|
||||||
&& request.payload.resumeWithoutCredentials === true;
|
|
||||||
const resumed = await store.resumeDownload(
|
|
||||||
request.downloadId,
|
|
||||||
resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined,
|
|
||||||
);
|
|
||||||
if (!resumed) {
|
if (!resumed) {
|
||||||
|
// The resume request may have opened the main window's
|
||||||
|
// keychain consent modal. It is a pending user decision, not
|
||||||
|
// a backend rejection to report from the child window.
|
||||||
|
if (useSettingsStore.getState().showKeychainModal) break;
|
||||||
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
|
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
|
||||||
}
|
}
|
||||||
// resumeDownload returns after the lifecycle request has been
|
// resumeDownload returns after the lifecycle request has been
|
||||||
@@ -752,6 +753,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||||
} else if (
|
} else if (
|
||||||
next !== before
|
next !== before
|
||||||
|
|| state.removalJobs[downloadId] !== previous.removalJobs[downloadId]
|
||||||
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
|
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
|
||||||
) {
|
) {
|
||||||
snapshotCoalescer.schedule(windowLabel);
|
snapshotCoalescer.schedule(windowLabel);
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ export default function SchedulerView() {
|
|||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
useSettingsStore.getState().setSchedulerRunning(false);
|
||||||
|
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
||||||
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
|
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -213,17 +215,30 @@ export default function SchedulerView() {
|
|||||||
const generation = beginSchedulerControl();
|
const generation = beginSchedulerControl();
|
||||||
const savedQueueIds = savedSettings.selectedQueueIds
|
const savedQueueIds = savedSettings.selectedQueueIds
|
||||||
.filter(queueId => availableQueueIds.has(queueId));
|
.filter(queueId => availableQueueIds.has(queueId));
|
||||||
const savedQueueSet = new Set(savedQueueIds);
|
const targetQueueIds = new Set<string>([
|
||||||
const trackedIdsOutsideSavedQueues = useSettingsStore.getState().schedulerActiveDownloadIds
|
...savedQueueIds,
|
||||||
.filter(id => {
|
...effectiveSelectedQueueIds
|
||||||
const queueId = useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID;
|
]);
|
||||||
return !savedQueueSet.has(queueId);
|
const trackedDownloadIds = useSettingsStore.getState().schedulerActiveDownloadIds;
|
||||||
});
|
const downloads = useDownloadStore.getState().downloads;
|
||||||
|
for (const id of trackedDownloadIds) {
|
||||||
|
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||||
|
if (availableQueueIds.has(queueId)) {
|
||||||
|
targetQueueIds.add(queueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const targetQueueList = Array.from(targetQueueIds);
|
||||||
|
const targetQueueSet = new Set(targetQueueList);
|
||||||
|
const trackedIdsOutsideQueues = trackedDownloadIds.filter(id => {
|
||||||
|
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||||
|
return !targetQueueSet.has(queueId);
|
||||||
|
});
|
||||||
|
|
||||||
const counts = await Promise.all(
|
const counts = await Promise.all(
|
||||||
savedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
targetQueueList.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||||
);
|
);
|
||||||
const directPauseResults = await Promise.allSettled(
|
const directPauseResults = await Promise.allSettled(
|
||||||
trackedIdsOutsideSavedQueues.map(id => useDownloadStore.getState().pauseDownload(id))
|
trackedIdsOutsideQueues.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||||
);
|
);
|
||||||
if (!isSchedulerControlCurrent(generation)) return;
|
if (!isSchedulerControlCurrent(generation)) return;
|
||||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
|
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
|
||||||
|
|||||||
+202
-160
@@ -466,6 +466,12 @@ const engineRunId = useRef(0);
|
|||||||
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
|
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
|
||||||
() => String(settings.maxConcurrentDownloads)
|
() => String(settings.maxConcurrentDownloads)
|
||||||
);
|
);
|
||||||
|
const [maxAutomaticRetriesInput, setMaxAutomaticRetriesInput] = useState(
|
||||||
|
() => String(settings.maxAutomaticRetries)
|
||||||
|
);
|
||||||
|
const [minimumNormalDownloadSpeedKiBInput, setMinimumNormalDownloadSpeedKiBInput] = useState(
|
||||||
|
() => String(settings.minimumNormalDownloadSpeedKiB)
|
||||||
|
);
|
||||||
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
||||||
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||||
() => String(settings.torrentMaxOpenFiles)
|
() => String(settings.torrentMaxOpenFiles)
|
||||||
@@ -490,6 +496,14 @@ const engineRunId = useRef(0);
|
|||||||
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
|
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
|
||||||
}, [settings.maxConcurrentDownloads]);
|
}, [settings.maxConcurrentDownloads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxAutomaticRetriesInput(String(settings.maxAutomaticRetries));
|
||||||
|
}, [settings.maxAutomaticRetries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMinimumNormalDownloadSpeedKiBInput(String(settings.minimumNormalDownloadSpeedKiB));
|
||||||
|
}, [settings.minimumNormalDownloadSpeedKiB]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProxyPortInput(String(settings.proxyPort));
|
setProxyPortInput(String(settings.proxyPort));
|
||||||
}, [settings.proxyPort]);
|
}, [settings.proxyPort]);
|
||||||
@@ -1068,14 +1082,24 @@ runEngineChecks(false);
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number" min="0" max="10"
|
type="number" min="0" max="10"
|
||||||
value={settings.maxAutomaticRetries}
|
value={maxAutomaticRetriesInput}
|
||||||
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
onBlur={(e) => {
|
const value = e.target.value;
|
||||||
const val = Number(e.target.value);
|
setMaxAutomaticRetriesInput(value);
|
||||||
if (val < 0) settings.setMaxAutomaticRetries(0);
|
if (value !== '' && Number.isFinite(Number(value))) {
|
||||||
if (val > 10) settings.setMaxAutomaticRetries(10);
|
settings.setMaxAutomaticRetries(Number(value));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => commitBoundedIntegerInput(
|
||||||
|
e.target.value,
|
||||||
|
settings.maxAutomaticRetries,
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
settings.setMaxAutomaticRetries,
|
||||||
|
setMaxAutomaticRetriesInput
|
||||||
|
)}
|
||||||
className="app-control w-24 text-center"
|
className="app-control w-24 text-center"
|
||||||
|
aria-label={t($ => $.settings.downloads.automaticRetries)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mac-settings-row">
|
<div className="mac-settings-row">
|
||||||
@@ -1085,8 +1109,22 @@ runEngineChecks(false);
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number" min="0" max="1048576"
|
type="number" min="0" max="1048576"
|
||||||
value={settings.minimumNormalDownloadSpeedKiB}
|
value={minimumNormalDownloadSpeedKiBInput}
|
||||||
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setMinimumNormalDownloadSpeedKiBInput(value);
|
||||||
|
if (value !== '' && Number.isFinite(Number(value))) {
|
||||||
|
settings.setMinimumNormalDownloadSpeedKiB(Number(value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => commitBoundedIntegerInput(
|
||||||
|
event.target.value,
|
||||||
|
settings.minimumNormalDownloadSpeedKiB,
|
||||||
|
0,
|
||||||
|
1048576,
|
||||||
|
settings.setMinimumNormalDownloadSpeedKiB,
|
||||||
|
setMinimumNormalDownloadSpeedKiBInput
|
||||||
|
)}
|
||||||
className="app-control w-24 text-center"
|
className="app-control w-24 text-center"
|
||||||
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
|
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
|
||||||
/>
|
/>
|
||||||
@@ -1395,92 +1433,162 @@ runEngineChecks(false);
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="network-settings-panel-general" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-general" hidden={networkSection !== 'general'} tabIndex={0}>
|
<div id="network-settings-panel-general" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-general" hidden={networkSection !== 'general'} tabIndex={0}>
|
||||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.proxy)}</h2>
|
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.proxy)}</h2>
|
||||||
<div className="mac-settings-group">
|
<div className="mac-settings-group">
|
||||||
<div className="mac-settings-row settings-network-row settings-choice-row">
|
<div className="mac-settings-row settings-network-row settings-choice-row">
|
||||||
<div className="settings-row-label">
|
<div className="settings-row-label">
|
||||||
<span>{t($ => $.settings.network.mode)}</span>
|
<span>{t($ => $.settings.network.mode)}</span>
|
||||||
<small>{t($ => $.settings.network.modeDescription)}</small>
|
<small>{t($ => $.settings.network.modeDescription)}</small>
|
||||||
|
</div>
|
||||||
|
<div className="settings-radio-group">
|
||||||
|
{[
|
||||||
|
['none', t($ => $.settings.network.noProxy)],
|
||||||
|
['system', t($ => $.settings.network.systemProxy)],
|
||||||
|
['custom', t($ => $.settings.network.customProxy)],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<label key={value}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="proxy-mode"
|
||||||
|
checked={settings.proxyMode === value}
|
||||||
|
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-radio-group">
|
{settings.proxyMode === 'custom' && (
|
||||||
{[
|
<>
|
||||||
['none', t($ => $.settings.network.noProxy)],
|
<div className="mac-settings-row settings-network-row">
|
||||||
['system', t($ => $.settings.network.systemProxy)],
|
<div className="settings-row-label">
|
||||||
['custom', t($ => $.settings.network.customProxy)],
|
<span>{t($ => $.settings.network.proxyHost)}</span>
|
||||||
].map(([value, label]) => (
|
<small>{t($ => $.settings.network.proxyHostDescription)}</small>
|
||||||
<label key={value}>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="text"
|
||||||
name="proxy-mode"
|
value={settings.proxyHost}
|
||||||
checked={settings.proxyMode === value}
|
onChange={(e) => settings.setProxyHost(e.target.value)}
|
||||||
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
|
placeholder={t($ => $.settings.network.proxyHostPlaceholder)}
|
||||||
|
className="app-control settings-network-input font-mono"
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{settings.proxyMode === 'custom' && (
|
|
||||||
<>
|
|
||||||
<div className="mac-settings-row settings-network-row">
|
|
||||||
<div className="settings-row-label">
|
|
||||||
<span>{t($ => $.settings.network.proxyHost)}</span>
|
|
||||||
<small>{t($ => $.settings.network.proxyHostDescription)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mac-settings-row settings-network-row">
|
||||||
|
<div className="settings-row-label">
|
||||||
|
<span>{t($ => $.settings.network.proxyPort)}</span>
|
||||||
|
<small>{t($ => $.settings.network.proxyPortDescription)}</small>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
value={proxyPortInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setProxyPortInput(value);
|
||||||
|
if (value !== '' && Number.isFinite(Number(value))) {
|
||||||
|
settings.setProxyPort(Number(value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => commitBoundedIntegerInput(
|
||||||
|
e.target.value,
|
||||||
|
settings.proxyPort,
|
||||||
|
1,
|
||||||
|
65535,
|
||||||
|
settings.setProxyPort,
|
||||||
|
setProxyPortInput
|
||||||
|
)}
|
||||||
|
className="app-control settings-port-input text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="settings-group-footer">
|
||||||
|
{settings.proxyMode === 'none' && t($ => $.settings.network.noProxyDescription)}
|
||||||
|
{settings.proxyMode === 'system' && t($ => $.settings.network.systemProxyDescription, { platform: platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop' })}
|
||||||
|
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
|
||||||
|
? t($ => $.settings.network.customProxyDescription)
|
||||||
|
: settings.proxyHost
|
||||||
|
? t($ => $.settings.network.invalidCustomProxy)
|
||||||
|
: t($ => $.settings.network.incompleteCustomProxy))}
|
||||||
|
</p>
|
||||||
|
{settings.proxyMode === 'system' && systemProxyStatus !== 'idle' && (
|
||||||
|
<p className="settings-group-footer settings-network-note" role="status">
|
||||||
|
{systemProxyStatus === 'checking' && <RefreshCw size={14} className="animate-spin text-accent shrink-0" aria-hidden="true" />}
|
||||||
|
{systemProxyStatus === 'detected' && <Check size={14} className="text-green-500 shrink-0" aria-hidden="true" />}
|
||||||
|
{systemProxyStatus === 'none' && <Info size={14} className="text-accent shrink-0" aria-hidden="true" />}
|
||||||
|
{systemProxyStatus === 'error' && <AlertCircle size={14} className="text-yellow-500 shrink-0" aria-hidden="true" />}
|
||||||
|
<span>
|
||||||
|
{systemProxyStatus === 'checking' && t($ => $.settings.network.checkingSystemProxy)}
|
||||||
|
{systemProxyStatus === 'detected' && t($ => $.settings.network.detectedSystemProxy)}
|
||||||
|
{systemProxyStatus === 'none' && t($ => $.settings.network.noSystemProxy)}
|
||||||
|
{systemProxyStatus === 'error' && t($ => $.settings.network.systemProxyReadFailed)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.identity)}</h2>
|
||||||
|
<div id="network-settings-group-general-identity" className="mac-settings-group settings-popup-group">
|
||||||
|
<div className="mac-settings-row settings-network-row">
|
||||||
|
<div className="settings-row-label">
|
||||||
|
<span>{t($ => $.settings.network.customUserAgent)}</span>
|
||||||
|
<small>{t($ => $.settings.network.userAgentDescription)}</small>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="settings-combobox"
|
||||||
|
ref={userAgentMenuRef}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||||
|
setIsUserAgentMenuOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.proxyHost}
|
value={settings.customUserAgent}
|
||||||
onChange={(e) => settings.setProxyHost(e.target.value)}
|
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
|
||||||
placeholder={t($ => $.settings.network.proxyHostPlaceholder)}
|
onFocus={() => setIsUserAgentMenuOpen(true)}
|
||||||
|
placeholder={t($ => $.settings.network.userAgentPlaceholder)}
|
||||||
className="app-control settings-network-input font-mono"
|
className="app-control settings-network-input font-mono"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isUserAgentMenuOpen}
|
||||||
|
aria-controls="user-agent-suggestions"
|
||||||
/>
|
/>
|
||||||
|
{isUserAgentMenuOpen && (
|
||||||
|
<div id="user-agent-suggestions" className="settings-combobox-menu" role="listbox">
|
||||||
|
{USER_AGENT_SUGGESTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.label}
|
||||||
|
type="button"
|
||||||
|
className="settings-combobox-option"
|
||||||
|
role="option"
|
||||||
|
aria-selected={settings.customUserAgent === option.value}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
settings.setCustomUserAgent(option.value);
|
||||||
|
setIsUserAgentMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="settings-combobox-value">{option.value}</span>
|
||||||
|
<span className="settings-combobox-meta">{
|
||||||
|
(option.label === 'Chrome (Windows)' ? t($ => $.settings.network.chromeWindows)
|
||||||
|
: option.label === 'Chrome (macOS)' ? t($ => $.settings.network.chromeMacos)
|
||||||
|
: option.label === 'Edge (Windows)' ? t($ => $.settings.network.edgeWindows)
|
||||||
|
: option.label === 'Firefox (Windows)' ? t($ => $.settings.network.firefoxWindows)
|
||||||
|
: option.label === 'Firefox (macOS)' ? t($ => $.settings.network.firefoxMacos)
|
||||||
|
: t($ => $.settings.network.safariMacos))
|
||||||
|
} · {
|
||||||
|
option.detail === 'Windows desktop'
|
||||||
|
? t($ => $.settings.network.windowsDesktop)
|
||||||
|
: t($ => $.settings.network.macosDesktop)
|
||||||
|
}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mac-settings-row settings-network-row">
|
</div>
|
||||||
<div className="settings-row-label">
|
<p className="settings-group-footer">{t($ => $.settings.network.userAgentOverrides)}</p>
|
||||||
<span>{t($ => $.settings.network.proxyPort)}</span>
|
</div>
|
||||||
<small>{t($ => $.settings.network.proxyPortDescription)}</small>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number" min="1" max="65535"
|
|
||||||
value={proxyPortInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setProxyPortInput(value);
|
|
||||||
if (value !== '' && Number.isFinite(Number(value))) {
|
|
||||||
settings.setProxyPort(Number(value));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={(e) => commitBoundedIntegerInput(
|
|
||||||
e.target.value,
|
|
||||||
settings.proxyPort,
|
|
||||||
1,
|
|
||||||
65535,
|
|
||||||
settings.setProxyPort,
|
|
||||||
setProxyPortInput
|
|
||||||
)}
|
|
||||||
className="app-control settings-port-input text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<p className="settings-group-footer">
|
|
||||||
{settings.proxyMode === 'none' && t($ => $.settings.network.noProxyDescription)}
|
|
||||||
{settings.proxyMode === 'system' && t($ => $.settings.network.systemProxyDescription, { platform: platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop' })}
|
|
||||||
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
|
|
||||||
? t($ => $.settings.network.customProxyDescription)
|
|
||||||
: settings.proxyHost
|
|
||||||
? t($ => $.settings.network.invalidCustomProxy)
|
|
||||||
: t($ => $.settings.network.incompleteCustomProxy))}
|
|
||||||
</p>
|
|
||||||
{settings.proxyMode === 'system' && (
|
|
||||||
<p className="settings-group-footer" role="status">
|
|
||||||
{systemProxyStatus === 'checking' && t($ => $.settings.network.checkingSystemProxy)}
|
|
||||||
{systemProxyStatus === 'detected' && t($ => $.settings.network.detectedSystemProxy)}
|
|
||||||
{systemProxyStatus === 'none' && t($ => $.settings.network.noSystemProxy)}
|
|
||||||
{systemProxyStatus === 'error' && t($ => $.settings.network.systemProxyReadFailed)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="network-settings-panel-discovery" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-discovery" hidden={networkSection !== 'discovery'} tabIndex={0}>
|
<div id="network-settings-panel-discovery" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-discovery" hidden={networkSection !== 'discovery'} tabIndex={0}>
|
||||||
@@ -1636,7 +1744,7 @@ runEngineChecks(false);
|
|||||||
value={settings.torrentPeerIdPrefix}
|
value={settings.torrentPeerIdPrefix}
|
||||||
label={t($ => $.settings.network.torrentPeerIdPrefix)}
|
label={t($ => $.settings.network.torrentPeerIdPrefix)}
|
||||||
description={t($ => $.settings.network.torrentPeerIdPrefixDescription)}
|
description={t($ => $.settings.network.torrentPeerIdPrefixDescription)}
|
||||||
placeholder="-FL-1-4-0-"
|
placeholder="-FL-1-4-2-"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
onCommit={settings.setTorrentPeerIdPrefix}
|
onCommit={settings.setTorrentPeerIdPrefix}
|
||||||
onError={showTorrentNetworkInputError}
|
onError={showTorrentNetworkInputError}
|
||||||
@@ -1646,7 +1754,7 @@ runEngineChecks(false);
|
|||||||
value={settings.torrentPeerAgent}
|
value={settings.torrentPeerAgent}
|
||||||
label={t($ => $.settings.network.torrentPeerAgent)}
|
label={t($ => $.settings.network.torrentPeerAgent)}
|
||||||
description={t($ => $.settings.network.torrentPeerAgentDescription)}
|
description={t($ => $.settings.network.torrentPeerAgentDescription)}
|
||||||
placeholder="Firelink/1.4.0"
|
placeholder="Firelink/1.4.2"
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
onCommit={settings.setTorrentPeerAgent}
|
onCommit={settings.setTorrentPeerAgent}
|
||||||
onError={showTorrentNetworkInputError}
|
onError={showTorrentNetworkInputError}
|
||||||
@@ -1762,72 +1870,6 @@ runEngineChecks(false);
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="network-settings-group-general-identity" className="settings-network-panel" role="region" aria-label={t($ => $.settings.network.identity)} hidden={networkSection !== 'general'}>
|
|
||||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.identity)}</h2>
|
|
||||||
<div className="mac-settings-group settings-popup-group">
|
|
||||||
<div className="mac-settings-row settings-network-row">
|
|
||||||
<div className="settings-row-label">
|
|
||||||
<span>{t($ => $.settings.network.customUserAgent)}</span>
|
|
||||||
<small>{t($ => $.settings.network.userAgentDescription)}</small>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="settings-combobox"
|
|
||||||
ref={userAgentMenuRef}
|
|
||||||
onBlur={(event) => {
|
|
||||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
||||||
setIsUserAgentMenuOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.customUserAgent}
|
|
||||||
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
|
|
||||||
onFocus={() => setIsUserAgentMenuOpen(true)}
|
|
||||||
placeholder={t($ => $.settings.network.userAgentPlaceholder)}
|
|
||||||
className="app-control settings-network-input font-mono"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={isUserAgentMenuOpen}
|
|
||||||
aria-controls="user-agent-suggestions"
|
|
||||||
/>
|
|
||||||
{isUserAgentMenuOpen && (
|
|
||||||
<div id="user-agent-suggestions" className="settings-combobox-menu" role="listbox">
|
|
||||||
{USER_AGENT_SUGGESTIONS.map(option => (
|
|
||||||
<button
|
|
||||||
key={option.label}
|
|
||||||
type="button"
|
|
||||||
className="settings-combobox-option"
|
|
||||||
role="option"
|
|
||||||
aria-selected={settings.customUserAgent === option.value}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
settings.setCustomUserAgent(option.value);
|
|
||||||
setIsUserAgentMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="settings-combobox-value">{option.value}</span>
|
|
||||||
<span className="settings-combobox-meta">{
|
|
||||||
(option.label === 'Chrome (Windows)' ? t($ => $.settings.network.chromeWindows)
|
|
||||||
: option.label === 'Chrome (macOS)' ? t($ => $.settings.network.chromeMacos)
|
|
||||||
: option.label === 'Edge (Windows)' ? t($ => $.settings.network.edgeWindows)
|
|
||||||
: option.label === 'Firefox (Windows)' ? t($ => $.settings.network.firefoxWindows)
|
|
||||||
: option.label === 'Firefox (macOS)' ? t($ => $.settings.network.firefoxMacos)
|
|
||||||
: t($ => $.settings.network.safariMacos))
|
|
||||||
} · {
|
|
||||||
option.detail === 'Windows desktop'
|
|
||||||
? t($ => $.settings.network.windowsDesktop)
|
|
||||||
: t($ => $.settings.network.macosDesktop)
|
|
||||||
}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="settings-group-footer">{t($ => $.settings.network.userAgentOverrides)}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2018,7 +2060,7 @@ runEngineChecks(false);
|
|||||||
aria-invalid={Boolean(loginFieldErrors.pattern)}
|
aria-invalid={Boolean(loginFieldErrors.pattern)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{loginFieldErrors.pattern && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.pattern}</p>}
|
{loginFieldErrors.pattern && <p className="text-red-500 text-xs mt-1 col-start-2">{loginFieldErrors.pattern}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
@@ -2034,7 +2076,7 @@ runEngineChecks(false);
|
|||||||
aria-invalid={Boolean(loginFieldErrors.username)}
|
aria-invalid={Boolean(loginFieldErrors.username)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{loginFieldErrors.username && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.username}</p>}
|
{loginFieldErrors.username && <p className="text-red-500 text-xs mt-1 col-start-2">{loginFieldErrors.username}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
@@ -2050,7 +2092,7 @@ runEngineChecks(false);
|
|||||||
aria-invalid={Boolean(loginFieldErrors.password)}
|
aria-invalid={Boolean(loginFieldErrors.password)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{loginFieldErrors.password && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.password}</p>}
|
{loginFieldErrors.password && <p className="text-red-500 text-xs mt-1 col-start-2">{loginFieldErrors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
@@ -2233,7 +2275,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
|||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
||||||
{/* Step 1 */}
|
{/* Step 1 */}
|
||||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between min-h-[190px]">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
|
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
|
||||||
@@ -2260,13 +2302,13 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
|||||||
}}
|
}}
|
||||||
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
|
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw size={11} /> Regenerate
|
<RefreshCw size={11} /> {t($ => $.settings.integrations.regenerateToken)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 2 */}
|
{/* Step 2 */}
|
||||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between min-h-[190px]">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
|
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
|
||||||
@@ -2296,7 +2338,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 3 */}
|
{/* Step 3 */}
|
||||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col h-[190px]">
|
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col min-h-[190px]">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
|
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
|
||||||
<Puzzle size={16} className="text-green-500" />
|
<Puzzle size={16} className="text-green-500" />
|
||||||
|
|||||||
+13
-16
@@ -7,12 +7,11 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useDownloadStore, DownloadCategory, Queue, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { isTransferActiveStatus } from '../utils/downloads';
|
import { isTransferActiveStatus } from '../utils/downloads';
|
||||||
import { canStartDownload } from '../utils/downloadActions';
|
|
||||||
import { clampFloatingPosition } from '../utils/floatingPosition';
|
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -22,10 +21,11 @@ interface SidebarProps {
|
|||||||
selectedFilter: SidebarFilter;
|
selectedFilter: SidebarFilter;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onSelectFilter: (filter: SidebarFilter) => void;
|
onSelectFilter: (filter: SidebarFilter) => void;
|
||||||
|
toggleButtonRef?: React.Ref<HTMLButtonElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||||
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
|
const { selectedFilter, onToggleSidebar, onSelectFilter, toggleButtonRef } = props;
|
||||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
@@ -117,7 +117,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCloseMenu = () => setContextMenu(null);
|
const handleCloseMenu = () => setContextMenu(null);
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') setContextMenu(null);
|
if (event.key === 'Escape' && contextMenuRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setContextMenu(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('click', handleCloseMenu);
|
window.addEventListener('click', handleCloseMenu);
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
@@ -389,6 +393,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={toggleButtonRef}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
onPointerDown={event => event.stopPropagation()}
|
||||||
|
onMouseDown={event => event.stopPropagation()}
|
||||||
onClick={onToggleSidebar ?? toggleSidebar}
|
onClick={onToggleSidebar ?? toggleSidebar}
|
||||||
className="sidebar-toggle-button"
|
className="sidebar-toggle-button"
|
||||||
title={t($ => $.actions.hideSidebar)}
|
title={t($ => $.actions.hideSidebar)}
|
||||||
@@ -525,19 +533,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
const queueId = contextMenu.id;
|
||||||
const credentialMarkedIds = downloads
|
|
||||||
.filter(download =>
|
|
||||||
(download.queueId || MAIN_QUEUE_ID) === queueId
|
|
||||||
&& download.credentialsRequired === true
|
|
||||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
|
||||||
)
|
|
||||||
.map(download => download.id);
|
|
||||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
|
||||||
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void startQueue(queueId, {
|
void startQueue(queueId).catch(error => {
|
||||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
|
||||||
}).catch(error => {
|
|
||||||
addToast({
|
addToast({
|
||||||
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { Maximize2, Minus, X } from 'lucide-react';
|
import { Maximize2, Minus, X } from 'lucide-react';
|
||||||
import type { PointerEvent } from 'react';
|
import type { MouseEvent, PointerEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
|
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
const stopTitlebarDrag = (event: PointerEvent<HTMLElement> | MouseEvent<HTMLElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
|||||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||||
aria-label={t($ => $.window.controls)}
|
aria-label={t($ => $.window.controls)}
|
||||||
role="group"
|
role="group"
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
onPointerDown={stopTitlebarDrag}
|
||||||
|
onMouseDown={stopTitlebarDrag}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const common = {
|
|||||||
maximize: 'Maximize',
|
maximize: 'Maximize',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "Removing…", error: "Removal failed", retry: "Retry removal", pending: "Download removal is pending.", failed: "Close programs using the files, check drive access and permissions, then retry removal." },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: 'Move Up',
|
moveUp: 'Move Up',
|
||||||
moveDown: 'Move Down',
|
moveDown: 'Move Down',
|
||||||
@@ -275,9 +276,6 @@ const common = {
|
|||||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
||||||
credentialsRequired: 'Credentials, cookies, or request headers from the previous session were not saved. Add them in Advanced, or confirm a retry without them.',
|
|
||||||
resumeWithoutCredentialsConfirm: 'This download used credentials, cookies, or request headers that are no longer available. Retry without them? If access is required, the server may reject the request.',
|
|
||||||
retryWithoutCredentials: 'Retry without saved credentials',
|
|
||||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||||
@@ -285,7 +283,8 @@ const common = {
|
|||||||
liveTorrentPeerOptions: 'Live Torrent peer controls',
|
liveTorrentPeerOptions: 'Live Torrent peer controls',
|
||||||
liveTorrentPeerOptionsApply: 'Apply peer controls',
|
liveTorrentPeerOptionsApply: 'Apply peer controls',
|
||||||
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
|
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
|
||||||
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
|
torrentPeerOptionsSavedHint: 'Saved per Torrent. This is the connection cap; 0 means unlimited. Blank uses Aria2’s default of 55 maximum peers, so active downloads often show about 44 connected peers.',
|
||||||
|
torrentPeerSpeedLimitHint: 'This is an aggregate download-speed trigger, not a bandwidth cap. Aria2 temporarily seeks more peers while the Torrent is below this speed; it does not detect or target your internet connection speed. Blank uses Aria2’s 50K default. Values use bytes per second, such as 50K or 35M.',
|
||||||
torrentTrackers: 'Additional Torrent trackers',
|
torrentTrackers: 'Additional Torrent trackers',
|
||||||
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
|
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
|
||||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||||
@@ -712,7 +711,7 @@ const common = {
|
|||||||
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
|
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
|
||||||
torrentMaxPeers: 'Maximum Torrent peers',
|
torrentMaxPeers: 'Maximum Torrent peers',
|
||||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||||
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
|
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 maximum peers, typically about 44 connected while downloading, and a 50K threshold). The threshold is an aggregate-speed trigger, not a bandwidth cap, and does not adapt to your internet speed. 0 peers means unlimited. Speed values use bytes per second, such as 50K or 35M.',
|
||||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||||
@@ -1124,6 +1123,7 @@ const common = {
|
|||||||
tokenCopied: 'Token copied to clipboard!',
|
tokenCopied: 'Token copied to clipboard!',
|
||||||
tokenCopyFailed: 'Could not copy token: {{detail}}',
|
tokenCopyFailed: 'Could not copy token: {{detail}}',
|
||||||
pairingTokenRegenerated: 'Pairing token regenerated',
|
pairingTokenRegenerated: 'Pairing token regenerated',
|
||||||
|
regenerateToken: 'Regenerate Token',
|
||||||
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
|
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
|
||||||
getExtension: 'Get Extension',
|
getExtension: 'Get Extension',
|
||||||
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
|
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const fa = {
|
|||||||
maximize: 'بیشینه کردن',
|
maximize: 'بیشینه کردن',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "در حال حذف…", error: "حذف ناموفق بود", retry: "تلاش دوباره برای حذف", pending: "حذف دانلود در انتظار انجام است.", failed: "برنامههایی را که از فایلها استفاده میکنند ببندید، دسترسی به درایو و مجوزها را بررسی کنید و دوباره حذف کنید." },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: 'انتقال به بالا',
|
moveUp: 'انتقال به بالا',
|
||||||
moveDown: 'انتقال به پایین',
|
moveDown: 'انتقال به پایین',
|
||||||
@@ -275,9 +276,6 @@ const fa = {
|
|||||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||||
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
|
||||||
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
|
||||||
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیرهشده',
|
|
||||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||||
@@ -285,7 +283,8 @@ const fa = {
|
|||||||
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
|
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
|
||||||
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
|
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
|
||||||
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال میشود. برای استفاده از پیشفرض آریا۲ خالی بگذارید.',
|
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال میشود. برای استفاده از پیشفرض آریا۲ خالی بگذارید.',
|
||||||
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره میشود. صفر یعنی نامحدود؛ خالی یعنی پیشفرض آریا۲.',
|
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره میشود. این مقدار سقف اتصال است؛ ۰ یعنی نامحدود. مقدار خالی از پیشفرض آریا۲ یعنی حداکثر ۵۵ همتا استفاده میکند، بنابراین دانلودهای فعال معمولاً حدود ۴۴ همتای متصل نشان میدهند.',
|
||||||
|
torrentPeerSpeedLimitHint: 'این مقدار محرکی بر اساس سرعت کلی دانلود است، نه سقف پهنایباند. آریا۲ وقتی سرعت تورنت کمتر از این مقدار باشد، موقتاً همتاهای بیشتری جستوجو میکند؛ این مقدار سرعت اینترنت شما را تشخیص نمیدهد یا هدف قرار نمیدهد. مقدار خالی از پیشفرض 50K آریا۲ استفاده میکند. مقادیر سرعت بر حسب بایتبرثانیه هستند، مثل 50K یا 35M.',
|
||||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||||
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
|
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
|
||||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||||
@@ -712,7 +711,7 @@ const fa = {
|
|||||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
|
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
|
||||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||||
torrentPeerOptionsHint: 'برای استفاده از پیشفرضهای آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
|
torrentPeerOptionsHint: 'برای استفاده از پیشفرضهای آریا۲ خالی بگذارید (حداکثر ۵۵ همتا، معمولاً حدود ۴۴ همتای متصل هنگام دانلود، و آستانهٔ 50K). آستانه بر اساس سرعت کلی دانلود عمل میکند، نه سقف پهنایباند، و با سرعت اینترنت شما سازگار نمیشود. صفر همتا یعنی نامحدود. مقادیر سرعت بر حسب بایتبرثانیه هستند، مثل 50K یا 35M.',
|
||||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||||
@@ -1124,6 +1123,7 @@ const fa = {
|
|||||||
tokenCopied: 'توکن در کلیپبورد کپی شد!',
|
tokenCopied: 'توکن در کلیپبورد کپی شد!',
|
||||||
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
|
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
|
||||||
pairingTokenRegenerated: 'توکن جفتسازی دوباره ایجاد شد',
|
pairingTokenRegenerated: 'توکن جفتسازی دوباره ایجاد شد',
|
||||||
|
regenerateToken: 'ایجاد دوباره توکن',
|
||||||
regenerateFailed: 'ایجاد دوباره توکن جفتسازی ناموفق بود: {{detail}}',
|
regenerateFailed: 'ایجاد دوباره توکن جفتسازی ناموفق بود: {{detail}}',
|
||||||
getExtension: 'دریافت افزونه',
|
getExtension: 'دریافت افزونه',
|
||||||
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
|
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const he = {
|
|||||||
maximize: 'הגדלה',
|
maximize: 'הגדלה',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "מסיר…", error: "ההסרה נכשלה", retry: "נסה להסיר שוב", pending: "הסרת ההורדה ממתינה לביצוע.", failed: "סגור תוכניות שמשתמשות בקבצים, בדוק גישה לכונן והרשאות ונסה להסיר שוב." },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: 'הזזה למעלה',
|
moveUp: 'הזזה למעלה',
|
||||||
moveDown: 'הזזה למטה',
|
moveDown: 'הזזה למטה',
|
||||||
@@ -275,9 +276,6 @@ const he = {
|
|||||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||||
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
|
||||||
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
|
||||||
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
|
|
||||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||||
@@ -285,7 +283,8 @@ const he = {
|
|||||||
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
|
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
|
||||||
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
|
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
|
||||||
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
|
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
|
||||||
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
|
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. זהו גבול החיבורים; 0 פירושו ללא הגבלה. שדה ריק משתמש בברירת המחדל של Aria2, 55 עמיתים לכל היותר, ולכן הורדות פעילות מציגות בדרך כלל כ-44 עמיתים מחוברים.',
|
||||||
|
torrentPeerSpeedLimitHint: 'זהו טריגר המבוסס על מהירות ההורדה המצטברת, לא מגבלת רוחב פס. Aria2 מחפש זמנית עמיתים נוספים כשהטורנט איטי מהמהירות הזו; הוא אינו מזהה או מכוון למהירות האינטרנט שלך. שדה ריק משתמש בברירת המחדל של Aria2, 50K. ערכי המהירות הם בייטים לשנייה, למשל 50K או 35M.',
|
||||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||||
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
|
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
|
||||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||||
@@ -712,7 +711,7 @@ const he = {
|
|||||||
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
|
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
|
||||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||||
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
|
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (מקסימום 55 עמיתים, בדרך כלל כ-44 מחוברים בזמן ההורדה, וסף 50K). הסף פועל לפי מהירות ההורדה המצטברת, לא כמגבלת רוחב פס, ואינו מתאים את עצמו למהירות האינטרנט שלך. אפס עמיתים פירושו ללא הגבלה. ערכי המהירות הם בייטים לשנייה, למשל 50K או 35M.',
|
||||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||||
@@ -1124,6 +1123,7 @@ const he = {
|
|||||||
tokenCopied: 'האסימון הועתק ללוח!',
|
tokenCopied: 'האסימון הועתק ללוח!',
|
||||||
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
|
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
|
||||||
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
|
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
|
||||||
|
regenerateToken: 'צור אסימון מחדש',
|
||||||
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
|
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
|
||||||
getExtension: 'קבלת התוסף',
|
getExtension: 'קבלת התוסף',
|
||||||
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
|
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const ru = {
|
|||||||
maximize: 'Развернуть',
|
maximize: 'Развернуть',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "Удаление…", error: "Не удалось удалить", retry: "Повторить удаление", pending: "Удаление загрузки ожидает выполнения.", failed: "Закройте программы, использующие файлы, проверьте доступ к диску и разрешения, затем повторите удаление." },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: 'Переместить вверх',
|
moveUp: 'Переместить вверх',
|
||||||
moveDown: 'Переместить вниз',
|
moveDown: 'Переместить вниз',
|
||||||
@@ -275,9 +276,6 @@ const ru = {
|
|||||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||||
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
|
||||||
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
|
||||||
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
|
|
||||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||||
@@ -285,7 +283,8 @@ const ru = {
|
|||||||
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
|
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
|
||||||
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
|
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
|
||||||
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
|
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
|
||||||
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
|
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. Это предел соединений; 0 означает без ограничений. Пустое поле использует значение Aria2 по умолчанию — максимум 55 пиров, поэтому активные загрузки обычно показывают около 44 подключённых пиров.',
|
||||||
|
torrentPeerSpeedLimitHint: 'Это триггер по общей скорости загрузки, а не ограничение пропускной способности. Aria2 временно ищет больше пиров, пока торрент работает медленнее указанной скорости; он не определяет и не настраивается под скорость вашего интернета. Пустое поле использует порог Aria2 по умолчанию — 50K. Значения скорости указываются в байтах в секунду, например 50K или 35M.',
|
||||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||||
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
|
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
|
||||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||||
@@ -712,7 +711,7 @@ const ru = {
|
|||||||
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
|
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
|
||||||
torrentMaxPeers: 'Максимум пиров торрента',
|
torrentMaxPeers: 'Максимум пиров торрента',
|
||||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||||
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
|
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (максимум 55 пиров, обычно около 44 подключённых при загрузке, и порог 50K). Порог зависит от общей скорости загрузки, а не ограничивает пропускную способность и не подстраивается под скорость вашего интернета. 0 пиров означает без ограничений. Значения скорости указываются в байтах в секунду, например 50K или 35M.',
|
||||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||||
@@ -1124,6 +1123,7 @@ const ru = {
|
|||||||
tokenCopied: 'Токен скопирован в буфер обмена!',
|
tokenCopied: 'Токен скопирован в буфер обмена!',
|
||||||
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
|
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
|
||||||
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
|
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
|
||||||
|
regenerateToken: 'Сгенерировать токен заново',
|
||||||
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
|
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
|
||||||
getExtension: 'Получить расширение',
|
getExtension: 'Получить расширение',
|
||||||
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
|
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const uk = {
|
|||||||
maximize: 'Розгорнути',
|
maximize: 'Розгорнути',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "Видалення…", error: "Не вдалося видалити", retry: "Повторити видалення", pending: "Видалення завантаження очікує на виконання.", failed: "Закрийте програми, що використовують файли, перевірте доступ до диска й дозволи та повторіть видалення." },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: 'Перемістити вгору',
|
moveUp: 'Перемістити вгору',
|
||||||
moveDown: 'Перемістити вниз',
|
moveDown: 'Перемістити вниз',
|
||||||
@@ -275,9 +276,6 @@ const uk = {
|
|||||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||||
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
|
||||||
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
|
||||||
retryWithoutCredentials: 'Повторити без збережених даних для входу',
|
|
||||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||||
@@ -285,7 +283,8 @@ const uk = {
|
|||||||
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
|
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
|
||||||
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
|
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
|
||||||
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
|
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
|
||||||
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
|
torrentPeerOptionsSavedHint: 'Зберігається для цього торента. Це межа з’єднань; 0 означає без обмежень. Порожнє поле використовує стандартне значення Aria2 — максимум 55 пірів, тому активні завантаження зазвичай показують близько 44 підключених пірів.',
|
||||||
|
torrentPeerSpeedLimitHint: 'Це тригер за загальною швидкістю завантаження, а не обмеження пропускної здатності. Aria2 тимчасово шукає більше пірів, коли торрент працює повільніше за цю швидкість; він не визначає швидкість вашого інтернету й не підлаштовується під неї. Порожнє поле використовує стандартний поріг Aria2 — 50K. Значення швидкості вказуються в байтах за секунду, наприклад 50K або 35M.',
|
||||||
torrentTrackers: 'Додаткові трекери торрента',
|
torrentTrackers: 'Додаткові трекери торрента',
|
||||||
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
|
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
|
||||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||||
@@ -712,7 +711,7 @@ const uk = {
|
|||||||
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
|
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
|
||||||
torrentMaxPeers: 'Максимум пірів торрента',
|
torrentMaxPeers: 'Максимум пірів торрента',
|
||||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||||
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
|
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (максимум 55 пірів, зазвичай близько 44 підключених під час завантаження, і поріг 50K). Поріг працює за загальною швидкістю завантаження, а не обмежує пропускну здатність і не підлаштовується під швидкість вашого інтернету. 0 пірів означає без обмежень. Значення швидкості вказуються в байтах за секунду, наприклад 50K або 35M.',
|
||||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||||
@@ -1124,6 +1123,7 @@ const uk = {
|
|||||||
tokenCopied: 'Токен скопійовано в буфер обміну!',
|
tokenCopied: 'Токен скопійовано в буфер обміну!',
|
||||||
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
|
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
|
||||||
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
|
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
|
||||||
|
regenerateToken: 'Згенерувати токен наново',
|
||||||
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
|
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
|
||||||
getExtension: 'Отримати розширення',
|
getExtension: 'Отримати розширення',
|
||||||
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
|
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const zhCN = {
|
|||||||
maximize: '最大化',
|
maximize: '最大化',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
|
removal: { removing: "正在移除…", error: "移除失败", retry: "重试移除", pending: "下载移除操作正在等待执行。", failed: "请关闭正在使用文件的程序,检查磁盘访问权限,然后重试移除。" },
|
||||||
actions: {
|
actions: {
|
||||||
moveUp: '上移',
|
moveUp: '上移',
|
||||||
moveDown: '下移',
|
moveDown: '下移',
|
||||||
@@ -275,9 +276,6 @@ const zhCN = {
|
|||||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||||
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
|
||||||
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
|
||||||
retryWithoutCredentials: '不使用已保存凭据重试',
|
|
||||||
liveTorrentUploadLimit: '实时种子上传限速',
|
liveTorrentUploadLimit: '实时种子上传限速',
|
||||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||||
@@ -285,7 +283,8 @@ const zhCN = {
|
|||||||
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
|
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
|
||||||
liveTorrentPeerOptionsApply: '应用节点控制',
|
liveTorrentPeerOptionsApply: '应用节点控制',
|
||||||
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
|
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
|
||||||
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
|
torrentPeerOptionsSavedHint: '按 Torrent 保存。这是连接上限;0 表示不限制。留空使用 Aria2 默认值(最多 55 个节点),因此活动下载通常会显示约 44 个已连接节点。',
|
||||||
|
torrentPeerSpeedLimitHint: '这是基于总下载速度的触发条件,不是带宽上限。当 Torrent 速度低于此值时,Aria2 会暂时寻找更多节点;它不会检测或针对你的互联网速度进行调整。留空使用 Aria2 默认阈值 50K。速度值使用每秒字节数,例如 50K 或 35M。',
|
||||||
torrentTrackers: '其他 Torrent Tracker',
|
torrentTrackers: '其他 Torrent Tracker',
|
||||||
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
|
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
|
||||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||||
@@ -712,7 +711,7 @@ const zhCN = {
|
|||||||
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
|
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
|
||||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||||
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
|
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(最多 55 个节点,下载时通常约 44 个已连接节点,阈值 50K)。该阈值根据总下载速度触发,不是带宽上限,也不会根据互联网速度自动调整。0 个节点表示不限制。速度值使用每秒字节数,例如 50K 或 35M。',
|
||||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||||
@@ -1124,6 +1123,7 @@ const zhCN = {
|
|||||||
tokenCopied: '令牌已复制到剪贴板!',
|
tokenCopied: '令牌已复制到剪贴板!',
|
||||||
tokenCopyFailed: '无法复制令牌:{{detail}}',
|
tokenCopyFailed: '无法复制令牌:{{detail}}',
|
||||||
pairingTokenRegenerated: '配对令牌已重新生成',
|
pairingTokenRegenerated: '配对令牌已重新生成',
|
||||||
|
regenerateToken: '重新生成令牌',
|
||||||
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
|
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
|
||||||
getExtension: '获取扩展',
|
getExtension: '获取扩展',
|
||||||
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
|
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
|
||||||
|
|||||||
+124
-3
@@ -27,6 +27,10 @@
|
|||||||
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
||||||
--surface-overlay: 0 0% 100%;
|
--surface-overlay: 0 0% 100%;
|
||||||
--shadow-color: 220 10% 20% / 0.1;
|
--shadow-color: 220 10% 20% / 0.1;
|
||||||
|
--window-frame-active: 220 12% 30% / 0.22;
|
||||||
|
--window-frame-inactive: 220 10% 30% / 0.08;
|
||||||
|
--window-frame-windows-active: 220 12% 30% / 0.60;
|
||||||
|
--window-frame-windows-inactive: 220 10% 30% / 0.18;
|
||||||
--sidebar-shell-bg: 0 0% 92%;
|
--sidebar-shell-bg: 0 0% 92%;
|
||||||
--sidebar-panel-bg: 0 0% 96%;
|
--sidebar-panel-bg: 0 0% 96%;
|
||||||
--workspace-bg: 0 0% 98%;
|
--workspace-bg: 0 0% 98%;
|
||||||
@@ -73,6 +77,10 @@
|
|||||||
--properties-header-surface: hsl(var(--bg-modal));
|
--properties-header-surface: hsl(var(--bg-modal));
|
||||||
--surface-overlay: 0 0% 100%;
|
--surface-overlay: 0 0% 100%;
|
||||||
--shadow-color: 220 10% 20% / 0.1;
|
--shadow-color: 220 10% 20% / 0.1;
|
||||||
|
--window-frame-active: 220 12% 30% / 0.22;
|
||||||
|
--window-frame-inactive: 220 10% 30% / 0.08;
|
||||||
|
--window-frame-windows-active: 220 12% 30% / 0.60;
|
||||||
|
--window-frame-windows-inactive: 220 10% 30% / 0.18;
|
||||||
--sidebar-shell-bg: 0 0% 92%;
|
--sidebar-shell-bg: 0 0% 92%;
|
||||||
--sidebar-panel-bg: 0 0% 96%;
|
--sidebar-panel-bg: 0 0% 96%;
|
||||||
--workspace-bg: 0 0% 98%;
|
--workspace-bg: 0 0% 98%;
|
||||||
@@ -103,6 +111,10 @@
|
|||||||
--bg-input: 0 0% 16%;
|
--bg-input: 0 0% 16%;
|
||||||
--properties-header-surface: hsl(0 0% 10%);
|
--properties-header-surface: hsl(0 0% 10%);
|
||||||
--shadow-color: 0 0% 0% / 0.30;
|
--shadow-color: 0 0% 0% / 0.30;
|
||||||
|
--window-frame-active: 0 0% 100% / 0.14;
|
||||||
|
--window-frame-inactive: 0 0% 100% / 0.06;
|
||||||
|
--window-frame-windows-active: 0 0% 100% / 0.35;
|
||||||
|
--window-frame-windows-inactive: 0 0% 100% / 0.14;
|
||||||
--status-completed: 136 62% 48%;
|
--status-completed: 136 62% 48%;
|
||||||
--status-paused: 0 0% 56%;
|
--status-paused: 0 0% 56%;
|
||||||
--status-downloading: 211 100% 56%;
|
--status-downloading: 211 100% 56%;
|
||||||
@@ -150,6 +162,10 @@
|
|||||||
--bg-input: 231 15% 20%;
|
--bg-input: 231 15% 20%;
|
||||||
--properties-header-surface: hsl(231 15% 17%);
|
--properties-header-surface: hsl(231 15% 17%);
|
||||||
--shadow-color: 231 20% 8% / 0.35;
|
--shadow-color: 231 20% 8% / 0.35;
|
||||||
|
--window-frame-active: 228 14% 84% / 0.18;
|
||||||
|
--window-frame-inactive: 228 14% 84% / 0.08;
|
||||||
|
--window-frame-windows-active: 228 14% 84% / 0.45;
|
||||||
|
--window-frame-windows-inactive: 228 14% 84% / 0.18;
|
||||||
--status-completed: 135 94% 65%;
|
--status-completed: 135 94% 65%;
|
||||||
--status-paused: 65 92% 76%;
|
--status-paused: 65 92% 76%;
|
||||||
--status-downloading: 191 97% 77%;
|
--status-downloading: 191 97% 77%;
|
||||||
@@ -197,6 +213,10 @@
|
|||||||
--bg-input: 220 16% 24%;
|
--bg-input: 220 16% 24%;
|
||||||
--properties-header-surface: hsl(220 16% 19%);
|
--properties-header-surface: hsl(220 16% 19%);
|
||||||
--shadow-color: 220 25% 10% / 0.34;
|
--shadow-color: 220 25% 10% / 0.34;
|
||||||
|
--window-frame-active: 218 27% 88% / 0.18;
|
||||||
|
--window-frame-inactive: 218 27% 88% / 0.08;
|
||||||
|
--window-frame-windows-active: 218 27% 88% / 0.45;
|
||||||
|
--window-frame-windows-inactive: 218 27% 88% / 0.18;
|
||||||
--status-completed: 92 28% 65%;
|
--status-completed: 92 28% 65%;
|
||||||
--status-paused: 40 71% 73%;
|
--status-paused: 40 71% 73%;
|
||||||
--status-downloading: 193 43% 67%;
|
--status-downloading: 193 43% 67%;
|
||||||
@@ -222,6 +242,14 @@
|
|||||||
--add-shadow: 220 28% 10% / 0.30;
|
--add-shadow: 220 28% 10% / 0.30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Windows cannot safely combine Tao's native undecorated shadow with this
|
||||||
|
per-pixel-transparent rounded surface. Select the stronger renderer-owned
|
||||||
|
contour tokens without increasing the macOS or Linux frame contrast. */
|
||||||
|
html[data-platform="windows"] {
|
||||||
|
--window-frame-active: var(--window-frame-windows-active);
|
||||||
|
--window-frame-inactive: var(--window-frame-windows-inactive);
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-sidebar-bg: hsl(var(--sidebar-bg));
|
--color-sidebar-bg: hsl(var(--sidebar-bg));
|
||||||
--color-sidebar-glass: hsl(var(--sidebar-glass));
|
--color-sidebar-glass: hsl(var(--sidebar-glass));
|
||||||
@@ -318,6 +346,13 @@ html[data-font-family="monospace"] {
|
|||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.is-resizing,
|
body.is-resizing,
|
||||||
@@ -589,9 +624,29 @@ html[data-list-density="relaxed"] {
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid hsl(var(--border-color));
|
border: 1px solid hsl(var(--window-frame-active));
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: var(--properties-body-surface);
|
background: var(--properties-body-surface);
|
||||||
|
transition: border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell[data-window-active="false"] {
|
||||||
|
border-color: hsl(var(--window-frame-inactive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell[data-window-active="false"] .properties-window-titlebar span,
|
||||||
|
.properties-window-shell[data-window-active="false"] .window-controls {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell[data-window-active="false"] .window-controls:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell[data-window-active="false"] .window-controls--style-macos:not(:hover) .window-control {
|
||||||
|
background: hsl(var(--text-primary) / 0.18);
|
||||||
|
border-color: hsl(var(--text-primary) / 0.12);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-titlebar {
|
.properties-window-titlebar {
|
||||||
@@ -609,6 +664,7 @@ html[data-list-density="relaxed"] {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
|
app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-titlebar span {
|
.properties-window-titlebar span {
|
||||||
@@ -2306,9 +2362,58 @@ html[data-list-density="relaxed"] {
|
|||||||
--window-corner-radius: 18px;
|
--window-corner-radius: 18px;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
background: hsl(var(--main-bg));
|
background: hsl(var(--main-bg));
|
||||||
border: 1px solid hsl(var(--border-color));
|
border: 1px solid hsl(var(--window-frame-active));
|
||||||
border-radius: var(--window-corner-radius);
|
border-radius: var(--window-corner-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-window-active="false"] {
|
||||||
|
border-color: hsl(var(--window-frame-inactive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-window-active="false"] .main-titlebar-title,
|
||||||
|
.app-shell[data-window-active="false"] .window-controls {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-window-active="false"] .window-controls:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-window-active="false"] .window-controls--style-macos:not(:hover) .window-control {
|
||||||
|
background: hsl(var(--text-primary) / 0.18);
|
||||||
|
border-color: hsl(var(--text-primary) / 0.12);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-platform="macos"] :is(.app-shell, .properties-window-shell) {
|
||||||
|
border-width: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linux uses an opaque GTK/WebKit surface, so renderer-only curves would
|
||||||
|
expose square native backing pixels. Maximized Windows surfaces likewise
|
||||||
|
need to meet the work area instead of leaving transparent corner cutouts.
|
||||||
|
macOS zoomed windows retain their native rounded AppKit contour. */
|
||||||
|
html[data-platform="linux"] :is(.app-shell, .properties-window-shell),
|
||||||
|
html[data-platform="windows"] :is(.app-shell, .properties-window-shell)[data-window-maximized="true"] {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-platform="windows"] :is(.app-shell, .properties-window-shell)[data-window-maximized="true"] {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.app-shell,
|
||||||
|
.properties-window-shell {
|
||||||
|
border-color: CanvasText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-window-active="false"],
|
||||||
|
.properties-window-shell[data-window-active="false"] {
|
||||||
|
border-color: GrayText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-shell {
|
.app-sidebar-shell {
|
||||||
@@ -2433,6 +2538,7 @@ html[data-list-density="relaxed"] {
|
|||||||
right: auto;
|
right: auto;
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native-decorated windows do not render the custom control rail. Keep the
|
/* Native-decorated windows do not render the custom control rail. Keep the
|
||||||
@@ -2692,6 +2798,7 @@ html[data-list-density="relaxed"] {
|
|||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-secondary));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle-button:hover {
|
.sidebar-toggle-button:hover {
|
||||||
@@ -2822,8 +2929,12 @@ html[data-list-density="relaxed"] {
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-network-section-title:not(:first-child) {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-network-panel .mac-settings-group {
|
.settings-network-panel .mac-settings-group {
|
||||||
margin-bottom: 0;
|
margin-bottom: 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3253,7 +3364,9 @@ html[data-list-density="relaxed"] {
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@@ -3286,8 +3399,12 @@ html[data-list-density="relaxed"] {
|
|||||||
transition:
|
transition:
|
||||||
filter 120ms ease,
|
filter 120ms ease,
|
||||||
color 120ms ease,
|
color 120ms ease,
|
||||||
|
background-color 120ms ease,
|
||||||
|
border-color 120ms ease,
|
||||||
|
box-shadow 120ms ease,
|
||||||
transform 120ms ease;
|
transform 120ms ease;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-control.close {
|
.window-control.close {
|
||||||
@@ -3481,6 +3598,8 @@ html[data-list-density="relaxed"] {
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
border-bottom: 1px solid hsl(var(--border-color));
|
border-bottom: 1px solid hsl(var(--border-color));
|
||||||
background: hsl(var(--statusbar-bg));
|
background: hsl(var(--statusbar-bg));
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-workspace--sidebar-right .main-titlebar {
|
.app-workspace--sidebar-right .main-titlebar {
|
||||||
@@ -3523,6 +3642,7 @@ html[data-list-density="relaxed"] {
|
|||||||
border: 1px solid hsl(var(--border-modal));
|
border: 1px solid hsl(var(--border-modal));
|
||||||
background: hsl(var(--bg-input));
|
background: hsl(var(--bg-input));
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-control-button {
|
.main-control-button {
|
||||||
@@ -3534,6 +3654,7 @@ html[data-list-density="relaxed"] {
|
|||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-secondary));
|
||||||
border-inline-end: 1px solid hsl(var(--border-color));
|
border-inline-end: 1px solid hsl(var(--border-color));
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-control-button svg {
|
.main-control-button svg {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DownloadRemovalJob } from "./bindings/DownloadRemovalJob";
|
||||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||||
import { error as logError } from './utils/logger';
|
import { error as logError } from './utils/logger';
|
||||||
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
@@ -72,6 +73,10 @@ type CommandMap = {
|
|||||||
open_downloaded_file: { args: { path: string }; result: void };
|
open_downloaded_file: { args: { path: string }; result: void };
|
||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||||
|
submit_download_removals: { args: { ids: string[]; deleteAssets: boolean }; result: void };
|
||||||
|
list_download_removals: { args: undefined; result: DownloadRemovalJob[] };
|
||||||
|
resume_download_removals: { args: undefined; result: void };
|
||||||
|
retry_download_removal: { args: { id: string }; result: void };
|
||||||
remove_download: {
|
remove_download: {
|
||||||
args: {
|
args: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -154,6 +159,7 @@ type CommandMap = {
|
|||||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||||
get_supported_media_domains: { args: undefined; result: string[] };
|
get_supported_media_domains: { args: undefined; result: string[] };
|
||||||
|
is_supported_media: { args: { url: string }; result: boolean };
|
||||||
db_save_settings: { args: { data: string }; result: void };
|
db_save_settings: { args: { data: string }; result: void };
|
||||||
db_load_settings: { args: undefined; result: string | null };
|
db_load_settings: { args: undefined; result: string | null };
|
||||||
canonicalize_torrent_network_setting: {
|
canonicalize_torrent_network_setting: {
|
||||||
@@ -212,6 +218,7 @@ export function invokeCommand<K extends CommandName>(
|
|||||||
type EventMap = {
|
type EventMap = {
|
||||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||||
'download-progress': DownloadProgressEvent;
|
'download-progress': DownloadProgressEvent;
|
||||||
|
'download-removal': DownloadRemovalJob;
|
||||||
'download-allocation': DownloadAllocationEvent;
|
'download-allocation': DownloadAllocationEvent;
|
||||||
'download-state': DownloadStateEvent;
|
'download-state': DownloadStateEvent;
|
||||||
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||||
|
|||||||
+45
-32
@@ -13,6 +13,11 @@ import { ToastProvider } from "./contexts/ToastContext";
|
|||||||
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { invokeCommand as invoke } from './ipc';
|
import { invokeCommand as invoke } from './ipc';
|
||||||
|
import { useWindowFocusState } from './utils/windowFocus';
|
||||||
|
import { syncPlatformDatasetFromUserAgent } from './utils/platform';
|
||||||
|
import { useWindowMaximizedState } from './utils/windowMaximized';
|
||||||
|
|
||||||
|
syncPlatformDatasetFromUserAgent(navigator.userAgent);
|
||||||
|
|
||||||
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
|
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
|
||||||
|
|
||||||
@@ -82,39 +87,47 @@ const renderRoot = (RootComponent: ComponentType) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PropertiesStartupFailure = () => (
|
const PropertiesStartupFailure = () => {
|
||||||
<main className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
const isWindowActive = useWindowFocusState();
|
||||||
<p role="alert">Download Properties could not be loaded.</p>
|
const isWindowMaximized = useWindowMaximizedState();
|
||||||
<button
|
return (
|
||||||
type="button"
|
<main data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||||
className="app-button app-button-primary px-3 text-xs"
|
<p role="alert">Download Properties could not be loaded.</p>
|
||||||
onClick={() => {
|
<button
|
||||||
void getCurrentWindow().close().catch(error => {
|
type="button"
|
||||||
console.error('[PropertiesStartupFailure] close failed', error);
|
className="app-button app-button-primary px-3 text-xs"
|
||||||
});
|
onClick={() => {
|
||||||
}}
|
void getCurrentWindow().close().catch(error => {
|
||||||
>
|
console.error('[PropertiesStartupFailure] close failed', error);
|
||||||
Close
|
});
|
||||||
</button>
|
}}
|
||||||
</main>
|
>
|
||||||
);
|
Close
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MainStartupFailure = () => (
|
const MainStartupFailure = () => {
|
||||||
<main className="flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
const isWindowActive = useWindowFocusState();
|
||||||
<p role="alert">Firelink could not be loaded.</p>
|
const isWindowMaximized = useWindowMaximizedState();
|
||||||
<button
|
return (
|
||||||
type="button"
|
<main data-window-active={isWindowActive ? 'true' : 'false'} data-window-maximized={isWindowMaximized ? 'true' : 'false'} className="app-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||||
className="app-button app-button-primary px-3 text-xs"
|
<p role="alert">Firelink could not be loaded.</p>
|
||||||
onClick={() => {
|
<button
|
||||||
void getCurrentWindow().close().catch(error => {
|
type="button"
|
||||||
console.error('[MainStartupFailure] close failed', error);
|
className="app-button app-button-primary px-3 text-xs"
|
||||||
});
|
onClick={() => {
|
||||||
}}
|
void getCurrentWindow().close().catch(error => {
|
||||||
>
|
console.error('[MainStartupFailure] close failed', error);
|
||||||
Close
|
});
|
||||||
</button>
|
}}
|
||||||
</main>
|
>
|
||||||
);
|
Close
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderMainApp = async () => {
|
const renderMainApp = async () => {
|
||||||
if (!rootElement) return;
|
if (!rootElement) return;
|
||||||
|
|||||||
@@ -641,4 +641,56 @@ describe('Properties window bridge', () => {
|
|||||||
expect(assigned).toBe(unlisten);
|
expect(assigned).toBe(unlisten);
|
||||||
expect(unlisten).not.toHaveBeenCalled();
|
expect(unlisten).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strictly validates numeric boundaries, speed limits, and tracker syntax in patch copies', () => {
|
||||||
|
const baseItem = { isTorrent: false, status: 'ready' as const };
|
||||||
|
const torrentItem = { isTorrent: true, status: 'paused' as const };
|
||||||
|
|
||||||
|
// Connections bounds (1 to 16, whole numbers)
|
||||||
|
expect(() => copyEditablePropertiesPatch({ connections: 0 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ connections: 17 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ connections: 1.5 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ connections: Number.NaN }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||||
|
expect(copyEditablePropertiesPatch({ connections: 1 }, baseItem)).toMatchObject({ connections: 1 });
|
||||||
|
expect(copyEditablePropertiesPatch({ connections: 16 }, baseItem)).toMatchObject({ connections: 16 });
|
||||||
|
|
||||||
|
// Torrent max peers bounds (0 to 1000, whole numbers)
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: -1 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 1001 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 10.5 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 0 }, torrentItem)).toMatchObject({ torrentMaxPeers: 0 });
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 1000 }, torrentItem)).toMatchObject({ torrentMaxPeers: 1000 });
|
||||||
|
|
||||||
|
// Speed limit normalization and invalid formats
|
||||||
|
expect(() => copyEditablePropertiesPatch({ speedLimit: 'invalid' }, baseItem)).toThrow('Invalid download speed limit');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ speedLimit: '0M' }, baseItem)).toThrow('Invalid download speed limit');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ speedLimit: '-5M' }, baseItem)).toThrow('Invalid download speed limit');
|
||||||
|
expect(copyEditablePropertiesPatch({ speedLimit: '2M' }, baseItem)).toMatchObject({ speedLimit: '2M' });
|
||||||
|
expect(copyEditablePropertiesPatch({ speedLimit: ' 500K ' }, baseItem)).toMatchObject({ speedLimit: '500K' });
|
||||||
|
expect(copyEditablePropertiesPatch({ speedLimit: '' }, baseItem).speedLimit).toBeUndefined();
|
||||||
|
|
||||||
|
// Torrent seed settings
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: -1 }, torrentItem)).toThrow('Invalid torrentSeedTime');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: Number.NaN }, torrentItem)).toThrow('Invalid torrentSeedTime');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentSeedRatio: -0.1 }, torrentItem)).toThrow('Invalid torrentSeedRatio');
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentSeedTime: 0 }, torrentItem)).toMatchObject({ torrentSeedTime: 0 });
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentSeedRatio: 1.5 }, torrentItem)).toMatchObject({ torrentSeedRatio: 1.5 });
|
||||||
|
|
||||||
|
// Torrent stop timeout
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: -1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: 7 * 24 * 60 * 60 + 1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentStopTimeout: 3600 }, torrentItem)).toMatchObject({ torrentStopTimeout: 3600 });
|
||||||
|
|
||||||
|
// Torrent trackers validation
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'not-a-url' }, torrentItem)).toThrow('Invalid Torrent tracker list');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'ftp://unsupported.tracker/announce' }, torrentItem)).toThrow('Invalid Torrent tracker list');
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentTrackers: 'https://tracker.example/announce' }, torrentItem))
|
||||||
|
.toMatchObject({ torrentTrackers: 'https://tracker.example/announce' });
|
||||||
|
|
||||||
|
// Torrent policies
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentEncryptionPolicy');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ torrentFileAllocation: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentFileAllocation');
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'require-crypto' }, torrentItem)).toMatchObject({ torrentEncryptionPolicy: 'require-crypto' });
|
||||||
|
expect(copyEditablePropertiesPatch({ torrentFileAllocation: 'prealloc' }, torrentItem)).toMatchObject({ torrentFileAllocation: 'prealloc' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
|||||||
'queuePosition',
|
'queuePosition',
|
||||||
'hasBeenDispatched',
|
'hasBeenDispatched',
|
||||||
'lastError',
|
'lastError',
|
||||||
'credentialsRequired',
|
|
||||||
'lastErrorKind',
|
'lastErrorKind',
|
||||||
'lastResolverFallback',
|
'lastResolverFallback',
|
||||||
'lastTry',
|
'lastTry',
|
||||||
@@ -170,6 +169,7 @@ export type PropertiesSnapshotContext = {
|
|||||||
queueName?: string;
|
queueName?: string;
|
||||||
windowChrome?: PropertiesWindowChrome;
|
windowChrome?: PropertiesWindowChrome;
|
||||||
allocationPending?: boolean;
|
allocationPending?: boolean;
|
||||||
|
removalPhase?: "pending" | "running" | "failed" | "completed";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropertiesSnapshot = SafePropertiesFields & {
|
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||||
@@ -177,6 +177,7 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
windowChrome: PropertiesWindowChrome;
|
windowChrome: PropertiesWindowChrome;
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
allocationPending?: boolean;
|
allocationPending?: boolean;
|
||||||
|
removalPhase?: "pending" | "running" | "failed" | "completed";
|
||||||
lastErrorKind?: DownloadErrorKind;
|
lastErrorKind?: DownloadErrorKind;
|
||||||
lastResolverFallback?: boolean;
|
lastResolverFallback?: boolean;
|
||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
@@ -303,8 +304,7 @@ export type PropertiesActionRequest = {
|
|||||||
payload?: PropertiesPatch
|
payload?: PropertiesPatch
|
||||||
| { selectedIndices: number[] | null }
|
| { selectedIndices: number[] | null }
|
||||||
| { limit: string | null }
|
| { limit: string | null }
|
||||||
| { maxPeers: string | null; peerSpeedLimit: string | null }
|
| { maxPeers: string | null; peerSpeedLimit: string | null };
|
||||||
| { resumeWithoutCredentials: boolean };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropertiesActionResult = {
|
export type PropertiesActionResult = {
|
||||||
@@ -413,6 +413,7 @@ const copyWithoutSecrets = (
|
|||||||
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
|
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||||
...(lastErrorKind ? { lastErrorKind } : {}),
|
...(lastErrorKind ? { lastErrorKind } : {}),
|
||||||
...(context?.queueName ? { queueName: context.queueName } : {}),
|
...(context?.queueName ? { queueName: context.queueName } : {}),
|
||||||
|
...(context?.removalPhase ? { removalPhase: context.removalPhase } : {}),
|
||||||
...(context?.allocationPending === true ? { allocationPending: true } : {}),
|
...(context?.allocationPending === true ? { allocationPending: true } : {}),
|
||||||
...(live?.progress ? {
|
...(live?.progress ? {
|
||||||
fraction: live.progress.fraction,
|
fraction: live.progress.fraction,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
|||||||
import { listenEvent as listen } from '../ipc';
|
import { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
import { canStartDownload } from '../utils/downloadActions';
|
|
||||||
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
import i18n from '../i18n';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearDownloadControlIntent,
|
clearDownloadControlIntent,
|
||||||
@@ -535,17 +533,7 @@ const startDownloadListeners = async () => {
|
|||||||
if (event.payload === 'pause-all') {
|
if (event.payload === 'pause-all') {
|
||||||
void mainStore.pauseAll();
|
void mainStore.pauseAll();
|
||||||
} else if (event.payload === 'resume-all') {
|
} else if (event.payload === 'resume-all') {
|
||||||
const credentialMarkedIds = mainStore.downloads
|
void mainStore.startAll();
|
||||||
.filter(download =>
|
|
||||||
download.credentialsRequired === true
|
|
||||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
|
||||||
)
|
|
||||||
.map(download => download.id);
|
|
||||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
|
||||||
&& window.confirm(i18n.t($ => $.properties.resumeWithoutCredentialsConfirm));
|
|
||||||
void mainStore.startAll({
|
|
||||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { commitDownloadState, dispatchItem, flushDownloadPersistence, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimate, initializeDownloadPersistence, MAIN_QUEUE_ID, normalizeCustomProxy, normalizePersistedDownloadProgress, normalizePersistedQueueState, normalizePersistedQueues, useDownloadStore } from './useDownloadStore';
|
import { commitDownloadState, currentDownloadLifecycleGeneration, dispatchItem, flushDownloadPersistence, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimate, initializeDownloadPersistence, MAIN_QUEUE_ID, normalizeCustomProxy, normalizePersistedDownloadProgress, normalizePersistedQueueState, normalizePersistedQueues, resetDownloadStoreModuleStateForTests, useDownloadStore } from './useDownloadStore';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import * as ipc from '../ipc';
|
import * as ipc from '../ipc';
|
||||||
@@ -7,6 +7,7 @@ import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
|
|||||||
|
|
||||||
vi.mock('../ipc', () => ({
|
vi.mock('../ipc', () => ({
|
||||||
invokeCommand: vi.fn(),
|
invokeCommand: vi.fn(),
|
||||||
|
listenEvent: vi.fn().mockResolvedValue(() => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
|
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
|
||||||
@@ -61,6 +62,7 @@ vi.mock('./useSettingsStore', () => ({
|
|||||||
|
|
||||||
describe('useDownloadStore', () => {
|
describe('useDownloadStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
resetDownloadStoreModuleStateForTests();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
proxyMode: 'none',
|
proxyMode: 'none',
|
||||||
@@ -112,6 +114,195 @@ describe('useDownloadStore', () => {
|
|||||||
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('closes confirmation before slow removal and keeps other downloads controllable', async () => {
|
||||||
|
const item = { id: 'slow-remove', url: 'https://example.com/file', fileName: 'file', status: 'paused' as const, fraction: 0, speed: '-', eta: '-', category: 'Other' as const, dateAdded: '2026-09-06', queueId: MAIN_QUEUE_ID };
|
||||||
|
useDownloadStore.setState({ downloads: [item], deleteModalState: { isOpen: true, downloadIds: [item.id] } });
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'submit_download_removals') return blocked;
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
const removing = useDownloadStore.getState().requestRemovals([item.id], true);
|
||||||
|
expect(useDownloadStore.getState().deleteModalState.isOpen).toBe(false);
|
||||||
|
expect(useDownloadStore.getState().downloads).toHaveLength(1);
|
||||||
|
expect(useDownloadStore.getState().removalJobs[item.id].phase).toBe('pending');
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload(item.id)).rejects.toThrow();
|
||||||
|
useDownloadStore.getState().openAddModalWithUrls('https://example.com/other');
|
||||||
|
expect(useDownloadStore.getState().isAddModalOpen).toBe(true);
|
||||||
|
release();
|
||||||
|
await removing;
|
||||||
|
useDownloadStore.getState().applyRemovalJob({ id: item.id, revision: 1, deleteAssets: true, phase: 'completed', error: null });
|
||||||
|
expect(useDownloadStore.getState().downloads).toHaveLength(0);
|
||||||
|
useDownloadStore.getState().updateDownload(item.id, { status: 'downloading' });
|
||||||
|
expect(useDownloadStore.getState().downloads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale removal event replace a newer completion', () => {
|
||||||
|
const completed = { id: 'revision-test', revision: 3, deleteAssets: true, phase: 'completed' as const, error: null };
|
||||||
|
useDownloadStore.getState().applyRemovalJob(completed);
|
||||||
|
useDownloadStore.getState().applyRemovalJob({ ...completed, revision: 2, phase: 'running' });
|
||||||
|
expect(useDownloadStore.getState().removalJobs[completed.id]).toEqual(completed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps partial removal failures visible and does not repeat successful jobs', async () => {
|
||||||
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||||
|
const failed = { id: 'failed-removal', revision: 1, deleteAssets: true, phase: 'failed' as const, error: 'Drive unavailable' };
|
||||||
|
useDownloadStore.getState().applyRemovalJob(failed);
|
||||||
|
await useDownloadStore.getState().requestRemovals([failed.id], true);
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('submit_download_removals', expect.anything());
|
||||||
|
expect(useDownloadStore.getState().removalJobs[failed.id]).toEqual(failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resurrect a row when completion races startup hydration', async () => {
|
||||||
|
const completed = { id: 'hydration-removal', revision: 3, deleteAssets: true, phase: 'completed' as const, error: null };
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'list_download_removals') {
|
||||||
|
useDownloadStore.getState().applyRemovalJob(completed);
|
||||||
|
return [{ ...completed, revision: 1, phase: 'pending' }] as never;
|
||||||
|
}
|
||||||
|
if (command === 'db_get_all_queues') return [] as never;
|
||||||
|
if (command === 'db_get_all_downloads') return [JSON.stringify({
|
||||||
|
id: completed.id, status: 'paused', queueId: MAIN_QUEUE_ID,
|
||||||
|
})] as never;
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
expect(useDownloadStore.getState().removalJobs[completed.id]).toEqual(completed);
|
||||||
|
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trigger persistence or wipe existing downloads when initDB hydrates with completed removal jobs', async () => {
|
||||||
|
const disposePersistence = initializeDownloadPersistence('main');
|
||||||
|
const commitCalls: unknown[] = [];
|
||||||
|
const completed = { id: 'tombstoned-1', revision: 2, deleteAssets: true, phase: 'completed' as const, error: null };
|
||||||
|
const keepDownload = {
|
||||||
|
id: 'keep-1',
|
||||||
|
url: 'https://example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'completed' as const,
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'list_download_removals') {
|
||||||
|
return [completed] as never;
|
||||||
|
}
|
||||||
|
if (command === 'db_get_all_queues') return [] as never;
|
||||||
|
if (command === 'db_get_all_downloads') {
|
||||||
|
return [JSON.stringify(keepDownload)] as never;
|
||||||
|
}
|
||||||
|
if (command === 'db_commit_download_state') {
|
||||||
|
commitCalls.push(command);
|
||||||
|
return undefined as never;
|
||||||
|
}
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
expect(commitCalls).toHaveLength(0);
|
||||||
|
expect(useDownloadStore.getState().downloads).toHaveLength(1);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].id).toBe('keep-1');
|
||||||
|
} finally {
|
||||||
|
disposePersistence();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves store collection reference equality in applyRemovalJob when the job ID is not present', () => {
|
||||||
|
const stateBefore = useDownloadStore.getState();
|
||||||
|
stateBefore.applyRemovalJob({
|
||||||
|
id: 'non-existent-job',
|
||||||
|
revision: 1,
|
||||||
|
deleteAssets: true,
|
||||||
|
phase: 'completed',
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
const stateAfter = useDownloadStore.getState();
|
||||||
|
expect(stateAfter.downloads).toBe(stateBefore.downloads);
|
||||||
|
expect(stateAfter.pendingOrder).toBe(stateBefore.pendingOrder);
|
||||||
|
expect(stateAfter.allocationPendingIds).toBe(stateBefore.allocationPendingIds);
|
||||||
|
expect(stateAfter.backendRegisteredIds).toBe(stateBefore.backendRegisteredIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores premature flushDownloadPersistence before hydration completes', async () => {
|
||||||
|
let commitCalled = false;
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'db_commit_download_state') {
|
||||||
|
commitCalled = true;
|
||||||
|
}
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushDownloadPersistence();
|
||||||
|
expect(commitCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores duplicate identical removal jobs and stale revisions without mutating state', () => {
|
||||||
|
const job = {
|
||||||
|
id: 'job-1',
|
||||||
|
revision: 2,
|
||||||
|
deleteAssets: true,
|
||||||
|
phase: 'failed' as const,
|
||||||
|
error: 'disk error'
|
||||||
|
};
|
||||||
|
useDownloadStore.getState().applyRemovalJob(job);
|
||||||
|
const stateAfterFirst = useDownloadStore.getState();
|
||||||
|
|
||||||
|
// Identical duplicate should be a no-op
|
||||||
|
useDownloadStore.getState().applyRemovalJob(job);
|
||||||
|
const stateAfterDuplicate = useDownloadStore.getState();
|
||||||
|
expect(stateAfterDuplicate.removalJobs).toBe(stateAfterFirst.removalJobs);
|
||||||
|
|
||||||
|
// Stale revision should be ignored
|
||||||
|
useDownloadStore.getState().applyRemovalJob({
|
||||||
|
id: 'job-1',
|
||||||
|
revision: 1,
|
||||||
|
deleteAssets: true,
|
||||||
|
phase: 'running' as const,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
const stateAfterStale = useDownloadStore.getState();
|
||||||
|
expect(stateAfterStale.removalJobs).toBe(stateAfterFirst.removalJobs);
|
||||||
|
expect(stateAfterStale.removalJobs['job-1'].revision).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves newer in-memory removal jobs during initDB', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
removalJobs: {
|
||||||
|
'concurrent-1': {
|
||||||
|
id: 'concurrent-1',
|
||||||
|
revision: 3,
|
||||||
|
deleteAssets: true,
|
||||||
|
phase: 'completed',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'list_download_removals') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'concurrent-1',
|
||||||
|
revision: 1,
|
||||||
|
deleteAssets: true,
|
||||||
|
phase: 'running',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
] as never;
|
||||||
|
}
|
||||||
|
if (command === 'db_get_all_queues') return [] as never;
|
||||||
|
if (command === 'db_get_all_downloads') return [] as never;
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
expect(useDownloadStore.getState().removalJobs['concurrent-1'].revision).toBe(3);
|
||||||
|
expect(useDownloadStore.getState().removalJobs['concurrent-1'].phase).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
||||||
const initialVersion = useDownloadStore.getState().pendingAddRequestVersion;
|
const initialVersion = useDownloadStore.getState().pendingAddRequestVersion;
|
||||||
|
|
||||||
@@ -211,6 +402,54 @@ describe('useDownloadStore', () => {
|
|||||||
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
|
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks a username-only properties change for credential recovery', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'username-only-properties',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'failed',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
}] as any[],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().applyProperties('username-only-properties', {
|
||||||
|
username: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
username: 'alice',
|
||||||
|
credentialsRequired: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the recovery marker when clearing a password leaves a username', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'username-without-password',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'failed',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret',
|
||||||
|
credentialsRequired: false,
|
||||||
|
}] as any[],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().applyProperties('username-without-password', {
|
||||||
|
password: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
username: 'alice',
|
||||||
|
password: undefined,
|
||||||
|
credentialsRequired: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
|
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -283,6 +522,27 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discards legacy cookies and sensitive headers for explicit media and media domains', () => {
|
||||||
|
useDownloadStore.getState().toggleAddModal(false);
|
||||||
|
useDownloadStore.getState().openAddModalWithUrls(
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com',
|
||||||
|
'video.mp4',
|
||||||
|
'Authorization: Bearer secret\nUser-Agent: FirelinkTest',
|
||||||
|
'session=leak',
|
||||||
|
true,
|
||||||
|
[{ url: 'https://www.youtube.com', cookies: 'session=leak' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = useDownloadStore.getState();
|
||||||
|
expect(state.pendingAddCookies).toBe('');
|
||||||
|
const context = state.pendingAddRequestContexts['https://www.youtube.com/watch?v=dQw4w9WgXcQ'];
|
||||||
|
expect(context?.cookies).toBe('');
|
||||||
|
expect(context?.cookieScopes).toBeUndefined();
|
||||||
|
expect(context?.headers).toBe('User-Agent: FirelinkTest');
|
||||||
|
expect(context?.media).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('replaces a paused download URL in place and preserves its progress', async () => {
|
it('replaces a paused download URL in place and preserves its progress', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -777,6 +1037,7 @@ describe('useDownloadStore', () => {
|
|||||||
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') return [];
|
if (cmd === 'db_get_all_downloads') return [];
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
@@ -807,6 +1068,7 @@ describe('useDownloadStore', () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') return [];
|
if (cmd === 'db_get_all_downloads') return [];
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
@@ -821,6 +1083,7 @@ describe('useDownloadStore', () => {
|
|||||||
if (cmd === 'db_get_all_queues') {
|
if (cmd === 'db_get_all_queues') {
|
||||||
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
|
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
|
||||||
}
|
}
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [
|
return [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -855,6 +1118,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('skips malformed persisted download records without blocking startup', async () => {
|
it('skips malformed persisted download records without blocking startup', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [
|
return [
|
||||||
'{not-json',
|
'{not-json',
|
||||||
@@ -884,6 +1148,7 @@ describe('useDownloadStore', () => {
|
|||||||
if (cmd === 'db_get_all_queues') {
|
if (cmd === 'db_get_all_queues') {
|
||||||
return [JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false })];
|
return [JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false })];
|
||||||
}
|
}
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [
|
return [
|
||||||
JSON.stringify({ id: 'active', status: 'downloading', queueId: 'queue-a', queuePosition: 0 }),
|
JSON.stringify({ id: 'active', status: 'downloading', queueId: 'queue-a', queuePosition: 0 }),
|
||||||
@@ -913,6 +1178,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
|
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'stale-media-estimate',
|
id: 'stale-media-estimate',
|
||||||
@@ -1340,6 +1606,7 @@ describe('useDownloadStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
|
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
|
||||||
|
const initialGeneration = currentDownloadLifecycleGeneration('late');
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
{ id: 'late', url: 'http://test', fileName: 'late.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
{ id: 'late', url: 'http://test', fileName: 'late.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||||
@@ -1383,7 +1650,7 @@ describe('useDownloadStore', () => {
|
|||||||
expect(
|
expect(
|
||||||
vi.mocked(ipc.invokeCommand).mock.calls.some(([command, args]) =>
|
vi.mocked(ipc.invokeCommand).mock.calls.some(([command, args]) =>
|
||||||
command === 'remove_download'
|
command === 'remove_download'
|
||||||
&& (args as { expectedLifecycleGeneration?: string })?.expectedLifecycleGeneration === '0'
|
&& (args as { expectedLifecycleGeneration?: string })?.expectedLifecycleGeneration === initialGeneration
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1593,6 +1860,7 @@ describe('useDownloadStore', () => {
|
|||||||
|
|
||||||
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
||||||
let enqueueGeneration: string | undefined;
|
let enqueueGeneration: string | undefined;
|
||||||
|
const initialGeneration = BigInt(currentDownloadLifecycleGeneration('resume-generation'));
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
{ id: 'resume-generation', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
{ id: 'resume-generation', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||||
@@ -1620,7 +1888,7 @@ describe('useDownloadStore', () => {
|
|||||||
id: 'resume-generation',
|
id: 'resume-generation',
|
||||||
queueId: 'MAIN'
|
queueId: 'MAIN'
|
||||||
});
|
});
|
||||||
expect(enqueueGeneration).toBe('1');
|
expect(enqueueGeneration).toBe((initialGeneration + 1n).toString());
|
||||||
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
||||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
||||||
});
|
});
|
||||||
@@ -1760,7 +2028,7 @@ describe('useDownloadStore', () => {
|
|||||||
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
|
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('limits credentialless selected resume to the explicitly approved rows', async () => {
|
it('automatically retries credential-marked rows during a selected start', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
{
|
{
|
||||||
@@ -1809,9 +2077,7 @@ describe('useDownloadStore', () => {
|
|||||||
await expect(useDownloadStore.getState().startSelected([
|
await expect(useDownloadStore.getState().startSelected([
|
||||||
'selected-with-credentials',
|
'selected-with-credentials',
|
||||||
'selected-without-credentials',
|
'selected-without-credentials',
|
||||||
], {
|
])).resolves.toBe(2);
|
||||||
resumeWithoutCredentialsIds: ['selected-without-credentials'],
|
|
||||||
})).resolves.toBe(2);
|
|
||||||
|
|
||||||
const enqueues = vi.mocked(ipc.invokeCommand).mock.calls
|
const enqueues = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
.filter(([command]) => command === 'enqueue_download')
|
.filter(([command]) => command === 'enqueue_download')
|
||||||
@@ -2608,7 +2874,134 @@ describe('useDownloadStore', () => {
|
|||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not resume a paused backend lifecycle without restored credentials', async () => {
|
it('keeps a configured site login available until keychain access is decided', async () => {
|
||||||
|
const setShowKeychainModal = vi.fn();
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...useSettingsStore.getState(),
|
||||||
|
siteLogins: [{ id: 'resume-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||||
|
keychainAccessReady: false,
|
||||||
|
keychainPromptDismissed: false,
|
||||||
|
setShowKeychainModal
|
||||||
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'credential-gated-resume',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
destination: '/tmp',
|
||||||
|
status: 'paused',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
credentialsRequired: true,
|
||||||
|
username: 'user'
|
||||||
|
}] as any[],
|
||||||
|
backendRegisteredIds: new Set(['credential-gated-resume'])
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload('credential-gated-resume'))
|
||||||
|
.resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(setShowKeychainModal).toHaveBeenCalledWith(true);
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('detach_download_for_reconfigure', expect.anything());
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
status: 'paused',
|
||||||
|
credentialsRequired: true,
|
||||||
|
username: 'user'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-enqueues a recovery-marked download so restored keychain credentials reach the backend', async () => {
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...useSettingsStore.getState(),
|
||||||
|
siteLogins: [{ id: 'restored-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||||
|
keychainAccessReady: true,
|
||||||
|
keychainPromptDismissed: false,
|
||||||
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'credential-recovery-requeue',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
destination: '/tmp',
|
||||||
|
status: 'paused',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
credentialsRequired: true,
|
||||||
|
}] as any[],
|
||||||
|
backendRegisteredIds: new Set(['credential-recovery-requeue'])
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'get_keychain_password') return 'secret';
|
||||||
|
if (command === 'enqueue_download') {
|
||||||
|
return { id: 'credential-recovery-requeue', filename: 'file.bin' };
|
||||||
|
}
|
||||||
|
if (command === 'get_pending_order') return ['credential-recovery-requeue'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload('credential-recovery-requeue'))
|
||||||
|
.resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'detach_download_for_reconfigure',
|
||||||
|
{ id: 'credential-recovery-requeue' }
|
||||||
|
);
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'enqueue_download',
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
username: 'user',
|
||||||
|
password: 'secret',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
status: 'queued',
|
||||||
|
credentialsRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not synthesize a site-login username when its password is unavailable', async () => {
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...useSettingsStore.getState(),
|
||||||
|
siteLogins: [{ id: 'dismissed-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||||
|
keychainAccessReady: false,
|
||||||
|
keychainPromptDismissed: true,
|
||||||
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'username-without-password',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
destination: '/tmp',
|
||||||
|
status: 'ready',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
}] as any[],
|
||||||
|
backendRegisteredIds: new Set(),
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'enqueue_download') return { id: 'username-without-password', filename: 'file.bin' };
|
||||||
|
if (command === 'get_pending_order') return ['username-without-password'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(dispatchItem('username-without-password')).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'enqueue_download',
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically retries a paused credential-marked download without saved credentials', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
id: 'credential-resume-gated',
|
id: 'credential-resume-gated',
|
||||||
@@ -2624,25 +3017,46 @@ describe('useDownloadStore', () => {
|
|||||||
backendRegisteredIds: new Set(['credential-resume-gated'])
|
backendRegisteredIds: new Set(['credential-resume-gated'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'enqueue_download') {
|
||||||
|
return { id: 'credential-resume-gated', filename: 'file.bin' };
|
||||||
|
}
|
||||||
|
if (command === 'get_pending_order') return ['credential-resume-gated'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload('credential-resume-gated'))
|
await expect(useDownloadStore.getState().resumeDownload('credential-resume-gated'))
|
||||||
.resolves.toBe(false);
|
.resolves.toBe(true);
|
||||||
|
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||||
'resume_download',
|
'resume_download',
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'enqueue_download',
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
cookies: null,
|
||||||
|
headers: 'Referer: https://example.com/page',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
status: 'queued',
|
||||||
|
credentialsRequired: false,
|
||||||
|
username: undefined,
|
||||||
|
password: undefined,
|
||||||
|
headers: 'Referer: https://example.com/page',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('explicitly requeues a credential-marked download without saved credentials', async () => {
|
it('does not ask for keychain access when automatically retrying a credential-marked download', async () => {
|
||||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
...useSettingsStore.getState(),
|
...useSettingsStore.getState(),
|
||||||
siteLogins: [{
|
siteLogins: [],
|
||||||
id: 'example-login',
|
keychainAccessReady: false,
|
||||||
urlPattern: 'example.com',
|
|
||||||
username: 'alice',
|
|
||||||
}],
|
|
||||||
keychainAccessReady: true,
|
|
||||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -2655,7 +3069,7 @@ describe('useDownloadStore', () => {
|
|||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
credentialsRequired: true,
|
credentialsRequired: true,
|
||||||
hasBeenDispatched: true,
|
hasBeenDispatched: true,
|
||||||
headers: 'Referer: https://example.com/page?session=secret#part\nAuthorization: Bearer secret\nUser-Agent: Browser',
|
headers: 'User-Agent: Browser',
|
||||||
}] as any[],
|
}] as any[],
|
||||||
backendRegisteredIds: new Set(['credentialless-resume'])
|
backendRegisteredIds: new Set(['credentialless-resume'])
|
||||||
});
|
});
|
||||||
@@ -2665,9 +3079,7 @@ describe('useDownloadStore', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-resume', {
|
await expect(useDownloadStore.getState().resumeDownload('credentialless-resume')).resolves.toBe(true);
|
||||||
resumeWithoutCredentials: true
|
|
||||||
})).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||||
@@ -2678,7 +3090,7 @@ describe('useDownloadStore', () => {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
cookies: null,
|
cookies: null,
|
||||||
headers: 'Referer: https://example.com/page\nUser-Agent: Browser',
|
headers: 'User-Agent: Browser',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -2699,7 +3111,7 @@ describe('useDownloadStore', () => {
|
|||||||
category: 'Other',
|
category: 'Other',
|
||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
credentialsRequired: true,
|
credentialsRequired: true,
|
||||||
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
|
headers: 'User-Agent: Browser',
|
||||||
}] as any[],
|
}] as any[],
|
||||||
backendRegisteredIds: new Set(['credentialless-queued-lifecycle'])
|
backendRegisteredIds: new Set(['credentialless-queued-lifecycle'])
|
||||||
});
|
});
|
||||||
@@ -2711,9 +3123,7 @@ describe('useDownloadStore', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-queued-lifecycle', {
|
await expect(useDownloadStore.getState().resumeDownload('credentialless-queued-lifecycle')).resolves.toBe(true);
|
||||||
resumeWithoutCredentials: true
|
|
||||||
})).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
'detach_download_for_reconfigure',
|
'detach_download_for_reconfigure',
|
||||||
@@ -2731,7 +3141,7 @@ describe('useDownloadStore', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps credential recovery available when credentialless detach fails', async () => {
|
it('keeps the recovery marker when automatic credentialless detach fails', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
id: 'credentialless-detach-failure',
|
id: 'credentialless-detach-failure',
|
||||||
@@ -2743,8 +3153,7 @@ describe('useDownloadStore', () => {
|
|||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
credentialsRequired: true,
|
credentialsRequired: true,
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'secret',
|
headers: 'User-Agent: Browser',
|
||||||
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
|
|
||||||
}] as any[],
|
}] as any[],
|
||||||
backendRegisteredIds: new Set(['credentialless-detach-failure'])
|
backendRegisteredIds: new Set(['credentialless-detach-failure'])
|
||||||
});
|
});
|
||||||
@@ -2755,9 +3164,7 @@ describe('useDownloadStore', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-detach-failure', {
|
await expect(useDownloadStore.getState().resumeDownload('credentialless-detach-failure')).resolves.toBe(false);
|
||||||
resumeWithoutCredentials: true
|
|
||||||
})).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
@@ -2765,6 +3172,7 @@ describe('useDownloadStore', () => {
|
|||||||
username: undefined,
|
username: undefined,
|
||||||
password: undefined,
|
password: undefined,
|
||||||
headers: 'User-Agent: Browser',
|
headers: 'User-Agent: Browser',
|
||||||
|
lastError: 'detach unavailable',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2816,7 +3224,54 @@ describe('useDownloadStore', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('durably pauses startup media rows when no recoverable credential source exists', async () => {
|
it('does not strip a configured site login during startup without keychain access', async () => {
|
||||||
|
const disposePersistence = initializeDownloadPersistence('main');
|
||||||
|
const id = 'startup-keychain-gated';
|
||||||
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
|
...useSettingsStore.getState(),
|
||||||
|
siteLogins: [{ id: 'startup-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||||
|
keychainAccessReady: false,
|
||||||
|
keychainPromptDismissed: false
|
||||||
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id,
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
destination: '/tmp',
|
||||||
|
status: 'queued',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
username: 'user',
|
||||||
|
credentialsRequired: true,
|
||||||
|
hasBeenDispatched: true,
|
||||||
|
queueId: MAIN_QUEUE_ID,
|
||||||
|
}] as any[],
|
||||||
|
pendingOrder: [id],
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'get_pending_order') return [id];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await useDownloadStore.getState().resumePendingDownloads();
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
status: 'queued',
|
||||||
|
username: 'user',
|
||||||
|
credentialsRequired: true,
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().pendingOrder).toContain(id);
|
||||||
|
} finally {
|
||||||
|
disposePersistence();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps credentialless startup rows retryable when the proxy is unavailable', async () => {
|
||||||
const disposePersistence = initializeDownloadPersistence('main');
|
const disposePersistence = initializeDownloadPersistence('main');
|
||||||
const id = 'startup-media-credential-block';
|
const id = 'startup-media-credential-block';
|
||||||
const persistedSnapshots: Array<Array<{ id: string; status: string }>> = [];
|
const persistedSnapshots: Array<Array<{ id: string; status: string }>> = [];
|
||||||
@@ -2859,22 +3314,22 @@ describe('useDownloadStore', () => {
|
|||||||
await flushDownloadPersistence();
|
await flushDownloadPersistence();
|
||||||
|
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_system_proxy', expect.anything());
|
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_system_proxy');
|
||||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
id,
|
id,
|
||||||
status: 'paused',
|
status: 'queued',
|
||||||
credentialsRequired: true,
|
credentialsRequired: true,
|
||||||
});
|
});
|
||||||
expect(useDownloadStore.getState().pendingOrder).not.toContain(id);
|
expect(useDownloadStore.getState().pendingOrder).toContain(id);
|
||||||
expect(persistedSnapshots.some(snapshot => snapshot.some(item =>
|
expect(persistedSnapshots.some(snapshot => snapshot.some(item =>
|
||||||
item.id === id && item.status === 'paused'
|
item.id === id && item.status === 'queued'
|
||||||
))).toBe(true);
|
))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
disposePersistence();
|
disposePersistence();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats an invalid media-cookie source as unavailable during recovery', async () => {
|
it('automatically retries media downloads without an unavailable cookie source', async () => {
|
||||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
...useSettingsStore.getState(),
|
...useSettingsStore.getState(),
|
||||||
mediaCookieSource: undefined
|
mediaCookieSource: undefined
|
||||||
@@ -2895,17 +3350,23 @@ describe('useDownloadStore', () => {
|
|||||||
backendRegisteredIds: new Set([id])
|
backendRegisteredIds: new Set([id])
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'enqueue_download') return { id, filename: 'video.mp4' };
|
||||||
|
if (command === 'get_pending_order') return [id];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(true);
|
||||||
|
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
expect(ipc.invokeCommand).toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
status: 'paused',
|
status: 'queued',
|
||||||
credentialsRequired: true
|
credentialsRequired: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies one explicit credentialless approval to queue and global starts', async () => {
|
it('automatically retries credential-marked rows from queue and global starts', async () => {
|
||||||
const ids = ['queue-recovery-approved', 'global-recovery-approved'];
|
const ids = ['queue-recovery-approved', 'global-recovery-approved'];
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: ids.map((id, index) => ({
|
downloads: ids.map((id, index) => ({
|
||||||
@@ -2936,13 +3397,9 @@ describe('useDownloadStore', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().startQueue('recovery-queue-0', {
|
await expect(useDownloadStore.getState().startQueue('recovery-queue-0')).resolves.toEqual([ids[0]]);
|
||||||
resumeWithoutCredentialsIds: [ids[0]]
|
|
||||||
})).resolves.toEqual([ids[0]]);
|
|
||||||
useDownloadStore.getState().updateDownload(ids[0], { status: 'completed' });
|
useDownloadStore.getState().updateDownload(ids[0], { status: 'completed' });
|
||||||
await expect(useDownloadStore.getState().startAll({
|
await expect(useDownloadStore.getState().startAll()).resolves.toBe(1);
|
||||||
resumeWithoutCredentialsIds: [ids[1]]
|
|
||||||
})).resolves.toBe(1);
|
|
||||||
|
|
||||||
const enqueuedItems = vi.mocked(ipc.invokeCommand).mock.calls
|
const enqueuedItems = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
.filter(([command]) => command === 'enqueue_download')
|
.filter(([command]) => command === 'enqueue_download')
|
||||||
@@ -2976,18 +3433,25 @@ describe('useDownloadStore', () => {
|
|||||||
backendRegisteredIds: new Set([id]),
|
backendRegisteredIds: new Set([id]),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'enqueue_download') return { id, filename: 'private.bin' };
|
||||||
|
if (command === 'get_pending_order') return [id];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(true);
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
expect(ipc.invokeCommand).toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
status: 'paused',
|
status: 'queued',
|
||||||
credentialsRequired: true,
|
credentialsRequired: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-failed',
|
id: 'startup-failed',
|
||||||
@@ -3023,6 +3487,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('keeps startup destination permission failures retryable without backend registration', async () => {
|
it('keeps startup destination permission failures retryable without backend registration', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-destination-access',
|
id: 'startup-destination-access',
|
||||||
@@ -3066,6 +3531,7 @@ describe('useDownloadStore', () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation((cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation((cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return Promise.resolve([]) as never;
|
if (cmd === 'db_get_all_queues') return Promise.resolve([]) as never;
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return Promise.resolve([JSON.stringify({
|
return Promise.resolve([JSON.stringify({
|
||||||
id: 'startup-torrent-allocation',
|
id: 'startup-torrent-allocation',
|
||||||
@@ -3121,6 +3587,7 @@ describe('useDownloadStore', () => {
|
|||||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
if (command === 'db_get_all_queues') return [];
|
if (command === 'db_get_all_queues') return [];
|
||||||
|
if (command === 'list_download_removals') return [];
|
||||||
if (command === 'db_get_all_downloads') {
|
if (command === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-proxy-blocked',
|
id: 'startup-proxy-blocked',
|
||||||
@@ -3152,6 +3619,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
|
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-accepted',
|
id: 'startup-accepted',
|
||||||
@@ -3192,6 +3660,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('does not restore a registration after a fast startup terminal event', async () => {
|
it('does not restore a registration after a fast startup terminal event', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-completed',
|
id: 'startup-completed',
|
||||||
@@ -3232,6 +3701,7 @@ describe('useDownloadStore', () => {
|
|||||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-credential-gated',
|
id: 'startup-credential-gated',
|
||||||
@@ -3281,6 +3751,7 @@ describe('useDownloadStore', () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'startup-single-flight',
|
id: 'startup-single-flight',
|
||||||
@@ -3541,6 +4012,7 @@ describe('useDownloadStore', () => {
|
|||||||
it('migrates legacy downloads without queue ids into the main queue', async () => {
|
it('migrates legacy downloads without queue ids into the main queue', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||||
if (cmd === 'db_get_all_downloads') {
|
if (cmd === 'db_get_all_downloads') {
|
||||||
return [JSON.stringify({
|
return [JSON.stringify({
|
||||||
id: 'legacy',
|
id: 'legacy',
|
||||||
@@ -4119,6 +4591,61 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes a browser-local torrent handoff with its managed cache identity', async () => {
|
||||||
|
const torrentPath = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent';
|
||||||
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
|
request_id: 'request-id',
|
||||||
|
urls: [torrentPath],
|
||||||
|
torrent_path: torrentPath,
|
||||||
|
referer: 'https://example.com/page',
|
||||||
|
silent: true,
|
||||||
|
filename: 'sample.torrent',
|
||||||
|
headers: null,
|
||||||
|
cookies: null,
|
||||||
|
cookie_scopes: null,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useDownloadStore.getState();
|
||||||
|
expect(state.pendingAddUrls).toBe(torrentPath);
|
||||||
|
expect(state.pendingAddTorrentUrls).toEqual([torrentPath]);
|
||||||
|
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
torrentPath,
|
||||||
|
torrentCacheId: 'request-id'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a Windows managed torrent path as the request context key', async () => {
|
||||||
|
const torrentPath = 'C:\\Users\\test\\AppData\\Roaming\\Firelink\\torrents\\request-id.torrent';
|
||||||
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
|
request_id: 'request-id',
|
||||||
|
urls: [torrentPath],
|
||||||
|
torrent_path: torrentPath,
|
||||||
|
referer: 'https://example.com/page',
|
||||||
|
silent: true,
|
||||||
|
filename: 'sample.torrent',
|
||||||
|
headers: null,
|
||||||
|
cookies: null,
|
||||||
|
cookie_scopes: null,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useDownloadStore.getState();
|
||||||
|
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
|
||||||
|
torrentPath,
|
||||||
|
torrentCacheId: 'request-id'
|
||||||
|
});
|
||||||
|
expect(state.pendingAddRequestContexts).not.toHaveProperty(`c:${torrentPath.slice(1)}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not reuse stale extension metadata for a later single-link handoff', async () => {
|
it('does not reuse stale extension metadata for a later single-link handoff', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
isAddModalOpen: true,
|
isAddModalOpen: true,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user