mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 09:45:44 +00:00
Compare commits
55 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 | |||
| 639f5bf091 | |||
| 97f15dee37 | |||
| 8d1cde8d2c |
@@ -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
|
||||
|
||||
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:
|
||||
name: Frontend checks
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -18,7 +30,7 @@ jobs:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: node --test scripts/*.node-test.js
|
||||
@@ -27,7 +39,7 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: Desktop checks (${{ matrix.target }})
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -45,11 +57,15 @@ jobs:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Cache Rust dependencies and build targets
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri -> target
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -114,16 +130,103 @@ jobs:
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
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'
|
||||
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 }}
|
||||
- name: Stage and verify engines
|
||||
env:
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||
run: |
|
||||
node scripts/stage-engines.js --target ${{ matrix.target }}
|
||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||
- 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
|
||||
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
|
||||
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
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
cache: npm
|
||||
- name: Verify tagged release version
|
||||
if: github.event_name == 'push' || inputs.publish_release
|
||||
@@ -100,17 +100,74 @@ jobs:
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- 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'
|
||||
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 }}
|
||||
- name: Build package
|
||||
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:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Build Linux native packages
|
||||
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:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
@@ -321,8 +378,12 @@ jobs:
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
with:
|
||||
files: release-assets/**
|
||||
|
||||
@@ -45,7 +45,6 @@ lerna-debug.log*
|
||||
target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/engine-dist/
|
||||
src-tauri/provisioned-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/),
|
||||
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
|
||||
|
||||
- **Torrent downloads**
|
||||
- **BitTorrent downloads and browser handoff**
|
||||
- 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.
|
||||
- Select files, prioritize pieces, preallocate or allocate as needed, verify existing data, remove unselected files safely, and add per-file web seeds.
|
||||
- Manage trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
|
||||
- View file progress, piece availability, connected and listed peers, seeders, upload totals and speed, and the info hash.
|
||||
- Set upload limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data to a new location.
|
||||
- Use a dedicated Torrents category with pause, resume, retry, redownload, and safe cleanup.
|
||||
- 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.
|
||||
- Configure 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.
|
||||
- Set upload and seeding limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data.
|
||||
- Manage Torrents in a dedicated 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**
|
||||
- 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.
|
||||
- Edit supported settings while a transfer is active, including speed, connections, Torrent upload and peer limits, seeding, verification, allocation, and encryption.
|
||||
- Inspect allocation, exact progress, resume failures, destinations, and current diagnostics; copy long URLs or paths and export magnet links where available.
|
||||
- Keep the window size during the app session while the window follows the current theme and locale.
|
||||
- Change supported transfer, Torrent, seeding, verification, allocation, and encryption settings while work is active.
|
||||
- Inspect exact progress, allocation, destinations, resume failures, and diagnostics; copy long URLs or paths and export magnet links.
|
||||
- Keep the Properties window size during the session while theme and locale follow the app.
|
||||
- **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**
|
||||
- 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.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve normal-download recovery across restarts, stale transfers, redirects, mirrors, connection-pool slowdowns, retries, and resume operations without saved credentials.
|
||||
- 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).
|
||||
- Make browser and deep-link inputs arrive in order, keep magnet clipboard handoffs usable, and make 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.
|
||||
- Add clear guidance for the macOS first-launch security warning and safe approval steps, responding to [#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.
|
||||
- Make normal downloads recover more reliably across restarts, redirects, retries, resumed transfers, missing credentials, and connection slowdowns.
|
||||
- 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).
|
||||
- Keep browser and deep-link inputs in order; make magnet clipboard handoffs and Add-window destination and metadata validation clearer.
|
||||
- Make the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior more usable at narrow window sizes.
|
||||
- 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 cross-platform package and release verification.
|
||||
|
||||
### 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).
|
||||
- Prevent late or duplicate lifecycle events from reviving, removing, or misreporting a download after a newer action has already won.
|
||||
- Keep replacement, removal, and pre-admission cleanup from leaving stale queue entries, partial files, or misleading progress behind.
|
||||
- Keep completed, paused, failed, and retrying downloads authoritative while allocation and progress updates arrive asynchronously.
|
||||
- Make scheduled actions, speed limits, logs, persisted settings, and browser credentials safer when several changes happen close together.
|
||||
- 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).
|
||||
- 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).
|
||||
- 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).
|
||||
- Protect persisted downloads during startup and recover schema-v3 records instead of wiping or losing them.
|
||||
- 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
|
||||
|
||||
- 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
-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
|
||||
|
||||
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.
|
||||
|
||||
@@ -99,7 +99,7 @@ Only use these steps for Firelink downloaded from the [official GitHub release p
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
+35
-3
@@ -22,9 +22,20 @@ Firelink never falls back to system-installed media tools.
|
||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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 run build
|
||||
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` 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
|
||||
working directory. The DMG bundler removes the intermediate app directory, so
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -27,9 +29,14 @@ License text: <https://openfontlicense.org/open-font-license-official-text/>
|
||||
- Source: <https://github.com/aria2/aria2>
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a"
|
||||
"version": "2.9.6",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-46-g139afe709a",
|
||||
"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",
|
||||
"sha256": "f966bc2e843bcd680dedd6d1a2c0c895bab859a402c6dd107cbe72a796dfebcf"
|
||||
"version": "9.0.1-26-g5c8e7e2433",
|
||||
"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": "dd232ccf8661f837a1faa5f534a1a0bdbdb25c42afe79391e8345154df78f791"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
|
||||
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||
"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": {
|
||||
@@ -30,21 +40,29 @@
|
||||
"sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530"
|
||||
"version": "2.9.6",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-46-g139afe709a",
|
||||
"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",
|
||||
"sha256": "0814f4491c2673ea505be8fb65a76c2bfabaa5aad8f33d49b1c5b87a2262e8c5"
|
||||
"version": "9.0.1-26-g5c8e7e2433",
|
||||
"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": "e60c4187c792cc35d2558adbae5582c470713f5afed7212999200340f1394f8d"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
|
||||
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
|
||||
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/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.tar.xz",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-10
@@ -10,23 +10,32 @@
|
||||
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"source": "https://github.com/aria2/aria2",
|
||||
"build": "arm64 executable with adjacent aria2-libs",
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"source": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs",
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
},
|
||||
"sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7",
|
||||
"allocationTelemetry": true,
|
||||
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125892-g406c5a37aa",
|
||||
"version": "9.0.1",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "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",
|
||||
"sha256": "734e6b72a0c2d0d5e089b5a0094be74fa058c15158f6b0689207a01fedafd8f5"
|
||||
"build": "Stable GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1787073674_9.0.1/ffmpeg.zip",
|
||||
"sourceSha256": "8287a1b2229e05eb41859f073e18e6c52c60a778f2f5e6881070fe51b79407fe",
|
||||
"sha256": "393e4c395020a1cb7cbd77fbe00599ce69d1c6466fee0dbd59d13f86a81a1611"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.6",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "b5bd08edab254d42d7b05aa5b6cb4c9b8d4dede4975aff76951ce2cce18866fa"
|
||||
"sha256": "b3ac3bd206e48c26026cadd80c1367e96c149f9c66130952382a642b09fa8a71"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
|
||||
Generated
+145
-235
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -18,33 +18,33 @@
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"i18next": "^26.4.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||
"@tauri-apps/plugin-log": "^2.9.1",
|
||||
"@tauri-apps/plugin-notification": "^2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.5",
|
||||
"i18next": "^26.4.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-i18next": "^17.0.13",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.26",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -418,13 +418,6 @@
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"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": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
@@ -997,54 +990,54 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-clipboard-manager": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
|
||||
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.3.tgz",
|
||||
"integrity": "sha512-KnyoTs9gj1yEgDkSPUNjOIOHjJTr5wk8IWcYMOWxYTIJCip6QwlyPW8u2X+6bd6kHM4fAdZNpxoal0gy/TwJbg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz",
|
||||
"integrity": "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.2.tgz",
|
||||
"integrity": "sha512-XXvMSnFiob+G1H+YHCDf+bzWVumseQuEIhzpbOJzevUfL4k0U+sTApZWJHpoLiamggnVPJm5dJ5sDsniRyWxlg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-log": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz",
|
||||
"integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.1.tgz",
|
||||
"integrity": "sha512-8dYNEQOgZcIEqeFtHAsOIGLoptm+j94270Jf2MrGS/zbsYNd4C8DKyOdeYggAyE3ugwr12mTefIfC8lVVEhdlg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.4.0.tgz",
|
||||
"integrity": "sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz",
|
||||
"integrity": "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
@@ -1086,9 +1079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -1416,9 +1409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
||||
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -1490,74 +1466,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"node_modules/@vitest/mocker/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": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"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"
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1569,9 +1497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"version": "10.5.5",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz",
|
||||
"integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1589,8 +1517,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"browserslist": "^4.28.9",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -1606,9 +1534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
||||
"version": "2.11.21",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||
"integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1619,9 +1547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
|
||||
"version": "4.28.9",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1639,11 +1567,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"electron-to-chromium": "^1.5.402",
|
||||
"node-releases": "^2.0.53",
|
||||
"update-browserslist-db": "^1.3.0"
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"electron-to-chromium": "^1.5.420",
|
||||
"node-releases": "^2.0.54",
|
||||
"update-browserslist-db": "^1.3.2"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -1653,9 +1581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001809",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
||||
"integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1683,13 +1611,6 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1707,9 +1628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.406",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz",
|
||||
"integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==",
|
||||
"version": "1.5.422",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
|
||||
"integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1727,9 +1648,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1824,9 +1745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2122,9 +2043,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
|
||||
"integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
|
||||
"integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2158,9 +2079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.53",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
||||
"integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2181,13 +2102,6 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2195,9 +2109,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2207,9 +2121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2226,7 +2140,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2263,9 +2177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.12",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||
"version": "17.0.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
|
||||
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
@@ -2377,11 +2291,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
@@ -2409,16 +2326,6 @@
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
@@ -2455,9 +2362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
|
||||
"integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2833,38 +2740,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
|
||||
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"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",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "6.1.4",
|
||||
"tinyexec": "1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2872,16 +2772,16 @@
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser-preview": "5.0.0",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitest/ui": "5.0.0",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@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": {
|
||||
"version": "2.3.0",
|
||||
"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",
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -24,7 +24,7 @@
|
||||
"desktop"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -40,8 +40,9 @@
|
||||
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
|
||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest"
|
||||
"tauri": "node scripts/tauri-command.js",
|
||||
"test": "vitest",
|
||||
"test:race": "vitest run --repeats 5 src/utils/dockBadge.test.ts src/store/downloadStore.test.ts src/store/useDownloadStore.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -53,29 +54,29 @@
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"i18next": "^26.4.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||
"@tauri-apps/plugin-log": "^2.9.1",
|
||||
"@tauri-apps/plugin-notification": "^2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.5",
|
||||
"i18next": "^26.4.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-i18next": "^17.0.13",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.26",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.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;
|
||||
try {
|
||||
result = await run(command, args);
|
||||
result = await run(command, args, options);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
|
||||
}
|
||||
@@ -130,13 +130,14 @@ async function main() {
|
||||
assertSafeTarget(target);
|
||||
|
||||
// The native-package build has already staged and verified the engines.
|
||||
// Verify once more before creating the AppImage so a failed preparation
|
||||
// cannot produce an artifact that later appears valid only because its
|
||||
// payload is absent.
|
||||
// Verify the immutable provisioned payload once more before creating the
|
||||
// AppImage so a failed preparation cannot produce an artifact that later
|
||||
// appears valid only because its payload is absent.
|
||||
const provisionedRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
await runChecked(
|
||||
process.execPath,
|
||||
['scripts/verify-binaries.js', '--staged', '--target', target],
|
||||
'staged engine verification'
|
||||
['scripts/verify-binaries.js', '--root', provisionedRoot, '--target', target],
|
||||
'provisioned engine verification'
|
||||
);
|
||||
|
||||
if (receivedSignal) {
|
||||
@@ -146,7 +147,9 @@ async function main() {
|
||||
}
|
||||
|
||||
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) {
|
||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||
|
||||
+188
-49
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -9,6 +10,7 @@ const repoRoot = path.resolve(__dirname, '..');
|
||||
const userAgent = 'firelink-update-check';
|
||||
const fetchRetryDelaysMs = [250, 1_000];
|
||||
const fetchTimeoutMs = 30_000;
|
||||
const cargoOutputLimit = 64 * 1024 * 1024;
|
||||
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function httpResponseError(response, url) {
|
||||
@@ -158,32 +160,39 @@ async function latestFfmpegStable() {
|
||||
async function latestMartinRiedlMacArm64Release() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const releaseSection = html.split('Download Release Build')[1] || '';
|
||||
const match =
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
|
||||
return match?.[1];
|
||||
const card = releaseSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||
const version =
|
||||
card.match(/<b>Release:\s*<\/b>\s*([0-9.]+)/)?.[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() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
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;
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
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');
|
||||
if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array');
|
||||
for (const release of releases) {
|
||||
if (release.tag_name === 'latest') continue;
|
||||
const assets = (release.assets || [])
|
||||
.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;
|
||||
return {
|
||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||
@@ -210,6 +219,110 @@ async function latestBtbnFfmpegN81Build() {
|
||||
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) {
|
||||
const entries = Object.entries(outdated);
|
||||
if (!entries.length) {
|
||||
@@ -227,7 +340,14 @@ function sourceEngineVersions(sourceLock) {
|
||||
const rows = [];
|
||||
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
|
||||
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;
|
||||
@@ -237,7 +357,14 @@ function packagedEngineVersions(engineLock) {
|
||||
const rows = [];
|
||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||
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;
|
||||
@@ -276,7 +403,8 @@ function checkRows(
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
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 status = versionOutdated
|
||||
? 'outdated'
|
||||
@@ -288,7 +416,7 @@ function checkRows(
|
||||
if (status !== 'current') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
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;
|
||||
}
|
||||
@@ -301,7 +429,9 @@ async function main() {
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
outdatedCount += printCargoReport(cargoCompatibleUpdates());
|
||||
|
||||
const ffmpegStablePromise = latestFfmpegStable();
|
||||
const providerChecks = [
|
||||
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
|
||||
['Deno latest release', () => githubLatest('denoland/deno')],
|
||||
@@ -309,7 +439,7 @@ async function main() {
|
||||
[
|
||||
'FFmpeg stable release',
|
||||
async () => {
|
||||
const version = await latestFfmpegStable();
|
||||
const version = await ffmpegStablePromise;
|
||||
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
@@ -317,17 +447,15 @@ async function main() {
|
||||
[
|
||||
'Martin Riedl macOS release',
|
||||
async () => {
|
||||
const version = await latestMartinRiedlMacArm64Release();
|
||||
if (!version) throw new Error('Martin Riedl macOS release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
],
|
||||
[
|
||||
'Martin Riedl macOS snapshot',
|
||||
async () => {
|
||||
const build = await latestMartinRiedlMacArm64Snapshot();
|
||||
if (!build?.version || !build.url) {
|
||||
throw new Error('Martin Riedl FFmpeg provider response has no complete macOS arm64 snapshot');
|
||||
const build = await latestMartinRiedlMacArm64Release();
|
||||
const stableVersion = await ffmpegStablePromise;
|
||||
if (
|
||||
!build?.version ||
|
||||
!build.url ||
|
||||
!build.sha256 ||
|
||||
compareVersions(build.version, stableVersion) !== 0
|
||||
) {
|
||||
throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
@@ -335,7 +463,7 @@ async function main() {
|
||||
[
|
||||
'BtbN FFmpeg Windows/Linux build',
|
||||
async () => {
|
||||
const build = await latestBtbnFfmpegN81Build();
|
||||
const build = await latestBtbnFfmpegStableBuild(await ffmpegStablePromise);
|
||||
if (
|
||||
!build?.version ||
|
||||
!build.urls?.windows ||
|
||||
@@ -365,8 +493,8 @@ async function main() {
|
||||
const deno = providerValue(1);
|
||||
const aria2 = providerValue(2);
|
||||
const ffmpeg = providerValue(3);
|
||||
const martinRiedlMacArm64Snapshot = providerValue(5);
|
||||
const btbnFfmpegN81Build = providerValue(6);
|
||||
const martinRiedlMacArm64Release = providerValue(4);
|
||||
const btbnFfmpegStableBuild = providerValue(5);
|
||||
const latestByEngine = {
|
||||
'yt-dlp': ytDlp?.tag_name,
|
||||
deno: deno?.tag_name,
|
||||
@@ -377,17 +505,18 @@ async function main() {
|
||||
const latestUrlsByTargetEngine = {};
|
||||
const latestHashesByTargetEngine = {};
|
||||
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
|
||||
if (btbnFfmpegN81Build?.version && btbnFfmpegN81Build.urls?.windows && btbnFfmpegN81Build.urls?.linux) {
|
||||
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.version;
|
||||
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.version;
|
||||
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.urls.windows;
|
||||
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.urls.linux;
|
||||
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.hashes?.windows;
|
||||
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.hashes?.linux;
|
||||
if (btbnFfmpegStableBuild?.version && btbnFfmpegStableBuild.urls?.windows && btbnFfmpegStableBuild.urls?.linux) {
|
||||
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.urls.windows;
|
||||
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.urls.linux;
|
||||
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.hashes?.windows;
|
||||
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.hashes?.linux;
|
||||
}
|
||||
if (martinRiedlMacArm64Snapshot?.version && martinRiedlMacArm64Snapshot.url) {
|
||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.version;
|
||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.url;
|
||||
if (martinRiedlMacArm64Release?.version && martinRiedlMacArm64Release.url) {
|
||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.version;
|
||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.url;
|
||||
latestHashesByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.sha256;
|
||||
}
|
||||
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
||||
|
||||
@@ -396,8 +525,8 @@ async function main() {
|
||||
console.log(` ${engine}: ${displayVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${displayVersion(btbnFfmpegN81Build?.version)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${displayVersion(martinRiedlMacArm64Snapshot?.version)}`);
|
||||
console.log(` BtbN FFmpeg stable Windows/Linux: ${displayVersion(btbnFfmpegStableBuild?.version)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 stable: ${displayVersion(martinRiedlMacArm64Release?.version)}`);
|
||||
|
||||
const targetSpecificEngines = new Set(['ffmpeg']);
|
||||
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 { 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) {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
@@ -117,3 +146,151 @@ test('npm executable selection uses the Windows command shim when needed', () =>
|
||||
assert.equal(npmExecutable('darwin'), '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 });
|
||||
}
|
||||
});
|
||||
|
||||
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 { promisify } from 'node:util';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
import { buildPayloadProvenance } from './engine-payload-manifest.js';
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
import {
|
||||
promoteDirectory,
|
||||
@@ -12,6 +13,13 @@ import {
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} 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 repoRoot = path.resolve(__dirname, '..');
|
||||
@@ -39,6 +47,15 @@ if (!targetSources) {
|
||||
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 isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
@@ -128,16 +145,7 @@ function writePayloadManifest() {
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
generatedFrom: Object.fromEntries(
|
||||
Object.entries(targetSources).map(([name, source]) => [
|
||||
name,
|
||||
{
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256
|
||||
}
|
||||
])
|
||||
),
|
||||
generatedFrom: buildPayloadProvenance(targetSources),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(payloadDestination, file).split(path.sep).join('/'),
|
||||
@@ -180,15 +188,93 @@ try {
|
||||
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
const aria2Source = targetSources.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();
|
||||
throwIfProvisioningAborted();
|
||||
await promoteDirectory(payloadDestination, destination);
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} 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) {
|
||||
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.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 { execFileSync, spawn } from 'node:child_process';
|
||||
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 arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
@@ -24,12 +33,18 @@ const argumentIndex = process.argv.indexOf('--binary');
|
||||
const binaryPath = path.resolve(
|
||||
argumentIndex >= 0
|
||||
? 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)) {
|
||||
@@ -69,6 +84,14 @@ async function rpc(port, secret, method, params = []) {
|
||||
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) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
@@ -166,11 +189,15 @@ await new Promise((resolve, reject) => {
|
||||
});
|
||||
const contentPort = contentServer.address().port;
|
||||
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)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
@@ -181,6 +208,7 @@ const child = spawn(binaryPath, [
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
@@ -189,17 +217,26 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
|
||||
assertAria2Baseline(version);
|
||||
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`], {
|
||||
'async-dns': 'false',
|
||||
...systemFixtureOptions,
|
||||
out: 'resolver-normal.bin',
|
||||
}]);
|
||||
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
||||
if (uriOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
|
||||
}
|
||||
assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri');
|
||||
|
||||
const torrent = bencode({
|
||||
info: {
|
||||
@@ -210,15 +247,52 @@ try {
|
||||
},
|
||||
}).toString('base64');
|
||||
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||
'async-dns': 'false',
|
||||
...systemFixtureOptions,
|
||||
dir: tempRoot,
|
||||
}]);
|
||||
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
||||
if (torrentOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
|
||||
}
|
||||
assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent');
|
||||
|
||||
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) {
|
||||
const detail = stderr.trim();
|
||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
|
||||
@@ -8,6 +8,12 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
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 arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
@@ -17,7 +23,13 @@ const targetTriple = `${arch}-${platform}`;
|
||||
const argumentIndex = process.argv.indexOf('--binary');
|
||||
const binaryPath = path.resolve(argumentIndex >= 0
|
||||
? 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}`);
|
||||
|
||||
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');
|
||||
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
|
||||
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)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
@@ -320,6 +336,7 @@ const child = spawn(binaryPath, [
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
`--server-stat-if=${serverStatPath}`,
|
||||
`--server-stat-of=${serverStatPath}`,
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
@@ -328,9 +345,16 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'resume.bin', split: '1', continue: 'true',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'cancel.bin', split: '1',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, cancelGid);
|
||||
@@ -380,17 +408,19 @@ try {
|
||||
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
|
||||
`http://127.0.0.1:${fixturePort}/missing`,
|
||||
`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);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== '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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||
@@ -417,6 +447,7 @@ try {
|
||||
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
|
||||
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
|
||||
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'redirect.bin',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'missing.bin', 'max-tries': '1',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'malformed.bin', 'max-tries': '1',
|
||||
}]);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -24,12 +25,16 @@ const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
|
||||
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
||||
: 5000;
|
||||
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, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
FIRELINK_SMOKE_TEST: '1',
|
||||
FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '',
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
||||
GDK_BACKEND: 'x11',
|
||||
},
|
||||
@@ -401,5 +406,7 @@ try {
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
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 { execFileSync, spawn } from 'node:child_process';
|
||||
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 repoRoot = path.resolve(__dirname, '..');
|
||||
@@ -36,7 +42,10 @@ if (!arch || !platform) {
|
||||
const targetTriple = `${arch}-${platform}`;
|
||||
const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`;
|
||||
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();
|
||||
@@ -65,6 +74,14 @@ let signalTerminationRequested = false;
|
||||
|
||||
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) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
@@ -245,9 +262,15 @@ async function listen(server) {
|
||||
|
||||
function daemonEnvironment() {
|
||||
const libraries = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||
return fs.existsSync(libraries)
|
||||
? { ...process.env, OPENSSL_MODULES: libraries }
|
||||
: process.env;
|
||||
if (!fs.existsSync(libraries)) return process.env;
|
||||
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
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 } = {}) {
|
||||
@@ -305,6 +328,7 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
|
||||
'--enable-dht=false',
|
||||
'--enable-peer-exchange=false',
|
||||
'--bt-enable-lpd=false',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
|
||||
@@ -326,15 +350,28 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
|
||||
};
|
||||
activeDaemons.add(daemon);
|
||||
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}`}`);
|
||||
try {
|
||||
await rpc(selectedRpcPort, secret, 'aria2.getVersion');
|
||||
return true;
|
||||
return await rpc(selectedRpcPort, secret, 'aria2.getVersion');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 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;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -720,7 +757,6 @@ async function main() {
|
||||
'bt-metadata-only': 'false',
|
||||
'bt-save-metadata': 'false',
|
||||
'follow-torrent': 'false',
|
||||
'async-dns': 'false',
|
||||
'max-tries': '3',
|
||||
'retry-wait': '2',
|
||||
'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.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['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]);
|
||||
try {
|
||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
|
||||
@@ -796,6 +832,8 @@ async function main() {
|
||||
timeout: '15',
|
||||
'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);
|
||||
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
|
||||
const savedTorrentPaths = fs.readdirSync(probeDir)
|
||||
@@ -841,7 +879,22 @@ async function main() {
|
||||
if (probeRemoved) await waitForRemoved(client, probeGid);
|
||||
fs.rmSync(probeDir, { recursive: true, force: 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', [
|
||||
trackerlessTorrentBytes.toString('base64'),
|
||||
|
||||
+66
-65
@@ -1,33 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 repoRoot = path.resolve(__dirname, '..');
|
||||
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 sourceLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8'));
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
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 target = resolveTargetTriple();
|
||||
const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [
|
||||
repoRoot,
|
||||
path.join(repoRoot, 'src-tauri'),
|
||||
path.join(repoRoot, 'src-tauri', 'engine-dist'),
|
||||
]);
|
||||
const isWindowsTarget = target.includes('windows');
|
||||
const suffix = isWindowsTarget ? '.exe' : '';
|
||||
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
@@ -52,6 +48,15 @@ if (!source) {
|
||||
}
|
||||
|
||||
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) {
|
||||
const name = `${engine}-${target}${suffix}`;
|
||||
const expected = targetLock.engines?.[engine]?.sha256;
|
||||
@@ -75,58 +80,54 @@ if (targetLock) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const manifestPath = path.join(source, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`No committed lock or payload manifest exists for ${target}.`);
|
||||
const sourceTargetLock = sourceLock.targets?.[target];
|
||||
if (!sourceTargetLock) {
|
||||
console.error(`No source lock exists for the provisioned engine target ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.target !== target) {
|
||||
console.error(`Payload manifest target mismatch: ${manifest.target}`);
|
||||
try {
|
||||
const manifest = readAndValidatePayloadManifest(source, sourceTargetLock, target);
|
||||
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
|
||||
assertAria2RouteSource(manifest.generatedFrom.aria2c, target);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
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) {
|
||||
console.error(`Payload manifest mismatch: ${relative}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
|
||||
const destination = path.join(outputRoot, target);
|
||||
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'],
|
||||
}).map(file => path.relative(source, file).split(path.sep).join('/'));
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
console.error(`Payload contains files not covered by manifest for ${target}.`);
|
||||
process.exit(1);
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
fs.cpSync(sourceDir, path.join(temporaryDestination, runtimeDir), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destination = path.join(outputRoot, target);
|
||||
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);
|
||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||
if (fs.existsSync(payloadManifest)) {
|
||||
fs.copyFileSync(payloadManifest, path.join(temporaryDestination, 'payload-manifest.json'));
|
||||
}
|
||||
await promoteDirectory(temporaryDestination, destination);
|
||||
} finally {
|
||||
await removePathWithRetry(temporaryRoot);
|
||||
}
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
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}`);
|
||||
console.log(`Staged Firelink engines for ${target} from ${source} into ${destination}`);
|
||||
|
||||
@@ -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 { execFileSync, spawn } from 'node:child_process';
|
||||
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 __dirname = path.dirname(__filename);
|
||||
@@ -29,9 +40,7 @@ if (!currentArch || !currentPlatform) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetTriple = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| `${currentArch}-${currentPlatform}`;
|
||||
const targetTriple = resolveTargetTriple();
|
||||
const hostTriple = `${currentArch}-${currentPlatform}`;
|
||||
const canExecuteTarget = targetTriple === hostTriple;
|
||||
const isWindows = targetTriple.includes('windows');
|
||||
@@ -41,6 +50,10 @@ const ext = isWindows ? '.exe' : '';
|
||||
const suffix = `-${targetTriple}${ext}`;
|
||||
|
||||
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');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
@@ -81,7 +94,7 @@ function findEngineRoot(root) {
|
||||
|
||||
const configuredRoot = argValue('--root')
|
||||
|| (process.argv.includes('--staged')
|
||||
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
||||
? path.join(resolveOutputRoot(), targetTriple)
|
||||
: searchRoot
|
||||
? findEngineRoot(searchRoot)
|
||||
: null);
|
||||
@@ -89,6 +102,8 @@ const binariesDir = configuredRoot
|
||||
? path.resolve(configuredRoot)
|
||||
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
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_STDERR = [
|
||||
@@ -109,6 +124,23 @@ function 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) {
|
||||
if (!fs.existsSync(root)) {
|
||||
return;
|
||||
@@ -152,9 +184,13 @@ function engineEnv(engine) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
return {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: modulesDir,
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${modulesDir}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,6 +502,7 @@ if (canExecuteTarget) {
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
@@ -560,6 +597,8 @@ if (canExecuteTarget) {
|
||||
try {
|
||||
const resp = JSON.parse(result.data);
|
||||
if (resp?.result?.version) {
|
||||
assertAria2Baseline(resp.result);
|
||||
assertAria2AllocationCapabilities(resp.result);
|
||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||
} else {
|
||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||
|
||||
@@ -12,12 +12,20 @@ function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function exactVersionTag(extensionRoot, expectedTag) {
|
||||
export function exactVersionTag(extensionRoot, expectedTag) {
|
||||
try {
|
||||
const tags = execFileSync(
|
||||
'git',
|
||||
['-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/)
|
||||
.map(tag => tag.trim())
|
||||
|
||||
@@ -3,7 +3,8 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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
+566
-528
File diff suppressed because it is too large
Load Diff
+21
-18
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.4.0"
|
||||
version = "1.4.2"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -24,48 +24,51 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-shell = "2.3.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
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"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
sysinfo = "0.39.6"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
sha1 = "0.11"
|
||||
base64 = { version = "0.23.1", default-features = false, features = ["std"] }
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.19"
|
||||
thiserror = "2.0.20"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
keepawake = "0.6.1"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.33", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
log = "0.4.34"
|
||||
tauri-plugin-log = "2.9.1"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
keyring-core = "1.0.0"
|
||||
[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"
|
||||
unicode-normalization = "0.1.25"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
[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() {
|
||||
std::fs::create_dir_all("engine-dist")
|
||||
.expect("failed to create generated engine resource directory");
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"clipboard-manager:allow-read-text"
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "properties-window",
|
||||
"description": "Minimal capability for Firelink Properties windows",
|
||||
"windows": ["properties-*"],
|
||||
"permissions": [
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"log:default"
|
||||
]
|
||||
}
|
||||
@@ -4,10 +4,12 @@ use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_in_file_manager(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let primary = authorize_download_path(&app_handle, &path)?;
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let primary = authorize_reveal_path(&app_handle, &path)?;
|
||||
let path = existing_download_asset(&primary).ok_or_else(|| {
|
||||
format!(
|
||||
"Downloaded file or partial file is missing: {}",
|
||||
@@ -29,9 +31,11 @@ pub async fn reveal_in_file_manager(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_downloaded_file(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let path = authorize_download_path(&app_handle, &path)?;
|
||||
if !path.exists() {
|
||||
return Err(format!("Downloaded file is missing: {}", path.display()));
|
||||
@@ -56,18 +60,37 @@ fn authorize_download_path(
|
||||
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
|
||||
}
|
||||
|
||||
fn authorize_reveal_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
authorize_exact_path_with_directory(
|
||||
Path::new(requested),
|
||||
&known_download_paths(app_handle)?,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
crate::download_ownership::known_primary_paths(app_handle)
|
||||
}
|
||||
|
||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
authorize_exact_path_with_directory(requested, allowed_paths, false)
|
||||
}
|
||||
|
||||
fn authorize_exact_path_with_directory(
|
||||
requested: &Path,
|
||||
allowed_paths: &[PathBuf],
|
||||
allow_directory: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symlink_component(requested) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||
if let Ok(metadata) = std::fs::metadata(&requested) {
|
||||
if !metadata.is_file() {
|
||||
if !metadata.is_file() && !(allow_directory && metadata.is_dir()) {
|
||||
return Err("Download path is not a file".to_string());
|
||||
}
|
||||
}
|
||||
@@ -140,8 +163,9 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
std::fs::symlink_metadata(candidate)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
|
||||
(metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2658
-71
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use tauri::Manager;
|
||||
struct DownloadOwnershipRecord {
|
||||
id: String,
|
||||
primary_path: String,
|
||||
owned_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn canonical_download_filename(filename: &str) -> String {
|
||||
@@ -90,8 +91,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
pub fn expected_primary_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
pub fn expected_primary_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
destination: &str,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
@@ -109,21 +110,138 @@ pub fn expected_primary_path(
|
||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||
}
|
||||
|
||||
pub fn register_expected(
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
destination: &str,
|
||||
filename: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = expected_primary_path(app_handle, destination, filename)?;
|
||||
set_primary_path(app_handle, id, &path)
|
||||
}
|
||||
|
||||
pub fn set_primary_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
pub fn set_primary_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
set_owned_paths(app_handle, id, &[path.to_path_buf()])
|
||||
}
|
||||
|
||||
pub fn set_owned_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
let primary = paths
|
||||
.first()
|
||||
.ok_or_else(|| "Download ownership requires at least one path".to_string())?;
|
||||
set_owned_paths_with_primary(app_handle, id, primary, paths)
|
||||
}
|
||||
|
||||
pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
primary: &Path,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
if paths.is_empty() {
|
||||
return Err("Download ownership requires at least one path".to_string());
|
||||
}
|
||||
|
||||
let canonical_primary = canonical_owned_path(app_handle, primary)?;
|
||||
let mut canonical_paths = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
return Err("Download ownership file path is a directory".to_string());
|
||||
}
|
||||
let canonical_path = canonical_owned_path(app_handle, path)?;
|
||||
if !canonical_paths
|
||||
.iter()
|
||||
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
|
||||
{
|
||||
canonical_paths.push(canonical_path);
|
||||
}
|
||||
}
|
||||
|
||||
let path_strings = canonical_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership_paths(
|
||||
&connection,
|
||||
id,
|
||||
&canonical_primary.to_string_lossy(),
|
||||
&path_strings,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_owned_paths_with_primary_and_removal<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
primary: &Path,
|
||||
paths: &[PathBuf],
|
||||
removal_paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
if paths.is_empty() {
|
||||
return Err("Download ownership requires at least one path".to_string());
|
||||
}
|
||||
|
||||
let canonical_primary = canonical_owned_path(app_handle, primary)?;
|
||||
let canonical_paths = canonical_file_paths(app_handle, paths)?;
|
||||
let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?;
|
||||
let mut current_paths = owned_paths_for_id(app_handle, id)?;
|
||||
if let Some(primary) = primary_path_for_id(app_handle, id)? {
|
||||
current_paths.push(primary);
|
||||
}
|
||||
let known_paths = known_primary_paths(app_handle)?;
|
||||
if canonical_removal_paths.iter().any(|candidate| {
|
||||
known_paths.iter().any(|known| {
|
||||
crate::platform::paths_equal(candidate, known)
|
||||
&& !current_paths
|
||||
.iter()
|
||||
.any(|current| crate::platform::paths_equal(candidate, current))
|
||||
})
|
||||
}) {
|
||||
return Err(
|
||||
"Torrent removal would delete a file owned by another Firelink download".to_string(),
|
||||
);
|
||||
}
|
||||
let path_strings = canonical_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let removal_strings = canonical_removal_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership_and_removal_paths(
|
||||
&connection,
|
||||
id,
|
||||
&canonical_primary.to_string_lossy(),
|
||||
&path_strings,
|
||||
&removal_strings,
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_file_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut canonical_paths = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
return Err("Download ownership file path is a directory".to_string());
|
||||
}
|
||||
let canonical_path = canonical_owned_path(app_handle, path)?;
|
||||
if !canonical_paths
|
||||
.iter()
|
||||
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
|
||||
{
|
||||
canonical_paths.push(canonical_path);
|
||||
}
|
||||
}
|
||||
Ok(canonical_paths)
|
||||
}
|
||||
|
||||
fn canonical_owned_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
path: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
if !path.is_absolute() {
|
||||
return Err("Download ownership path must be absolute".to_string());
|
||||
}
|
||||
@@ -140,18 +258,61 @@ pub fn set_primary_path(
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
if !crate::is_safe_path(&canonical_path, app_handle) {
|
||||
return Err("Download ownership path is outside an allowed download location".to_string());
|
||||
}
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
pub fn remove<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<(), String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::remove_ownership(&connection, id)
|
||||
}
|
||||
|
||||
pub fn clear_torrent_removal_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<(), String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::remove_torrent_removal_paths(&connection, id)
|
||||
}
|
||||
|
||||
/// Clear a Torrent removal reservation only after every reserved path is
|
||||
/// absent. The reservation protects paths that Aria2 may still remove after
|
||||
/// a terminal event has been observed; callers must not release it merely
|
||||
/// because the daemon reported completion or failure.
|
||||
pub fn clear_torrent_removal_paths_if_absent<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<bool, String> {
|
||||
let paths = torrent_removal_paths_for_id(app_handle, id)?;
|
||||
if paths.iter().any(|path| {
|
||||
!matches!(
|
||||
std::fs::symlink_metadata(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
}) {
|
||||
return Ok(false);
|
||||
}
|
||||
clear_torrent_removal_paths(app_handle, id)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn torrent_removal_paths_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_torrent_removal_paths(&connection, id)
|
||||
.map(|paths| paths.into_iter().map(PathBuf::from).collect())
|
||||
}
|
||||
|
||||
pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
@@ -162,16 +323,50 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||
.map(|record| PathBuf::from(record.primary_path)))
|
||||
}
|
||||
|
||||
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
pub fn owned_paths_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
Ok(load_records(app_handle)?
|
||||
.into_iter()
|
||||
.find(|record| record.id == id)
|
||||
.map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn known_primary_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||
.into_iter()
|
||||
.map(|record| PathBuf::from(record.primary_path))
|
||||
.flat_map(|record| {
|
||||
std::iter::once(PathBuf::from(record.primary_path)).chain(
|
||||
record.owned_paths.into_iter().map(PathBuf::from),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// One-time compatibility for downloads created before the backend-owned
|
||||
// registry existed. This imports the exact persisted queue path only.
|
||||
for path in legacy_download_queue_paths(app_handle)? {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
|
||||
for path in removal_paths.into_iter().map(PathBuf::from) {
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| crate::platform::paths_equal(existing, &path))
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(connection);
|
||||
|
||||
// Compatibility for downloads created before the backend-owned registry
|
||||
// existed. Import only the exact persisted queue paths.
|
||||
for (_, path) in legacy_download_queue_path_records(app_handle)? {
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| crate::platform::paths_equal(existing, &path))
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
@@ -179,18 +374,83 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Return the Firelink download that owns an exact output path, if any.
|
||||
///
|
||||
/// This is intentionally based on the persisted ownership registry rather
|
||||
/// than on the visible download list. The renderer can be stale while a
|
||||
/// queued/native lifecycle is being admitted, so duplicate replacement must
|
||||
/// make this decision at the native boundary.
|
||||
pub fn owner_for_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
path: &Path,
|
||||
) -> Result<Option<String>, String> {
|
||||
let canonical = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download target could not be canonicalized".to_string())?;
|
||||
let mut owners = Vec::new();
|
||||
for record in load_records(app_handle)? {
|
||||
let primary = PathBuf::from(&record.primary_path);
|
||||
if crate::platform::paths_equal(&primary, &canonical)
|
||||
|| record
|
||||
.owned_paths
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.any(|owned| crate::platform::paths_equal(&owned, &canonical))
|
||||
{
|
||||
owners.push(record.id);
|
||||
}
|
||||
}
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
for (id, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
|
||||
if removal_paths
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.any(|removal| crate::platform::paths_equal(&removal, &canonical))
|
||||
&& !owners.contains(&id)
|
||||
{
|
||||
owners.push(id);
|
||||
}
|
||||
}
|
||||
drop(connection);
|
||||
|
||||
// Older rows may predate the ownership registry. They still represent
|
||||
// Firelink-owned targets and must not be downgraded to unmanaged disk
|
||||
// files merely because their migration record is absent.
|
||||
for (id, legacy_path) in legacy_download_queue_path_records(app_handle)? {
|
||||
if crate::platform::paths_equal(&legacy_path, &canonical) && !owners.contains(&id) {
|
||||
owners.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
match owners.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(owners.pop()),
|
||||
_ => Err(format!(
|
||||
"Download target is claimed by multiple Firelink downloads: {}",
|
||||
owners.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<Vec<DownloadOwnershipRecord>, String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_ownership(&connection).map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path })
|
||||
.map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
|
||||
id,
|
||||
primary_path,
|
||||
owned_paths,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
fn legacy_download_queue_path_records<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
let settings = crate::settings::load_settings(app_handle).ok();
|
||||
|
||||
let downloads = {
|
||||
@@ -199,7 +459,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
let mut paths: Vec<(String, PathBuf)> = Vec::new();
|
||||
for download in downloads {
|
||||
let category = format!("{:?}", download.category);
|
||||
let mut destinations = Vec::new();
|
||||
@@ -258,8 +518,10 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
|
||||
for destination in destinations {
|
||||
if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
if !paths.iter().any(|(id, existing)| {
|
||||
id == &download.id && crate::platform::paths_equal(existing, &path)
|
||||
}) {
|
||||
paths.push((download.id.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-10
@@ -8,10 +8,27 @@ pub fn resolve_bundled_binary_path(
|
||||
let binary_name = crate::platform::engine_binary_name(engine);
|
||||
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() {
|
||||
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
@@ -20,19 +37,24 @@ pub fn resolve_bundled_binary_path(
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
for candidate in executable_relative_candidates(&exe_path, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Development payloads are intentionally discoverable from the checkout,
|
||||
// but a packaged/release app must never execute an engine selected by its
|
||||
// working directory. If the packaged resource or executable-relative
|
||||
// payload is missing, fail closed instead of allowing a same-named binary
|
||||
// from an untrusted CWD to take over the media/download process.
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
for candidate in development_candidates(&cwd, &target, &binary_name) {
|
||||
for candidate in development_candidates_for_runtime(&cwd, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
let absolute = candidate.canonicalize().map_err(|error| {
|
||||
format!("Failed to canonicalize '{}': {error}", candidate.display())
|
||||
})?;
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, absolute);
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
return Ok(absolute);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +66,22 @@ pub fn resolve_bundled_binary_path(
|
||||
))
|
||||
}
|
||||
|
||||
fn development_candidates_for_runtime(
|
||||
cwd: &Path,
|
||||
target: &str,
|
||||
binary_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
development_candidates(cwd, target, binary_name)
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = (cwd, target, binary_name);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn packaged_candidates(resource_dir: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
resource_dir
|
||||
@@ -98,6 +136,12 @@ fn executable_relative_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))]
|
||||
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
|
||||
let mut candidates = Vec::new();
|
||||
@@ -116,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) {
|
||||
if let Some(modules_dir) = aria2_openssl_modules_dir(binary_path) {
|
||||
command.env("OPENSSL_MODULES", modules_dir);
|
||||
}
|
||||
apply_aria2_runtime_environment(command, binary_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) {
|
||||
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> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
if !cfg!(any(target_os = "macos", target_os = "windows")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -141,7 +215,10 @@ fn aria2_openssl_modules_dir(binary_path: &Path) -> Option<PathBuf> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{development_candidates, packaged_candidates};
|
||||
use super::{
|
||||
development_candidates, development_candidates_for_runtime, packaged_candidates,
|
||||
runtime_candidates,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
@@ -171,4 +248,34 @@ mod tests {
|
||||
Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe")
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn development_resolution_is_disabled_in_release_builds() {
|
||||
let candidates = development_candidates_for_runtime(
|
||||
Path::new("/repo"),
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"yt-dlp-x86_64-unknown-linux-gnu",
|
||||
);
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
assert!(!candidates.is_empty());
|
||||
} else {
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,7 +28,11 @@ use ts_rs::TS;
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||
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 SERVER_HEADER: &str = "x-firelink-server";
|
||||
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 SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "4";
|
||||
const PROTOCOL_VERSION: &str = "6";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
@@ -75,9 +80,13 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
#[serde(default)]
|
||||
torrent: bool,
|
||||
#[serde(default)]
|
||||
batch: bool,
|
||||
#[serde(default)]
|
||||
batch_name: Option<String>,
|
||||
#[serde(default)]
|
||||
torrent_bytes_base64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
@@ -100,8 +109,14 @@ pub struct ExtensionDownload {
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
torrent: bool,
|
||||
batch: bool,
|
||||
batch_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
torrent_path: Option<String>,
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
torrent_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn start_server(
|
||||
@@ -300,33 +315,51 @@ async fn download_handler(
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let download = match normalize_download(payload) {
|
||||
let mut download = match normalize_download(payload) {
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
if let Some(window) = state.app_handle.get_webview_window("main") {
|
||||
let is_visible = window.is_visible().unwrap_or(true);
|
||||
if !is_visible {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
// Sleep briefly to let the webview wake up from macOS App Nap
|
||||
// otherwise the IPC event emitted immediately after is dropped.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
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
|
||||
.app_handle
|
||||
.get_webview_window("main")
|
||||
.and_then(|window| window.is_visible().ok())
|
||||
.is_some_and(|is_visible| !is_visible);
|
||||
crate::restore_main_window(&state.app_handle);
|
||||
if is_hidden {
|
||||
// Sleep briefly to let the webview wake up from macOS App Nap
|
||||
// otherwise the IPC event emitted immediately after is dropped.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).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);
|
||||
}
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let mut download = download;
|
||||
let Some(ack_receiver) = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
else {
|
||||
if cached_torrent {
|
||||
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||
}
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
};
|
||||
download.request_id = Some(request_id.clone());
|
||||
|
||||
if state
|
||||
@@ -335,6 +368,9 @@ async fn download_handler(
|
||||
.is_err()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -404,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> {
|
||||
if payload.urls.len() > MAX_URL_COUNT {
|
||||
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 urls = payload
|
||||
.urls
|
||||
@@ -419,6 +477,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
if urls.is_empty() {
|
||||
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
|
||||
&& urls.iter().any(|url| {
|
||||
Url::parse(url)
|
||||
@@ -428,6 +496,21 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let torrent = !payload.media
|
||||
&& urls.len() == 1
|
||||
&& Url::parse(&urls[0]).ok().is_some_and(|url| {
|
||||
if url.scheme() == "magnet" {
|
||||
return true;
|
||||
}
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& (payload.torrent
|
||||
|| torrent_bytes.is_some()
|
||||
|| filename_is_torrent(payload.filename.as_deref())
|
||||
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
||||
});
|
||||
if payload.torrent && !torrent {
|
||||
return None;
|
||||
}
|
||||
|
||||
let referer = payload.referer.and_then(|value| {
|
||||
let url = Url::parse(value.trim()).ok()?;
|
||||
@@ -482,8 +565,11 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
torrent,
|
||||
batch,
|
||||
batch_name,
|
||||
torrent_path: None,
|
||||
torrent_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,18 +634,8 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| {
|
||||
!matches!(
|
||||
name.trim().to_ascii_lowercase().as_str(),
|
||||
"authorization"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "proxy-authorization"
|
||||
| "set-cookie"
|
||||
| "set-cookie2"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
.map(|(name, _)| !crate::queue::header_name_has_credential_material(name))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -568,7 +644,15 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
|
||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||
let url = Url::parse(raw_url.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet")
|
||||
.then(|| url.to_string())
|
||||
}
|
||||
|
||||
fn filename_is_torrent(filename: Option<&str>) -> bool {
|
||||
filename
|
||||
.and_then(|value| Path::new(value.trim()).file_name())
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.to_ascii_lowercase().ends_with(".torrent"))
|
||||
}
|
||||
|
||||
fn sanitize_filename(filename: &str) -> Option<String> {
|
||||
@@ -716,9 +800,10 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
|
||||
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
decode_torrent_bytes, has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
|
||||
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
@@ -726,6 +811,7 @@ mod tests {
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
@@ -752,7 +838,7 @@ mod tests {
|
||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||
assert_eq!(
|
||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||
"4"
|
||||
"6"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
@@ -816,8 +902,10 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
@@ -836,8 +924,10 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
@@ -891,20 +981,25 @@ mod tests {
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox",
|
||||
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nX-Api-Key: stale\nX-Auth-Token: stale\nX-Access-Token: stale\nX-Request-Signature: stale\nX-Session: stale\n: malformed\nUser-Agent: Firefox\nX-Trace: safe",
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
|
||||
assert!(download.media);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
assert_eq!(
|
||||
download.headers.as_deref(),
|
||||
Some("User-Agent: Firefox\nX-Trace: safe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -918,8 +1013,10 @@ mod tests {
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
@@ -930,6 +1027,164 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_shared_credentials_but_keeps_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://one.example/file.zip".to_string(),
|
||||
"https://two.example/file.zip".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(
|
||||
"X-Api-Key: shared-secret\nX-Request-Signature: signature-secret\n: malformed\nUser-Agent: Firefox\nX-Trace: safe"
|
||||
.to_string(),
|
||||
),
|
||||
cookies: Some("session=must-not-cross-hosts".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("batch".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
assert!(download.batch);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(
|
||||
download.headers.as_deref(),
|
||||
Some("User-Agent: Firefox\nX-Trace: safe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_handoff_accepts_magnets_and_preserves_the_intent() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid magnet torrent handoff");
|
||||
|
||||
assert!(download.torrent);
|
||||
assert_eq!(download.urls[0], "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
let opaque = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://example.com/download?id=opaque".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("explicit opaque torrent handoff");
|
||||
assert!(opaque.torrent);
|
||||
|
||||
let legacy_magnet = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("legacy magnet handoff");
|
||||
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]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -954,8 +1209,10 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
@@ -984,8 +1241,10 @@ mod tests {
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
@@ -1007,8 +1266,10 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid selected-link batch");
|
||||
|
||||
@@ -1030,8 +1291,10 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid single-link handoff");
|
||||
|
||||
@@ -1069,4 +1332,70 @@ mod tests {
|
||||
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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+583
-4
@@ -14,6 +14,50 @@ fn default_sidebar_position() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
fn default_torrent_enable_dht() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_torrent_enable_dht6() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_enable_pex() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_torrent_enable_lpd() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_open_files() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
}
|
||||
|
||||
fn default_torrent_dht_message_timeout() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
}
|
||||
|
||||
fn default_torrent_separate_seed_slots() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_concurrent_seeds() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}
|
||||
|
||||
fn default_torrent_ipv6_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_aria2_disk_cache() -> String {
|
||||
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
||||
}
|
||||
|
||||
fn default_adaptive_mirror_selection() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -26,6 +70,13 @@ pub enum DownloadStatus {
|
||||
/// Post-download media processing such as yt-dlp/ffmpeg merging or
|
||||
/// extraction. The queue permit is still held.
|
||||
Processing,
|
||||
/// A BitTorrent download has all selected data and is still seeding.
|
||||
/// The Aria2 GID and queue permit remain live until seeding ends.
|
||||
Seeding,
|
||||
/// A BitTorrent download is complete but paused while waiting for a
|
||||
/// Firelink-owned seeding slot.
|
||||
#[serde(rename = "waitingToSeed")]
|
||||
WaitingToSeed,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
@@ -33,6 +84,11 @@ pub enum DownloadStatus {
|
||||
/// Transient state: a connection-aware retry is in progress with
|
||||
/// exponential backoff. The download slot/permit is still held.
|
||||
Retrying,
|
||||
/// Aria2 is verifying already-present Torrent data before transfer or
|
||||
/// after an explicit integrity check.
|
||||
Verifying,
|
||||
/// Firelink is moving owned Torrent data between managed destinations.
|
||||
Moving,
|
||||
}
|
||||
|
||||
impl DownloadStatus {
|
||||
@@ -42,11 +98,15 @@ impl DownloadStatus {
|
||||
Self::Staged => "staged",
|
||||
Self::Downloading => "downloading",
|
||||
Self::Processing => "processing",
|
||||
Self::Seeding => "seeding",
|
||||
Self::WaitingToSeed => "waitingToSeed",
|
||||
Self::Paused => "paused",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Queued => "queued",
|
||||
Self::Retrying => "retrying",
|
||||
Self::Verifying => "verifying",
|
||||
Self::Moving => "moving",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +120,7 @@ pub enum DownloadCategory {
|
||||
Documents,
|
||||
Pictures,
|
||||
Applications,
|
||||
Torrents,
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -84,6 +145,46 @@ pub struct QueueConcurrencyConfig {
|
||||
pub max_concurrent: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadErrorKind {
|
||||
NameResolution,
|
||||
DestinationAccess,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadTargetKind {
|
||||
Missing,
|
||||
RegularFile,
|
||||
Directory,
|
||||
Symlink,
|
||||
Special,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadTargetInfo {
|
||||
pub kind: DownloadTargetKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub owned_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadAssetRemovalPolicy {
|
||||
Trash,
|
||||
PermanentIfUnfinished,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -119,6 +220,8 @@ pub struct DownloadItem {
|
||||
#[ts(optional)]
|
||||
pub password: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub sftp_host_key_md: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub headers: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub checksum: Option<String>,
|
||||
@@ -142,8 +245,282 @@ pub struct DownloadItem {
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub credentials_required: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_resolver_fallback: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub replace_existing_fingerprint: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub is_torrent: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_path: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_file_indices: Option<Vec<u32>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_info_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_time: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional, type = "number")]
|
||||
pub torrent_uploaded_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional, type = "number")]
|
||||
pub torrent_seeded_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_relocation_check_pending: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_move_destination: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_move_restore_status: Option<DownloadStatus>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds_native: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_max_peers: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_check_integrity: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_connect_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_interval: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_prioritize_piece: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_remove_unselected_file: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_encryption_policy: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_file_allocation: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_verify_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_verify_restore_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeer {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub ip: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub port: Option<u16>,
|
||||
#[ts(type = "number")]
|
||||
pub download_speed: u64,
|
||||
#[ts(type = "number")]
|
||||
pub upload_speed: u64,
|
||||
pub seeder: bool,
|
||||
pub am_choking: bool,
|
||||
pub peer_choking: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeerDiagnostics {
|
||||
#[ts(type = "number")]
|
||||
pub listed_peers: u32,
|
||||
#[ts(type = "number")]
|
||||
pub listed_seeders: u32,
|
||||
pub peers: Vec<TorrentPeer>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgress {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_length: u64,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgressSnapshot {
|
||||
pub files: Vec<TorrentFileProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPieceProgressSnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub num_pieces: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_pieces: u64,
|
||||
pub buckets: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileSelectionEntry {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
pub selected: bool,
|
||||
#[ts(type = "number")]
|
||||
#[ts(optional)]
|
||||
pub completed_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileSelectionSnapshot {
|
||||
pub files: Vec<TorrentFileSelectionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentDetails {
|
||||
pub info_hash: String,
|
||||
pub display_name: String,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
#[ts(type = "number")]
|
||||
pub file_count: u32,
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub piece_count: u64,
|
||||
pub private: bool,
|
||||
pub creation_date: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub trackers: Vec<String>,
|
||||
pub web_seeds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentAvailabilityBucket {
|
||||
#[ts(type = "number")]
|
||||
pub minimum_copies: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentAvailabilitySnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_count: u64,
|
||||
pub availability: f64,
|
||||
#[ts(type = "number")]
|
||||
pub connected_peers: u32,
|
||||
pub buckets: Vec<TorrentAvailabilityBucket>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentMoveProgressEvent {
|
||||
pub id: String,
|
||||
pub fraction: f64,
|
||||
#[ts(type = "number")]
|
||||
pub copied_bytes: u64,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentWebSeed {
|
||||
#[ts(type = "number")]
|
||||
pub file_index: u32,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFile {
|
||||
pub index: u32,
|
||||
pub path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentMetadata {
|
||||
pub name: String,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
pub files: Vec<TorrentFile>,
|
||||
pub info_hash: String,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -193,7 +570,7 @@ pub enum ListRowDensity {
|
||||
Relaxed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum PostQueueAction {
|
||||
@@ -312,6 +689,14 @@ pub struct SchedulerSettings {
|
||||
pub post_queue_action: PostQueueAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MainWindowSize {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -332,9 +717,16 @@ pub struct PersistedSettings {
|
||||
pub approved_download_roots: Vec<String>,
|
||||
pub max_concurrent_downloads: usize,
|
||||
pub global_speed_limit: String,
|
||||
#[serde(default)]
|
||||
pub torrent_overall_upload_limit: String,
|
||||
pub speed_limit_preset_values: Vec<f64>,
|
||||
pub logs_enabled: bool,
|
||||
pub is_sidebar_visible: bool,
|
||||
#[serde(default)]
|
||||
pub is_folders_collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub main_window_size: Option<MainWindowSize>,
|
||||
#[serde(default = "default_sidebar_position")]
|
||||
pub sidebar_position: String,
|
||||
pub active_settings_tab: SettingsTab,
|
||||
@@ -342,12 +734,21 @@ pub struct PersistedSettings {
|
||||
pub scheduler_running: bool,
|
||||
pub scheduler_active_download_ids: Vec<String>,
|
||||
pub scheduler_last_start_key: String,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub scheduler_triggered_start_key: Option<String>,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
#[serde(default = "default_speed_limit_unit")]
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
#[serde(default)]
|
||||
pub minimum_normal_download_speed_ki_b: u32,
|
||||
#[serde(default)]
|
||||
pub retry_not_found_errors: bool,
|
||||
#[serde(default = "default_adaptive_mirror_selection")]
|
||||
pub adaptive_mirror_selection: bool,
|
||||
pub show_notifications: bool,
|
||||
pub play_completion_sound: bool,
|
||||
#[serde(default)]
|
||||
@@ -359,6 +760,46 @@ pub struct PersistedSettings {
|
||||
pub proxy_mode: ProxyMode,
|
||||
pub proxy_host: String,
|
||||
pub proxy_port: u16,
|
||||
#[serde(default = "default_torrent_enable_dht")]
|
||||
pub torrent_enable_dht: bool,
|
||||
#[serde(default = "default_torrent_enable_dht6")]
|
||||
pub torrent_enable_dht6: bool,
|
||||
#[serde(default = "default_torrent_enable_pex")]
|
||||
pub torrent_enable_pex: bool,
|
||||
#[serde(default = "default_torrent_enable_lpd")]
|
||||
pub torrent_enable_lpd: bool,
|
||||
#[serde(default = "default_torrent_max_open_files")]
|
||||
pub torrent_max_open_files: u32,
|
||||
#[serde(default = "default_torrent_dht_message_timeout")]
|
||||
pub torrent_dht_message_timeout: u32,
|
||||
#[serde(default = "default_torrent_separate_seed_slots")]
|
||||
pub torrent_separate_seed_slots: bool,
|
||||
#[serde(default = "default_torrent_max_concurrent_seeds")]
|
||||
pub torrent_max_concurrent_seeds: u32,
|
||||
#[serde(default = "default_torrent_ipv6_enabled")]
|
||||
pub torrent_ipv6_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub torrent_listen_port: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_listen_port: String,
|
||||
#[serde(default)]
|
||||
pub torrent_external_ip: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_entry_point: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_entry_point6: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_listen_addr6: String,
|
||||
#[serde(default)]
|
||||
pub torrent_lpd_interface: String,
|
||||
#[serde(default)]
|
||||
pub torrent_peer_id_prefix: String,
|
||||
#[serde(default)]
|
||||
pub torrent_peer_agent: String,
|
||||
#[serde(default)]
|
||||
pub torrent_bind_address: String,
|
||||
#[serde(default = "default_aria2_disk_cache")]
|
||||
pub aria2_disk_cache: String,
|
||||
pub custom_user_agent: String,
|
||||
pub ask_where_to_save_each_file: bool,
|
||||
pub remember_last_used_download_directory: bool,
|
||||
@@ -390,6 +831,31 @@ pub enum QueueDirection {
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadStateProgress {
|
||||
pub fraction: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub downloaded_bytes: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub total_bytes: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub total_is_estimate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadAllocationEvent {
|
||||
pub id: String,
|
||||
pub pending: bool,
|
||||
pub lifecycle_generation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -397,8 +863,22 @@ pub struct DownloadStateEvent {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub resolver_fallback: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub file_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub destination: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub progress: Option<DownloadStateProgress>,
|
||||
}
|
||||
|
||||
impl DownloadStateEvent {
|
||||
@@ -407,25 +887,56 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: status.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Failed.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_seed_remaining(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,18 +945,86 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Completed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: Some(file_name.into()),
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient retry state. Carries the human-readable reason so the UI can
|
||||
/// surface "network dropped, retrying in 5s…". The slot is still held.
|
||||
pub fn retrying(id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
let (reason, error_kind) = Self::safe_error(reason);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||
error: Some(reason.into()),
|
||||
error: Some(reason),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waiting_to_seed(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retrying_with_resolver_fallback(
|
||||
id: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) -> Self {
|
||||
let mut event = Self::retrying(id, reason);
|
||||
event.resolver_fallback = Some(true);
|
||||
event
|
||||
}
|
||||
|
||||
pub fn with_destination(mut self, destination: impl Into<String>) -> Self {
|
||||
self.destination = Some(destination.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_progress(mut self, progress: DownloadStateProgress) -> Self {
|
||||
self.progress = Some(progress);
|
||||
self
|
||||
}
|
||||
|
||||
fn safe_error(error: impl Into<String>) -> (String, Option<DownloadErrorKind>) {
|
||||
let error = crate::redact_sensitive_text(&error.into());
|
||||
let error_kind = crate::retry::is_aria2_name_resolution_error(&error)
|
||||
.then_some(DownloadErrorKind::NameResolution);
|
||||
(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 }
|
||||
|
||||
+11299
-447
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
+608
-192
@@ -1,103 +1,144 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
use std::{process::Stdio, time::Duration};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
match native_system_proxy() {
|
||||
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
match bounded_native_system_proxy(&SystemProxyCommandRunner, PROXY_DISCOVERY_TIMEOUT).await {
|
||||
Ok(Some(proxy)) => Ok(Some(proxy)),
|
||||
Ok(None) => Ok(proxy_from_environment()),
|
||||
Err(native_error) => match sysproxy::Sysproxy::get_system_proxy() {
|
||||
Ok(proxy) if proxy.enable => {
|
||||
if proxy.host.contains('=') {
|
||||
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))
|
||||
}
|
||||
Err(native_error) => proxy_from_environment()
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("failed to read system proxy settings: {native_error}")),
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
Err(error) => proxy_from_environment().map(Some).ok_or_else(|| {
|
||||
format!(
|
||||
"failed to read system proxy settings: {native_error}; sysproxy fallback: {error}"
|
||||
)
|
||||
}),
|
||||
},
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.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")]
|
||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
||||
fallback_windows_proxy().map_err(|_| "failed to read Windows proxy registry".to_string())
|
||||
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||
windows_system_proxy(runner).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
||||
let proxy = sysproxy::Sysproxy::get_system_proxy().map_err(|error| error.to_string())?;
|
||||
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())
|
||||
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||
macos_system_proxy(runner).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn native_system_proxy() -> Result<Option<String>, String> {
|
||||
let mode =
|
||||
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")))
|
||||
async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||
linux_system_proxy(runner).await
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
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> {
|
||||
[
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
("HTTPS_PROXY", "http"),
|
||||
("https_proxy", "http"),
|
||||
("HTTP_PROXY", "http"),
|
||||
("http_proxy", "http"),
|
||||
("ALL_PROXY", "socks5"),
|
||||
("all_proxy", "socks5"),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|name| {
|
||||
.find_map(|(name, scheme)| {
|
||||
std::env::var(name)
|
||||
.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> {
|
||||
let trimmed = raw.trim().trim_matches('"').trim_end_matches('/');
|
||||
let trimmed = raw.trim().trim_matches('"');
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -113,30 +154,40 @@ fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
|
||||
_ => return None,
|
||||
}
|
||||
parsed.host_str()?;
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
fn normalize_sysproxy_address(host: &str, port: u16) -> Option<String> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
if parsed.port() == Some(0)
|
||||
|| !matches!(parsed.path(), "" | "/")
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Some(candidate.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
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> {
|
||||
let value = value.trim().trim_matches('"');
|
||||
if value.is_empty() {
|
||||
@@ -167,24 +218,16 @@ fn parse_windows_proxy_server(value: &str) -> Option<String> {
|
||||
https.or(http).or(socks)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
|
||||
let services_output =
|
||||
command_stdout(Command::new("networksetup").arg("-listallnetworkservices")).ok()?;
|
||||
for service in parse_macos_network_services(&services_output) {
|
||||
for (target, scheme) in [
|
||||
("securewebproxy", "http"),
|
||||
("webproxy", "http"),
|
||||
("socksfirewallproxy", "socks5"),
|
||||
] {
|
||||
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);
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn scutil_dict_value<'a>(output: &'a str, key: &str) -> Option<&'a str> {
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some((k, v)) = trimmed.split_once(':') {
|
||||
if k.trim().eq_ignore_ascii_case(key) {
|
||||
let val = v.trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,15 +235,78 @@ fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_macos_network_services(output: &str) -> Vec<String> {
|
||||
output
|
||||
fn parse_macos_scutil_proxy(output: &str) -> Option<String> {
|
||||
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()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter(|line| !line.starts_with("An asterisk"))
|
||||
.filter(|line| !line.starts_with('*'))
|
||||
.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))]
|
||||
@@ -212,15 +318,7 @@ fn parse_macos_networksetup_proxy(output: &str, scheme: &str) -> Option<String>
|
||||
}
|
||||
let server = macos_networksetup_value(output, "Server:")?;
|
||||
let port = macos_networksetup_value(output, "Port:")?;
|
||||
normalize_proxy_address(&format!("{scheme}://{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)
|
||||
proxy_from_host_port(server, port, scheme)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
@@ -232,17 +330,56 @@ fn macos_networksetup_value<'a>(output: &'a str, key: &str) -> Option<&'a str> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_gsettings_proxy(service: &str, scheme: &str) -> Option<String> {
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
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 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);
|
||||
if host.is_empty() {
|
||||
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();
|
||||
normalize_proxy_address(&format!("{scheme}://{host}:{port}"), scheme)
|
||||
proxy_from_host_port(&host, port, scheme)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
@@ -254,52 +391,56 @@ fn strip_gsettings_string(value: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn fallback_windows_proxy() -> Result<Option<String>, ()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let output = Command::new("reg")
|
||||
.args(&[
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyEnable",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let enabled = registry_value(&stdout, "ProxyEnable")
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn windows_system_proxy(runner: &dyn ProxyCommandRunner) -> Result<Option<String>, String> {
|
||||
let output = match runner
|
||||
.stdout(
|
||||
"reg",
|
||||
&string_args(&[
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyEnable",
|
||||
]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
if is_probe_unavailable(&error) {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let enabled = registry_value(&output, "ProxyEnable")
|
||||
.as_deref()
|
||||
.is_some_and(windows_proxy_enabled);
|
||||
if !enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output = Command::new("reg")
|
||||
.args(&[
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyServer",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(registry_value(&stdout, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value)))
|
||||
let output = match runner
|
||||
.stdout(
|
||||
"reg",
|
||||
&string_args(&[
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyServer",
|
||||
]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
if is_probe_unavailable(&error) {
|
||||
return Ok(None);
|
||||
}
|
||||
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))]
|
||||
@@ -340,10 +481,78 @@ fn registry_value(output: &str, name: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod proxy_tests {
|
||||
use super::{
|
||||
normalize_proxy_address, normalize_sysproxy_address, parse_macos_network_services,
|
||||
parse_macos_networksetup_proxy, parse_windows_proxy_server, proxy_matches_host_port,
|
||||
registry_value, strip_gsettings_string, windows_proxy_enabled,
|
||||
bounded_native_system_proxy, normalize_proxy_address, parse_macos_network_services,
|
||||
parse_macos_networksetup_proxy, parse_macos_scutil_proxy, parse_windows_proxy_server,
|
||||
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]
|
||||
fn normalizes_bare_proxy_addresses() {
|
||||
@@ -356,6 +565,33 @@ mod proxy_tests {
|
||||
Some("socks5://127.0.0.1:1080")
|
||||
);
|
||||
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]
|
||||
@@ -374,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]
|
||||
fn parses_macos_proxy_outputs_with_scheme() {
|
||||
let services = r#"
|
||||
@@ -399,7 +619,7 @@ Wi-Fi
|
||||
Thunderbolt Bridge
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_macos_network_services(services),
|
||||
parse_macos_network_services(services).unwrap(),
|
||||
vec!["Wi-Fi".to_string(), "Thunderbolt Bridge".to_string()]
|
||||
);
|
||||
|
||||
@@ -413,21 +633,55 @@ Authenticated Proxy Enabled: 0
|
||||
parse_macos_networksetup_proxy(proxy, "socks5").as_deref(),
|
||||
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");
|
||||
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]
|
||||
fn strips_gsettings_string_quotes() {
|
||||
assert_eq!(strip_gsettings_string("'manual'\n"), "manual");
|
||||
@@ -452,6 +706,132 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings
|
||||
assert!(!windows_proxy_enabled("0X0"));
|
||||
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]
|
||||
@@ -485,7 +865,9 @@ pub fn get_file_category(filename: String) -> DownloadCategory {
|
||||
"run", "sh", "bin", "jar",
|
||||
];
|
||||
|
||||
if music_exts.contains(&ext.as_str()) {
|
||||
if ext == "torrent" {
|
||||
DownloadCategory::Torrents
|
||||
} else if music_exts.contains(&ext.as_str()) {
|
||||
DownloadCategory::Musics
|
||||
} else if movie_exts.contains(&ext.as_str()) {
|
||||
DownloadCategory::Movies
|
||||
@@ -539,8 +921,10 @@ struct GitHubRelease {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<ReleaseCheckOutcome, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let current_version = app_handle.package_info().version.to_string();
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
@@ -606,10 +990,12 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_category_directories(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
base_folder: String,
|
||||
subfolders: std::collections::HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let base = crate::resolve_path(&base_folder, &app_handle);
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -673,6 +1059,9 @@ pub fn get_supported_media_domains() -> Vec<String> {
|
||||
#[tauri::command]
|
||||
pub fn is_supported_media(url: String) -> bool {
|
||||
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() {
|
||||
let host_lower = host.to_lowercase();
|
||||
for domain in SUPPORTED_DOMAINS.iter() {
|
||||
@@ -684,3 +1073,30 @@ pub fn is_supported_media(url: String) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{get_file_category, is_supported_media};
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[test]
|
||||
fn classifies_torrent_files_as_torrents() {
|
||||
assert!(matches!(
|
||||
get_file_category("Example.TORRENT".to_string()),
|
||||
DownloadCategory::Torrents
|
||||
));
|
||||
assert!(matches!(
|
||||
get_file_category("Example.mkv".to_string()),
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
+524
-9
@@ -1,6 +1,348 @@
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
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
|
||||
/// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with
|
||||
/// delete sharing so inspection does not unnecessarily block normal cleanup
|
||||
/// or replacement; callers still validate the path with `symlink_metadata`
|
||||
/// before using this identity.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn file_identity(path: &Path) -> Option<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_ATTRIBUTE_NORMAL,
|
||||
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<_>>();
|
||||
// A zero desired-access mask requests metadata access only. Opening with
|
||||
// all sharing flags avoids introducing a lock that changes the outcome of
|
||||
// a subsequent exact replacement or cleanup operation.
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide_path.as_ptr(),
|
||||
0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut metadata = BY_HANDLE_FILE_INFORMATION::default();
|
||||
let result = unsafe {
|
||||
let succeeded = GetFileInformationByHandle(handle, &mut metadata) != 0;
|
||||
let _ = CloseHandle(handle);
|
||||
succeeded
|
||||
};
|
||||
result.then(|| format_file_identity(&metadata))
|
||||
}
|
||||
|
||||
/// 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-";
|
||||
|
||||
/// Write bytes to a same-directory temporary file, synchronize them, and
|
||||
/// replace the destination without ever opening the destination for writing.
|
||||
///
|
||||
/// The destination is checked with `symlink_metadata` so managed callers fail
|
||||
/// closed when an attacker or another process has substituted a link or a
|
||||
/// non-file. The final rename is atomic on Unix and uses Windows replace
|
||||
/// semantics rather than the non-replacing `std::fs::rename` behavior.
|
||||
pub async fn atomic_write_replace(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "atomic path has no parent"))?;
|
||||
validate_atomic_parent(parent).await?;
|
||||
|
||||
match tokio::fs::symlink_metadata(path).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"atomic destination cannot be a symbolic link",
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.file_type().is_file() => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"atomic destination is not a regular file",
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let temporary = parent.join(format!(
|
||||
"{ATOMIC_TEMP_PREFIX}{}.tmp",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let write_result = async {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = write_result {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if let Err(error) = replace_staged_file(&temporary, path) {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// A directory sync makes the rename durable across a power loss on
|
||||
// platforms that support opening directories as file descriptors.
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_atomic_parent(parent: &Path) -> io::Result<()> {
|
||||
use std::path::Component;
|
||||
|
||||
let mut current = PathBuf::new();
|
||||
for component in parent.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => current.push(prefix.as_os_str()),
|
||||
Component::RootDir => current.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"atomic parent contains a parent-directory component",
|
||||
));
|
||||
}
|
||||
Component::Normal(name) => {
|
||||
current.push(name);
|
||||
let metadata = tokio::fs::symlink_metadata(¤t).await?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
if let Some(canonical_alias) = resolve_atomic_system_alias(¤t)? {
|
||||
current = canonical_alias;
|
||||
continue;
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"atomic parent cannot contain a symbolic link",
|
||||
));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotADirectory,
|
||||
"atomic parent is not a directory",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_atomic_system_alias(path: &Path) -> io::Result<Option<PathBuf>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let expected = match path {
|
||||
path if path == Path::new("/tmp") => Some(Path::new("/private/tmp")),
|
||||
path if path == Path::new("/var") => Some(Path::new("/private/var")),
|
||||
path if path == Path::new("/etc") => Some(Path::new("/private/etc")),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
let canonical = std::fs::canonicalize(path)?;
|
||||
if canonical == expected {
|
||||
return Ok(Some(canonical));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = path;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn is_atomic_temp_file_name(name: &str) -> bool {
|
||||
let Some(suffix) = name.strip_prefix(ATOMIC_TEMP_PREFIX) else {
|
||||
return false;
|
||||
};
|
||||
let Some(identifier) = suffix.strip_suffix(".tmp") else {
|
||||
return false;
|
||||
};
|
||||
identifier.len() == 32 && identifier.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn replace_staged_file(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::fs::rename(temporary, destination)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
GetLastError, ERROR_LOCK_VIOLATION, ERROR_SHARING_VIOLATION,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
let temporary = temporary
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let destination = destination
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for attempt in 0..5 {
|
||||
// SAFETY: both paths are NUL-terminated UTF-16 buffers owned for
|
||||
// the duration of the call, and the flags request same-volume
|
||||
// replacement with write-through semantics.
|
||||
let replaced = unsafe {
|
||||
MoveFileExW(
|
||||
temporary.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
if replaced != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let error = unsafe { GetLastError() };
|
||||
if !matches!(error, ERROR_LOCK_VIOLATION | ERROR_SHARING_VIOLATION) || attempt == 4 {
|
||||
return Err(io::Error::from_raw_os_error(error as i32));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25 * (attempt + 1) as u64));
|
||||
}
|
||||
|
||||
unreachable!("atomic Windows replacement loop always returns");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_arch() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
@@ -108,29 +450,115 @@ fn trusted_system_path_entries() -> Vec<PathBuf> {
|
||||
pub fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = path.to_string_lossy().to_lowercase();
|
||||
let root = root.to_string_lossy().to_lowercase();
|
||||
let path = path_identity(path);
|
||||
let root = path_identity(root);
|
||||
path == root
|
||||
|| (root.len() == 3
|
||||
&& root.ends_with('/')
|
||||
&& root.as_bytes()[1] == b':'
|
||||
&& path.starts_with(&root))
|
||||
|| path
|
||||
.strip_prefix(&root)
|
||||
.is_some_and(|suffix| suffix.starts_with(['\\', '/']))
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Containment is a scope check, not an equality check. Do not fold
|
||||
// case here: case-sensitive APFS/HFS+ volumes are valid macOS
|
||||
// configurations, and lowercasing could admit `/Users/nima2` or a
|
||||
// differently-cased sibling outside the approved root. Callers pass
|
||||
// canonical paths (with only missing leaf components preserved), so
|
||||
// NFC normalization is enough to compare macOS path spellings.
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
let path = path.to_string_lossy().nfc().collect::<String>();
|
||||
let root = root.to_string_lossy().nfc().collect::<String>();
|
||||
let root = root.trim_end_matches('/');
|
||||
if path == root || (root.is_empty() && path == "/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if root.is_empty() {
|
||||
return path.starts_with('/');
|
||||
}
|
||||
|
||||
path.strip_prefix(root)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
path.starts_with(root)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||
path_identity(left) == path_identity(right)
|
||||
}
|
||||
|
||||
/// Return the in-process lock identity for a path using the same platform
|
||||
/// equivalence rules as `paths_equal`. Callers use this for serialization,
|
||||
/// not for display or persistence.
|
||||
pub fn path_identity(path: &Path) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
let mut normalized = path.to_string_lossy().replace('\\', "/");
|
||||
if normalized
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/"))
|
||||
{
|
||||
normalized.replace_range(..8, "//");
|
||||
} else if normalized
|
||||
.get(..4)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/"))
|
||||
{
|
||||
normalized.replace_range(..4, "");
|
||||
}
|
||||
|
||||
let is_unc = normalized.starts_with("//");
|
||||
let mut collapsed = String::with_capacity(normalized.len());
|
||||
for character in normalized.chars() {
|
||||
if character == '/' && collapsed.ends_with('/') && !(is_unc && collapsed.len() == 1) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(character);
|
||||
}
|
||||
while collapsed.len() > 1
|
||||
&& collapsed.ends_with('/')
|
||||
&& !(collapsed.len() == 3 && collapsed.as_bytes()[1] == b':')
|
||||
{
|
||||
collapsed.pop();
|
||||
}
|
||||
collapsed.to_lowercase()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
left == right
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
path.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.nfc()
|
||||
.collect::<String>()
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
path.as_os_str()
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(any(unix, target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +583,10 @@ fn numbered_windows_device(stem: &str, prefix: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{engine_binary_name, is_windows_reserved_filename, target_triple};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use super::path_is_within;
|
||||
use super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn target_engine_name_uses_current_rust_target() {
|
||||
@@ -186,4 +617,88 @@ mod tests {
|
||||
assert!(!is_windows_reserved_filename(filename), "{filename}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_matches_the_host_filesystem_case_contract() {
|
||||
let left = Path::new("/downloads/Selected/File.bin");
|
||||
let right = Path::new("/Downloads/selected/file.BIN");
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) {
|
||||
assert!(paths_equal(left, right));
|
||||
} else {
|
||||
assert!(!paths_equal(left, right));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_path_identity_normalizes_separators_and_verbatim_prefixes() {
|
||||
assert!(paths_equal(
|
||||
Path::new(r"C:\downloads\file.bin"),
|
||||
Path::new("c:/DOWNLOADS/file.bin")
|
||||
));
|
||||
assert!(paths_equal(
|
||||
Path::new(r"C:\downloads\file.bin"),
|
||||
Path::new(r"\\?\C:\downloads\file.bin")
|
||||
));
|
||||
assert!(paths_equal(
|
||||
Path::new(r"\\server\share\file.bin"),
|
||||
Path::new(r"\\?\UNC\server\share\file.bin")
|
||||
));
|
||||
assert!(path_is_within(
|
||||
Path::new("c:/downloads/file.bin"),
|
||||
Path::new(r"C:\downloads")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_handles_non_ascii_case_differences() {
|
||||
let left = Path::new("/downloads/Ärt/File.bin");
|
||||
let right = Path::new("/DOWNLOADS/ärt/file.BIN");
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) {
|
||||
assert!(paths_equal(left, right));
|
||||
} else {
|
||||
assert!(!paths_equal(left, right));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_handles_macos_unicode_normalization() {
|
||||
let composed = Path::new("/downloads/café/File.bin");
|
||||
let decomposed = Path::new("/DOWNLOADS/cafe\u{301}/file.BIN");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(paths_equal(composed, decomposed));
|
||||
} else {
|
||||
assert!(!paths_equal(composed, decomposed));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_path_is_within_preserves_scope_and_unicode_identity() {
|
||||
assert!(path_is_within(
|
||||
Path::new("/Downloads/cafe\u{301}/movie.bin"),
|
||||
Path::new("/Downloads/café")
|
||||
));
|
||||
assert!(path_is_within(
|
||||
Path::new("/Downloads/movie.bin"),
|
||||
Path::new("/Downloads")
|
||||
));
|
||||
assert!(path_is_within(
|
||||
Path::new("/Downloads"),
|
||||
Path::new("/Downloads/")
|
||||
));
|
||||
assert!(path_is_within(Path::new("/"), Path::new("////")));
|
||||
assert!(path_is_within(
|
||||
Path::new("/Downloads/movie.bin"),
|
||||
Path::new("/")
|
||||
));
|
||||
assert!(!path_is_within(
|
||||
Path::new("/downloads/cafeteria/movie.bin"),
|
||||
Path::new("/Downloads/café")
|
||||
));
|
||||
assert!(!path_is_within(
|
||||
Path::new("/downloads/movie.bin"),
|
||||
Path::new("/Downloads")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const PROPERTIES_LABEL_PREFIX: &str = "properties-";
|
||||
const PROPERTIES_WINDOW_TITLE: &str = "Properties - Firelink";
|
||||
const PROPERTIES_DEFAULT_WIDTH: f64 = 960.0;
|
||||
const PROPERTIES_DEFAULT_HEIGHT: f64 = 640.0;
|
||||
const PROPERTIES_MIN_WIDTH: f64 = 680.0;
|
||||
const PROPERTIES_MIN_HEIGHT: f64 = 500.0;
|
||||
const PROPERTIES_WINDOW_READY_EVENT: &str = "properties-window-ready";
|
||||
const PROPERTIES_WINDOW_ACTION_REQUEST_EVENT: &str = "properties-window-action-request";
|
||||
const MAX_PROPERTIES_ACTION_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
const MAX_PROPERTIES_SESSION_ID_BYTES: usize = 128;
|
||||
const MAX_PROPERTIES_REQUEST_ID: u64 = 9_007_199_254_740_991;
|
||||
const MAX_RETIRED_PROPERTIES_SESSIONS: usize = 256;
|
||||
const PROPERTIES_SESSION_HISTORY_EXHAUSTED: &str =
|
||||
"Properties window session history is exhausted; close and reopen the window";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PropertiesWindowRegistry {
|
||||
state: Mutex<RegistryState>,
|
||||
window_creation: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RegistryState {
|
||||
by_download: HashMap<String, String>,
|
||||
by_window: HashMap<String, String>,
|
||||
ready_windows: HashSet<String>,
|
||||
sessions_by_window: HashMap<String, String>,
|
||||
retired_sessions_by_window: HashMap<String, HashSet<String>>,
|
||||
remembered_size: Option<PropertiesWindowSize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PropertiesWindowSize {
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PropertiesWindowReadyEvent {
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PropertiesWindowActionEvent {
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
request_id: u64,
|
||||
action: String,
|
||||
payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl PropertiesWindowRegistry {
|
||||
async fn lock_window_creation(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||
self.window_creation.lock().await
|
||||
}
|
||||
|
||||
pub(crate) fn remember_size(
|
||||
&self,
|
||||
window_label: &str,
|
||||
physical_width: u32,
|
||||
physical_height: u32,
|
||||
scale_factor: f64,
|
||||
) -> Result<(), String> {
|
||||
if !scale_factor.is_finite() || scale_factor <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let width = (f64::from(physical_width) / scale_factor).round();
|
||||
let height = (f64::from(physical_height) / scale_factor).round();
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(window_label) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.remembered_size = Some(PropertiesWindowSize {
|
||||
width: width.max(PROPERTIES_MIN_WIDTH),
|
||||
height: height.max(PROPERTIES_MIN_HEIGHT),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remembered_size(&self) -> Result<Option<(f64, f64)>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.remembered_size
|
||||
.map(|size| (size.width, size.height)))
|
||||
}
|
||||
|
||||
pub fn allocate(&self, download_id: &str) -> Result<String, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if let Some(label) = state.by_download.get(download_id) {
|
||||
return Ok(label.clone());
|
||||
}
|
||||
|
||||
let label = format!("{PROPERTIES_LABEL_PREFIX}{}", Uuid::new_v4().simple());
|
||||
state.by_download.insert(download_id.to_string(), label.clone());
|
||||
state.by_window.insert(label.clone(), download_id.to_string());
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
pub fn download_for_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.by_window
|
||||
.get(label)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn remove_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
let download_id = state.by_window.remove(label);
|
||||
state.ready_windows.remove(label);
|
||||
state.sessions_by_window.remove(label);
|
||||
state.retired_sessions_by_window.remove(label);
|
||||
if let Some(download_id) = &download_id {
|
||||
state.by_download.remove(download_id);
|
||||
}
|
||||
Ok(download_id)
|
||||
}
|
||||
|
||||
pub fn remove_download(&self, download_id: &str) -> Result<Option<String>, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
let label = state.by_download.remove(download_id);
|
||||
if let Some(label) = &label {
|
||||
state.by_window.remove(label);
|
||||
state.ready_windows.remove(label);
|
||||
state.sessions_by_window.remove(label);
|
||||
state.retired_sessions_by_window.remove(label);
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
pub fn window_for_download(&self, download_id: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.by_download
|
||||
.get(download_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn mark_ready(&self, label: &str) -> Result<(), String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(label) {
|
||||
return Err("Properties window is no longer registered".to_string());
|
||||
}
|
||||
state.ready_windows.insert(label.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_session(&self, label: &str, session_id: &str) -> Result<(), String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(label) {
|
||||
return Err("Properties window is no longer registered".to_string());
|
||||
}
|
||||
if state
|
||||
.sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|current| current == session_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if state
|
||||
.retired_sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|retired| retired.contains(session_id))
|
||||
{
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
if state.sessions_by_window.contains_key(label)
|
||||
&& state
|
||||
.retired_sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|retired| retired.len() >= MAX_RETIRED_PROPERTIES_SESSIONS)
|
||||
{
|
||||
return Err(PROPERTIES_SESSION_HISTORY_EXHAUSTED.to_string());
|
||||
}
|
||||
if let Some(previous) = state
|
||||
.sessions_by_window
|
||||
.insert(label.to_string(), session_id.to_string())
|
||||
{
|
||||
state
|
||||
.retired_sessions_by_window
|
||||
.entry(label.to_string())
|
||||
.or_default()
|
||||
.insert(previous);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_for_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.sessions_by_window
|
||||
.get(label)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn session_matches(&self, label: &str, session_id: &str) -> Result<bool, String> {
|
||||
Ok(self.session_for_window(label)?.as_deref() == Some(session_id))
|
||||
}
|
||||
|
||||
/// Validate a session and perform a short synchronous mutation while the
|
||||
/// registry lock is held. Callers use this for cancellation flags so a
|
||||
/// stale session cannot pass validation and then race a replacement
|
||||
/// session before its mutation is recorded.
|
||||
pub fn with_current_session<T>(
|
||||
&self,
|
||||
label: &str,
|
||||
session_id: &str,
|
||||
mutation: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if state.sessions_by_window.get(label).map(String::as_str) != Some(session_id) {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
mutation()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_ready(&self, label: &str) -> Result<bool, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.ready_windows
|
||||
.contains(label))
|
||||
}
|
||||
|
||||
pub fn clear_ready(&self, label: &str) -> Result<(), String> {
|
||||
self.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.ready_windows
|
||||
.remove(label);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_properties_window_label(label: &str) -> bool {
|
||||
label.starts_with(PROPERTIES_LABEL_PREFIX)
|
||||
&& label.len() > PROPERTIES_LABEL_PREFIX.len()
|
||||
&& label[PROPERTIES_LABEL_PREFIX.len()..]
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Custom Tauri commands are not automatically narrowed by a capability's
|
||||
/// window list. Commands that a Properties child may call must therefore
|
||||
/// validate the invoking webview and its registered download explicitly.
|
||||
pub fn ensure_properties_or_main(
|
||||
caller: &tauri::WebviewWindow,
|
||||
registry: &PropertiesWindowRegistry,
|
||||
download_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() == MAIN_WINDOW_LABEL {
|
||||
return Ok(());
|
||||
}
|
||||
if !is_properties_window_label(caller.label())
|
||||
|| registry.download_for_window(caller.label())?.as_deref() != Some(download_id)
|
||||
{
|
||||
return Err("This window is not authorized for the requested download".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_main_window(caller: &tauri::WebviewWindow) -> Result<(), String> {
|
||||
(caller.label() == MAIN_WINDOW_LABEL)
|
||||
.then_some(())
|
||||
.ok_or_else(|| "This command is available only to the main window".to_string())
|
||||
}
|
||||
|
||||
fn emit_to_main<T: Serialize + Clone>(
|
||||
app: &tauri::AppHandle,
|
||||
event: &str,
|
||||
payload: T,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
if app.get_webview_window(MAIN_WINDOW_LABEL).is_none() {
|
||||
return Err("Firelink main window is unavailable".to_string());
|
||||
}
|
||||
|
||||
app.emit_to(
|
||||
tauri::EventTarget::webview_window(MAIN_WINDOW_LABEL),
|
||||
event,
|
||||
payload,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn registered_download_for_caller(
|
||||
caller: &tauri::WebviewWindow,
|
||||
registry: &PropertiesWindowRegistry,
|
||||
) -> Result<String, String> {
|
||||
let label = caller.label();
|
||||
if !is_properties_window_label(label) {
|
||||
return Err("This window is not a Properties window".to_string());
|
||||
}
|
||||
registry
|
||||
.download_for_window(label)?
|
||||
.ok_or_else(|| "Properties window is no longer registered".to_string())
|
||||
}
|
||||
|
||||
fn is_properties_action(action: &str) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
"apply-properties"
|
||||
| "set-torrent-file-selection"
|
||||
| "pause-resume"
|
||||
| "verify-torrent"
|
||||
| "set-download-limit"
|
||||
| "set-torrent-upload-limit"
|
||||
| "set-torrent-peer-options"
|
||||
)
|
||||
}
|
||||
|
||||
fn download_exists(db: &crate::db::DbState, download_id: &str) -> Result<bool, String> {
|
||||
let connection = db.lock()?;
|
||||
Ok(crate::db::load_downloads(&connection)?.into_iter().any(|record| {
|
||||
serde_json::from_str::<serde_json::Value>(&record)
|
||||
.ok()
|
||||
.and_then(|value| value.get("id").and_then(serde_json::Value::as_str).map(str::to_owned))
|
||||
.is_some_and(|id| id == download_id)
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_download_id(download_id: &str) -> Result<(), String> {
|
||||
let trimmed = download_id.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) {
|
||||
return Err("Invalid download ID".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_properties_session_id(session_id: &str) -> Result<(), String> {
|
||||
if session_id.is_empty()
|
||||
|| session_id.len() > MAX_PROPERTIES_SESSION_ID_BYTES
|
||||
|| !session_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err("Invalid Properties window session".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_properties_request_id(request_id: u64) -> Result<(), String> {
|
||||
if request_id == 0 || request_id > MAX_PROPERTIES_REQUEST_ID {
|
||||
return Err("Invalid Properties action request ID".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_download_properties_window(
|
||||
app: tauri::AppHandle,
|
||||
caller: tauri::WebviewWindow,
|
||||
db: tauri::State<'_, crate::db::DbState>,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: 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 {
|
||||
return Err("Only the main window can open Properties windows".to_string());
|
||||
}
|
||||
validate_download_id(&id)?;
|
||||
if !download_exists(&db, &id)? {
|
||||
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)?;
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// Visibility belongs to the native window owner, not to the renderer
|
||||
// handshake. A delayed or lost snapshot must leave a usable loading
|
||||
// window on screen instead of making the open request appear to do
|
||||
// nothing.
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(label);
|
||||
}
|
||||
|
||||
// If the native window disappeared without delivering Destroyed, discard
|
||||
// the old readiness bit before constructing a fresh hidden webview.
|
||||
registry.clear_ready(&label)?;
|
||||
let (initial_width, initial_height) = registry
|
||||
.remembered_size()?
|
||||
.unwrap_or((PROPERTIES_DEFAULT_WIDTH, PROPERTIES_DEFAULT_HEIGHT));
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
|
||||
.title(PROPERTIES_WINDOW_TITLE)
|
||||
.inner_size(initial_width, initial_height)
|
||||
.min_inner_size(PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT)
|
||||
.resizable(true)
|
||||
.always_on_top(false)
|
||||
// Let the child renderer paint its rounded loading shell before the
|
||||
// native window becomes visible. Showing an opaque native surface
|
||||
// here exposes the webview's unpainted white background.
|
||||
.visible(false)
|
||||
// A hidden WebView2 must not request focus during construction. The
|
||||
// native reveal path focuses it after the window is visible.
|
||||
.focused(false);
|
||||
// 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"))]
|
||||
let builder = builder.decorations(false);
|
||||
let build_result = builder.build();
|
||||
if let Err(error) = build_result {
|
||||
// The native builder can report an error after registering a window.
|
||||
// Prefer that registered native owner over discarding its registry
|
||||
// entry and leaving the child inaccessible.
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(label);
|
||||
}
|
||||
let _ = registry.remove_window(&label);
|
||||
return Err(format!("Could not open Properties window: {error}"));
|
||||
}
|
||||
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_properties_window_download_id(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
) -> Result<String, String> {
|
||||
registered_download_for_caller(&caller, ®istry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_send_ready(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
validate_properties_session_id(&session_id)?;
|
||||
let download_id = registered_download_for_caller(&caller, ®istry)?;
|
||||
if let Err(error) = registry.register_session(caller.label(), &session_id) {
|
||||
if error == PROPERTIES_SESSION_HISTORY_EXHAUSTED {
|
||||
let _ = registry.remove_window(caller.label());
|
||||
let _ = caller.close();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
emit_to_main(
|
||||
&app,
|
||||
PROPERTIES_WINDOW_READY_EVENT,
|
||||
PropertiesWindowReadyEvent {
|
||||
window_label: caller.label().to_string(),
|
||||
download_id,
|
||||
session_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_reveal(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
registered_download_for_caller(&caller, ®istry)?;
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?;
|
||||
validate_properties_session_id(&session_id)?;
|
||||
if !registry.session_matches(caller.label(), &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
}
|
||||
registry.mark_ready(caller.label())?;
|
||||
caller.show().map_err(|error| error.to_string())?;
|
||||
caller.set_focus().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_send_action(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: String,
|
||||
request_id: u64,
|
||||
action: String,
|
||||
payload: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
validate_properties_session_id(&session_id)?;
|
||||
validate_properties_request_id(request_id)?;
|
||||
if !is_properties_action(&action)
|
||||
|| action.len() > 64
|
||||
|| action.chars().any(char::is_control)
|
||||
{
|
||||
return Err("Invalid Properties action".to_string());
|
||||
}
|
||||
if let Some(payload) = payload.as_ref() {
|
||||
let payload_size = serde_json::to_vec(payload)
|
||||
.map_err(|_| "Invalid Properties action payload".to_string())?
|
||||
.len();
|
||||
if payload_size > MAX_PROPERTIES_ACTION_PAYLOAD_BYTES {
|
||||
return Err("Properties action payload is too large".to_string());
|
||||
}
|
||||
}
|
||||
let download_id = registered_download_for_caller(&caller, ®istry)?;
|
||||
if !registry.session_matches(caller.label(), &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
emit_to_main(
|
||||
&app,
|
||||
PROPERTIES_WINDOW_ACTION_REQUEST_EVENT,
|
||||
PropertiesWindowActionEvent {
|
||||
window_label: caller.label().to_string(),
|
||||
download_id,
|
||||
session_id,
|
||||
request_id,
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn validate_properties_window_request(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
request_id: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
return Err("Only the main window can validate Properties requests".to_string());
|
||||
}
|
||||
validate_download_id(&download_id)?;
|
||||
validate_properties_session_id(&session_id)?;
|
||||
if let Some(request_id) = request_id {
|
||||
validate_properties_request_id(request_id)?;
|
||||
}
|
||||
if !is_properties_window_label(&window_label) {
|
||||
return Err("Invalid Properties window label".to_string());
|
||||
}
|
||||
if registry.download_for_window(&window_label)?.as_deref() != Some(download_id.as_str()) {
|
||||
return Err("Properties window request does not match its registered download".to_string());
|
||||
}
|
||||
if !registry.session_matches(&window_label, &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_download_properties_window(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let _window_creation_guard = registry.lock_window_creation().await;
|
||||
let label = caller.label();
|
||||
let registered_id = if label == MAIN_WINDOW_LABEL {
|
||||
registry.window_for_download(&id)?.map(|_| id.clone())
|
||||
} else {
|
||||
registry.download_for_window(label)?
|
||||
};
|
||||
if registered_id.as_deref() != Some(id.as_str()) {
|
||||
return Err("Properties window close request is not registered".to_string());
|
||||
}
|
||||
if let Some(window_label) = registry.window_for_download(&id)? {
|
||||
if let Some(window) = app.get_webview_window(&window_label) {
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
// A native window can disappear without delivering its Destroyed
|
||||
// event. Only clear this stale registry entry when there is no
|
||||
// window left to receive a close-request veto from the child.
|
||||
let _ = registry.remove_download(&id);
|
||||
}
|
||||
} else {
|
||||
let _ = registry.remove_download(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn properties_window_registry_remove_for_download(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
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(window) = app.get_webview_window(&label) {
|
||||
// This command is used after the download has already been
|
||||
// removed. It is a forced lifecycle teardown, so a dirty-draft
|
||||
// close-request handler must not be able to leave an orphaned
|
||||
// Properties window behind.
|
||||
let _ = window.destroy();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_are_opaque_and_strictly_scoped() {
|
||||
assert!(is_properties_window_label("properties-0123456789abcdef"));
|
||||
assert!(!is_properties_window_label("properties-download-id"));
|
||||
assert!(!is_properties_window_label("main"));
|
||||
assert!(!is_properties_window_label("properties-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_reuses_one_label_per_download_and_cleans_both_indexes() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let first = registry.allocate("download-a").unwrap();
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
registry.mark_ready(&first).unwrap();
|
||||
assert!(registry.is_ready(&first).unwrap());
|
||||
registry.clear_ready(&first).unwrap();
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
registry.mark_ready(&first).unwrap();
|
||||
assert_eq!(registry.allocate("download-a").unwrap(), first);
|
||||
assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string()));
|
||||
assert_eq!(registry.remove_window(&first).unwrap(), Some("download-a".to_string()));
|
||||
assert_eq!(registry.download_for_window(&first).unwrap(), None);
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
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]
|
||||
fn remembered_size_uses_logical_units_and_survives_window_cleanup() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.remember_size(&label, 1920, 1280, 2.0).unwrap();
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
|
||||
registry.remove_window(&label).unwrap();
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_size_clamps_below_minimum_and_ignores_invalid_scale() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
registry.remember_size(&label, 1, 1, 1.0).unwrap();
|
||||
assert_eq!(
|
||||
registry.remembered_size().unwrap(),
|
||||
Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT))
|
||||
);
|
||||
|
||||
registry.remember_size(&label, 2000, 1600, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
registry.remembered_size().unwrap(),
|
||||
Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_resize_from_unregistered_window_cannot_overwrite_session_size() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.remember_size(&label, 1920, 1280, 2.0).unwrap();
|
||||
registry.remove_window(&label).unwrap();
|
||||
registry
|
||||
.remember_size(&label, 2560, 1600, 2.0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ids_are_rejected() {
|
||||
assert!(validate_download_id("").is_err());
|
||||
assert!(validate_download_id("\n").is_err());
|
||||
assert!(validate_download_id("valid-id").is_ok());
|
||||
assert!(validate_properties_session_id("session-1").is_ok());
|
||||
assert!(validate_properties_session_id("").is_err());
|
||||
assert!(validate_properties_session_id("bad session").is_err());
|
||||
assert!(validate_properties_request_id(1).is_ok());
|
||||
assert!(validate_properties_request_id(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_ready_from_a_retired_session_cannot_reclaim_the_window() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.register_session(&label, "session-old").unwrap();
|
||||
assert!(registry.session_matches(&label, "session-old").unwrap());
|
||||
|
||||
registry.register_session(&label, "session-new").unwrap();
|
||||
assert!(!registry.session_matches(&label, "session-old").unwrap());
|
||||
assert!(registry.session_matches(&label, "session-new").unwrap());
|
||||
assert!(registry.register_session(&label, "session-old").is_err());
|
||||
assert!(registry.session_matches(&label, "session-new").unwrap());
|
||||
|
||||
for index in 0..(MAX_RETIRED_PROPERTIES_SESSIONS - 1) {
|
||||
registry
|
||||
.register_session(&label, &format!("session-{index}"))
|
||||
.unwrap();
|
||||
}
|
||||
assert!(registry.register_session(&label, "session-after-limit").is_err());
|
||||
|
||||
registry.remove_window(&label).unwrap();
|
||||
assert!(!registry.session_matches(&label, "session-new").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_session_mutation_is_fenced_from_retired_sessions() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
registry.register_session(&label, "session-old").unwrap();
|
||||
|
||||
let mut mutations = 0;
|
||||
let stale = registry.with_current_session(&label, "session-old", || {
|
||||
mutations += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert!(stale.is_ok());
|
||||
|
||||
registry.register_session(&label, "session-new").unwrap();
|
||||
let rejected = registry.with_current_session(&label, "session-old", || {
|
||||
mutations += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert!(rejected.is_err());
|
||||
assert_eq!(mutations, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_actions_are_allowlisted() {
|
||||
assert!(is_properties_action("apply-properties"));
|
||||
assert!(is_properties_action("verify-torrent"));
|
||||
assert!(is_properties_action("set-torrent-peer-options"));
|
||||
assert!(!is_properties_action("get_keychain_password"));
|
||||
assert!(!is_properties_action(""));
|
||||
}
|
||||
}
|
||||
+9521
-272
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());
|
||||
}
|
||||
}
|
||||
+193
-5
@@ -50,6 +50,121 @@ pub const BACKOFF_SCHEDULE_429: [Duration; 3] = [
|
||||
/// fall through to a hard `Failed`. Three strikes matches the schedule length.
|
||||
pub const MAX_RETRIES: usize = BACKOFF_SCHEDULE.len();
|
||||
|
||||
/// Detect Aria2's name-resolution failure without treating arbitrary DNS-like
|
||||
/// text as a resolver failure. The numeric code is the authoritative signal;
|
||||
/// the message forms cover older/alternate Aria2 wrappers that omit it.
|
||||
pub fn is_aria2_name_resolution_error(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
aria2_error_code(message).as_deref() == Some("19")
|
||||
|| (lower.contains("name resolution")
|
||||
&& lower.contains("failed")
|
||||
&& lower.contains("could not contact dns"))
|
||||
|| lower.contains("could not contact dns server")
|
||||
}
|
||||
|
||||
/// Extract Aria2's numeric error code without retaining the rest of its
|
||||
/// message. Aria2 error messages can include the request URI, so diagnostics
|
||||
/// should record this code rather than the raw text.
|
||||
pub fn aria2_error_code(message: &str) -> Option<String> {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
let marker = "aria2 error code";
|
||||
let start = lower.find(marker)? + marker.len();
|
||||
let remainder = lower[start..].trim_start_matches(|character: char| {
|
||||
character.is_ascii_whitespace()
|
||||
|| matches!(character, ':' | '=' | '(' | ')' | '[' | ']')
|
||||
});
|
||||
let digits: String = remainder
|
||||
.chars()
|
||||
.take_while(|character| character.is_ascii_digit())
|
||||
.collect();
|
||||
(!digits.is_empty()).then_some(digits)
|
||||
}
|
||||
|
||||
/// Coarse, secret-free classification for retry diagnostics. The returned
|
||||
/// value is intentionally stable and contains no provider or request text.
|
||||
pub fn network_error_class(message: &str) -> &'static str {
|
||||
if is_aria2_name_resolution_error(message) {
|
||||
return "name_resolution";
|
||||
}
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("private/local ip") || lower.contains("ssrf") {
|
||||
return "ssrf_policy";
|
||||
}
|
||||
if lower.contains("permission denied") || lower.contains("operation not permitted") {
|
||||
return "permission";
|
||||
}
|
||||
if lower.contains("timed out") || lower.contains("timeout") {
|
||||
return "timeout";
|
||||
}
|
||||
if lower.contains("connection refused") {
|
||||
return "connection_refused";
|
||||
}
|
||||
if lower.contains("connection reset") || lower.contains("connection aborted") {
|
||||
return "connection_reset";
|
||||
}
|
||||
if [
|
||||
"invalid range",
|
||||
"range not satisfiable",
|
||||
"range request",
|
||||
"range support",
|
||||
"accept-ranges",
|
||||
"bounded range",
|
||||
"byte range",
|
||||
"does not support range",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
{
|
||||
return "range";
|
||||
}
|
||||
if lower.contains("dns") || lower.contains("name resolution") {
|
||||
return "dns";
|
||||
}
|
||||
let has_http_version_token = lower.split_whitespace().any(|token| {
|
||||
let token = token.trim_start_matches(|character: char| {
|
||||
matches!(character, '(' | '[' | '{')
|
||||
});
|
||||
token.starts_with("http/")
|
||||
&& token
|
||||
.chars()
|
||||
.nth(5)
|
||||
.is_some_and(|character| character.is_ascii_digit())
|
||||
});
|
||||
if lower.contains("http error")
|
||||
|| has_http_version_token
|
||||
|| lower.contains("http status")
|
||||
|| lower.contains("response status")
|
||||
|| lower.contains("status code")
|
||||
|| [
|
||||
"status=400",
|
||||
"status=401",
|
||||
"status=403",
|
||||
"status=404",
|
||||
"status=408",
|
||||
"status=410",
|
||||
"status=429",
|
||||
"status=451",
|
||||
"status=500",
|
||||
"status=502",
|
||||
"status=503",
|
||||
"status=504",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| {
|
||||
lower.split_whitespace().any(|token| {
|
||||
token
|
||||
.trim_matches(|character: char| {
|
||||
!character.is_ascii_alphanumeric() && character != '='
|
||||
})
|
||||
== *marker
|
||||
})
|
||||
})
|
||||
{
|
||||
return "http";
|
||||
}
|
||||
"transport"
|
||||
}
|
||||
|
||||
/// Resolve the backoff delay for a 0-based strike. Strikes at or beyond the
|
||||
/// schedule length clamp to the longest slot (10s) rather than panicking, so a
|
||||
/// mis-sized loop degrades gracefully instead of aborting the worker.
|
||||
@@ -64,7 +179,7 @@ pub fn backoff_for(strike: usize) -> Duration {
|
||||
/// Classify an error string as a transient network condition worth retrying.
|
||||
///
|
||||
/// Returns `true` for socket drops, connect/read timeouts, connection resets,
|
||||
/// and HTTP 408 / request-timeout conditions across both download paths:
|
||||
/// and transient HTTP status conditions across both download paths:
|
||||
///
|
||||
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
|
||||
/// `HTTP Error 408`.
|
||||
@@ -122,9 +237,13 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_aria2_name_resolution_error(message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 34] = [
|
||||
const TRANSIENT: [&str; 36] = [
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
@@ -140,6 +259,8 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"connection aborted",
|
||||
"error sending request", // reqwest wrapper for connect/send failures
|
||||
"dns error", // transient resolver failures
|
||||
"name resolution", // aria2 name-resolution failures
|
||||
"could not contact dns", // aria2 c-ares resolver failures
|
||||
"protocol error", // aria2 read/protocol failures after a link drop
|
||||
"tls handshake failure",
|
||||
"ssl/tls handshake failure",
|
||||
@@ -164,9 +285,12 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"timeout.",
|
||||
"invalid range header",
|
||||
];
|
||||
contains_http_status(&m, "408")
|
||||
|| contains_http_status(&m, "429")
|
||||
|| contains_http_status(&m, "503")
|
||||
const TRANSIENT_HTTP_STATUS: [&str; 11] = [
|
||||
"408", "429", "500", "502", "503", "504", "520", "521", "522", "523", "524",
|
||||
];
|
||||
TRANSIENT_HTTP_STATUS
|
||||
.iter()
|
||||
.any(|status| contains_http_status(&m, status))
|
||||
|| TRANSIENT.iter().any(|t| m.contains(t))
|
||||
}
|
||||
|
||||
@@ -242,6 +366,44 @@ mod tests {
|
||||
assert_eq!(MAX_RETRIES, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_aria2_error_code_without_message_material() {
|
||||
for error in [
|
||||
"aria2 error code 19: Could not contact DNS servers",
|
||||
"aria2 error code: 19: Could not contact DNS servers",
|
||||
"aria2 error code (19): Could not contact DNS servers",
|
||||
"aria2 error code=19: Could not contact DNS servers",
|
||||
] {
|
||||
assert_eq!(aria2_error_code(error).as_deref(), Some("19"));
|
||||
}
|
||||
let error =
|
||||
"aria2 error code 19: Could not contact DNS servers for https://example.test/file?token=secret";
|
||||
assert_eq!(network_error_class(error), "name_resolution");
|
||||
assert_eq!(aria2_error_code("aria2 error code: unknown 19"), None);
|
||||
assert!(is_aria2_name_resolution_error("aria2 error code: 19"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_diagnostic_errors_without_echoing_private_details() {
|
||||
assert_eq!(network_error_class("operation not permitted"), "permission");
|
||||
assert_eq!(network_error_class("connect timed out"), "timeout");
|
||||
assert_eq!(network_error_class("invalid range header"), "range");
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for https://example.test/file"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for http://example.test/file"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for https://example.test/file?status=503"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(network_error_class("ranged GET fallback failed"), "transport");
|
||||
assert_eq!(network_error_class("HTTP Error 503"), "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_for_indexes_then_clamps() {
|
||||
assert_eq!(backoff_for(0), Duration::from_secs(2));
|
||||
@@ -292,6 +454,16 @@ mod tests {
|
||||
assert!(is_transient_network_error("The response status is not successful. status=429"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_rpc_http_gateway_errors_as_transient() {
|
||||
for status in [500, 502, 503, 504, 520, 521, 522, 523, 524] {
|
||||
assert!(
|
||||
is_transient_network_error(&format!("HTTP {status} gateway failure")),
|
||||
"HTTP {status} should be retryable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ytdlp_and_aria2_phrasing_as_transient() {
|
||||
assert!(is_transient_network_error(
|
||||
@@ -313,6 +485,22 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_aria2_name_resolution_failures_precisely() {
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers."
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"Name resolution for example.test failed: Could not contact DNS server"
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: connection refused"
|
||||
));
|
||||
assert!(!is_aria2_name_resolution_error(
|
||||
"aria2 error code 8: No URI available"
|
||||
));
|
||||
}
|
||||
|
||||
// --- transient classification: negative cases -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
+125
-8
@@ -5,9 +5,18 @@ use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
|
||||
fn minute_of_day(value: &str) -> Option<u32> {
|
||||
let (hour, minute) = value.split_once(':')?;
|
||||
let hour = hour.parse::<u32>().ok()?;
|
||||
let minute = minute.parse::<u32>().ok()?;
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() != 5
|
||||
|| 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)
|
||||
}
|
||||
|
||||
@@ -16,13 +25,14 @@ fn stop_is_due(
|
||||
stop_minute: Option<u32>,
|
||||
current_minute: u32,
|
||||
last_start_key: &str,
|
||||
triggered_start_key: &str,
|
||||
start_key: &str,
|
||||
last_stop_key: &str,
|
||||
stop_key: &str,
|
||||
) -> bool {
|
||||
stop_time_enabled
|
||||
&& stop_minute.is_some_and(|stop| current_minute >= stop)
|
||||
&& last_start_key == start_key
|
||||
&& (last_start_key == start_key || triggered_start_key == start_key)
|
||||
&& last_stop_key != stop_key
|
||||
}
|
||||
|
||||
@@ -33,6 +43,7 @@ struct OvernightStopCheck<'a> {
|
||||
current_minute: u32,
|
||||
previous_day_allowed: bool,
|
||||
last_start_key: &'a str,
|
||||
triggered_start_key: &'a str,
|
||||
previous_start_key: &'a str,
|
||||
last_stop_key: &'a str,
|
||||
stop_key: &'a str,
|
||||
@@ -46,6 +57,7 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
|
||||
current_minute,
|
||||
previous_day_allowed,
|
||||
last_start_key,
|
||||
triggered_start_key,
|
||||
previous_start_key,
|
||||
last_stop_key,
|
||||
stop_key,
|
||||
@@ -55,10 +67,31 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
|
||||
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
|
||||
stop < start && current_minute >= stop && current_minute < start
|
||||
})
|
||||
&& last_start_key == previous_start_key
|
||||
&& (last_start_key == previous_start_key || triggered_start_key == previous_start_key)
|
||||
&& last_stop_key != stop_key
|
||||
}
|
||||
|
||||
fn persist_scheduler_start_trigger(
|
||||
app_handle: &tauri::AppHandle,
|
||||
settings_cache: &Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
key: &str,
|
||||
) {
|
||||
if let Err(error) = crate::settings::update_settings_state(app_handle, |state| {
|
||||
state.insert(
|
||||
"schedulerTriggeredStartKey".to_string(),
|
||||
serde_json::json!(key),
|
||||
);
|
||||
}) {
|
||||
log::warn!("Failed to persist scheduler start trigger: {error}");
|
||||
}
|
||||
|
||||
if let Ok(mut settings) = settings_cache.write() {
|
||||
if let Some(settings) = settings.as_mut() {
|
||||
settings.scheduler_triggered_start_key = Some(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(
|
||||
app_handle: tauri::AppHandle,
|
||||
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
@@ -66,6 +99,11 @@ pub fn spawn_scheduler(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
|
||||
// Renderer acknowledgement remains the durable completion record, but
|
||||
// a native dispatch marker also survives a closed/unmounted webview so
|
||||
// an overnight stop does not become permanently ineligible. The
|
||||
// process-local start key also covers the same-loop event/stop check.
|
||||
let mut triggered_start_key = String::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
@@ -74,11 +112,21 @@ pub fn spawn_scheduler(
|
||||
(
|
||||
settings.scheduler.clone(),
|
||||
settings.scheduler_last_start_key.clone(),
|
||||
settings
|
||||
.scheduler_triggered_start_key
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
settings.scheduler_last_stop_key.clone(),
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings {
|
||||
if let Some((
|
||||
scheduler,
|
||||
scheduler_last_start_key,
|
||||
persisted_triggered_start_key,
|
||||
scheduler_last_stop_key,
|
||||
)) = settings
|
||||
{
|
||||
if !scheduler.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -108,13 +156,29 @@ pub fn spawn_scheduler(
|
||||
.get("start")
|
||||
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
let _ = app_handle.emit(
|
||||
if persisted_triggered_start_key != start_key
|
||||
&& triggered_start_key != start_key
|
||||
{
|
||||
// Record the dispatch intent before emitting so a
|
||||
// crash between the native event and renderer ack
|
||||
// still makes an overnight stop eligible. Start
|
||||
// events remain retryable until the renderer acks
|
||||
// them, which covers startup/listener races.
|
||||
persist_scheduler_start_trigger(
|
||||
&app_handle,
|
||||
&settings_cache,
|
||||
&start_key,
|
||||
);
|
||||
}
|
||||
if app_handle.emit(
|
||||
"schedule-trigger",
|
||||
serde_json::json!({
|
||||
"action": "start",
|
||||
"key": start_key
|
||||
}),
|
||||
);
|
||||
).is_ok() {
|
||||
triggered_start_key = start_key.clone();
|
||||
}
|
||||
last_emit.insert("start", std::time::Instant::now());
|
||||
}
|
||||
|
||||
@@ -125,6 +189,13 @@ pub fn spawn_scheduler(
|
||||
stop_minute,
|
||||
current_minute,
|
||||
&scheduler_last_start_key,
|
||||
if triggered_start_key == start_key {
|
||||
start_key.as_str()
|
||||
} else if persisted_triggered_start_key == start_key {
|
||||
start_key.as_str()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
&start_key,
|
||||
&scheduler_last_stop_key,
|
||||
&stop_key,
|
||||
@@ -146,6 +217,13 @@ pub fn spawn_scheduler(
|
||||
current_minute,
|
||||
previous_day_allowed,
|
||||
last_start_key: &scheduler_last_start_key,
|
||||
triggered_start_key: if triggered_start_key == previous_start_key {
|
||||
previous_start_key.as_str()
|
||||
} else if persisted_triggered_start_key == previous_start_key {
|
||||
previous_start_key.as_str()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
previous_start_key: &previous_start_key,
|
||||
last_stop_key: &scheduler_last_stop_key,
|
||||
stop_key: &stop_key,
|
||||
@@ -185,6 +263,10 @@ mod tests {
|
||||
fn rejects_invalid_scheduler_times() {
|
||||
assert_eq!(minute_of_day("24:00"), 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);
|
||||
}
|
||||
|
||||
@@ -195,6 +277,7 @@ mod tests {
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
@@ -204,12 +287,43 @@ mod tests {
|
||||
Some(480),
|
||||
600,
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_accepts_process_local_start_when_renderer_ack_is_missing() {
|
||||
assert!(stop_is_due(
|
||||
true,
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_stop_accepts_persisted_start_trigger_when_app_restarts() {
|
||||
assert!(overnight_stop_is_due(OvernightStopCheck {
|
||||
stop_time_enabled: true,
|
||||
start_minute: Some(1320),
|
||||
stop_minute: Some(360),
|
||||
current_minute: 420,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "",
|
||||
triggered_start_key: "2026-06-22-start",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_stop_uses_the_previous_day_start() {
|
||||
assert!(overnight_stop_is_due(OvernightStopCheck {
|
||||
@@ -219,6 +333,7 @@ mod tests {
|
||||
current_minute: 420,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
@@ -230,6 +345,7 @@ mod tests {
|
||||
current_minute: 1380,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-22-stop",
|
||||
@@ -241,6 +357,7 @@ mod tests {
|
||||
current_minute: 420,
|
||||
previous_day_allowed: false,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
|
||||
+959
-4
File diff suppressed because it is too large
Load Diff
+286
-2
@@ -5,6 +5,11 @@ pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
const ARIA2_DATA_DIR: &str = "aria2";
|
||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||
const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt";
|
||||
const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
@@ -14,6 +19,15 @@ pub enum StorageMode {
|
||||
|
||||
impl StorageMode {
|
||||
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 {
|
||||
return Self::Standard;
|
||||
};
|
||||
@@ -104,10 +118,149 @@ impl StorageLayout {
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
|
||||
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
(
|
||||
directory.join(ARIA2_DHT_FILE),
|
||||
directory.join(ARIA2_DHT6_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn aria2_server_stat_path(&self) -> PathBuf {
|
||||
self.data_dir
|
||||
.join(ARIA2_DATA_DIR)
|
||||
.join(ARIA2_SERVER_STAT_FILE)
|
||||
}
|
||||
|
||||
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||
/// the table contents; Firelink owns this exact location and must never
|
||||
/// fall back to a user-global default when it cannot establish it.
|
||||
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err(format!(
|
||||
"Aria2 state directory contains a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
|
||||
match std::fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"Aria2 state directory is a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
return Err(format!(
|
||||
"Aria2 state path is not a directory: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(&directory).map_err(|error| {
|
||||
format!(
|
||||
"failed to create Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.aria2_dht_paths())
|
||||
}
|
||||
|
||||
/// Prepare the exact cache file used by Aria2's adaptive URI selector.
|
||||
/// The cache is non-authoritative: malformed or oversized contents are
|
||||
/// reset to empty, while symlinks and non-files disable the cache instead
|
||||
/// of allowing Aria2 to write outside Firelink's storage boundary.
|
||||
pub fn prepare_aria2_server_stat_path(&self) -> Result<PathBuf, String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err("Aria2 server-stat directory contains a symlink".to_string());
|
||||
}
|
||||
std::fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?;
|
||||
|
||||
let path = self.aria2_server_stat_path();
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err("Aria2 server-stat cache is a symlink".to_string());
|
||||
}
|
||||
Ok(metadata) if !metadata.is_file() => {
|
||||
return Err("Aria2 server-stat cache is not a regular file".to_string());
|
||||
}
|
||||
Ok(metadata) => {
|
||||
let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.is_some_and(|contents| aria2_server_stat_is_valid(&contents));
|
||||
if !valid {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to reset Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to create Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 server-stat cache: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn aria2_server_stat_is_valid(contents: &str) -> bool {
|
||||
contents.lines().all(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if line.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
let fields = line
|
||||
.split(',')
|
||||
.filter_map(|field| field.trim().split_once('='))
|
||||
.map(|(name, value)| (name.trim(), value.trim()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
["host", "protocol", "dl_speed", "last_updated", "status"]
|
||||
.iter()
|
||||
.all(|name| fields.get(name).is_some_and(|value| !value.is_empty()))
|
||||
})
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symlink_component(path) {
|
||||
if crate::path_has_symbolic_link_component(path) {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked component: '{}'",
|
||||
path.display()
|
||||
@@ -154,7 +307,7 @@ fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -182,6 +335,109 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn test_layout(data_dir: &Path) -> StorageLayout {
|
||||
let data_dir = fs::canonicalize(data_dir).unwrap();
|
||||
StorageLayout {
|
||||
mode: StorageMode::Standard,
|
||||
data_dir: data_dir.clone(),
|
||||
log_dir: data_dir.join("logs"),
|
||||
webview_dir: data_dir.join("webview"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
layout.aria2_dht_paths(),
|
||||
(
|
||||
root_path.join("aria2/dht.dat"),
|
||||
root_path.join("aria2/dht6.dat")
|
||||
)
|
||||
);
|
||||
let prepared = layout.prepare_aria2_dht_paths().unwrap();
|
||||
assert_eq!(prepared, layout.aria2_dht_paths());
|
||||
assert!(root_path.join("aria2").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not a directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
let path = layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(path, layout.aria2_server_stat_path());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
fs::write(&path, "not an aria2 server profile\n").unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
let valid =
|
||||
"host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n";
|
||||
fs::write(&path, valid).unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_rejects_symlink_output() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
symlink(
|
||||
target.path().join("outside"),
|
||||
layout.aria2_server_stat_path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(layout.prepare_aria2_server_stat_path().is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
symlink(target.path(), root_path.join("aria2")).unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
@@ -208,4 +464,32 @@ mod tests {
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_junctions_for_redirected_storage_paths() {
|
||||
use std::process::Command;
|
||||
|
||||
let parent = TempDir::new().unwrap();
|
||||
let spaced_parent = parent.path().join("firelink test data");
|
||||
fs::create_dir(&spaced_parent).unwrap();
|
||||
let root = TempDir::new_in(&spaced_parent).unwrap();
|
||||
let target = TempDir::new_in(&spaced_parent).unwrap();
|
||||
let redirected = root.path().join("redirected");
|
||||
let target_storage = target.path().join("firelink");
|
||||
fs::create_dir(&target_storage).unwrap();
|
||||
|
||||
let status = Command::new("cmd")
|
||||
.args(["/D", "/C", "mklink", "/J"])
|
||||
.arg(&redirected)
|
||||
.arg(target.path())
|
||||
.status()
|
||||
.expect("Windows junction creation command should start");
|
||||
assert!(status.success(), "mklink /J failed with status {status}");
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_storage_path(&redirected.join("firelink")).unwrap(),
|
||||
fs::canonicalize(target_storage).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
use crate::ipc::MainWindowSize;
|
||||
|
||||
pub const MAIN_WINDOW_DEFAULT_WIDTH: u32 = 1280;
|
||||
pub const MAIN_WINDOW_DEFAULT_HEIGHT: u32 = 800;
|
||||
pub const MAIN_WINDOW_MIN_WIDTH: u32 = 960;
|
||||
pub const MAIN_WINDOW_MIN_HEIGHT: u32 = 640;
|
||||
pub const MAIN_WINDOW_MAX_WIDTH: u32 = 16_384;
|
||||
pub const MAIN_WINDOW_MAX_HEIGHT: u32 = 16_384;
|
||||
|
||||
pub fn default_main_window_size() -> MainWindowSize {
|
||||
MainWindowSize {
|
||||
width: MAIN_WINDOW_DEFAULT_WIDTH,
|
||||
height: MAIN_WINDOW_DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_main_window_size(size: Option<&MainWindowSize>) -> Option<MainWindowSize> {
|
||||
let size = size?;
|
||||
if size.width < MAIN_WINDOW_MIN_WIDTH
|
||||
|| size.height < MAIN_WINDOW_MIN_HEIGHT
|
||||
|| size.width > MAIN_WINDOW_MAX_WIDTH
|
||||
|| size.height > MAIN_WINDOW_MAX_HEIGHT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(size.clone())
|
||||
}
|
||||
|
||||
pub fn clamp_main_window_size(
|
||||
size: MainWindowSize,
|
||||
work_area_width: u32,
|
||||
work_area_height: u32,
|
||||
) -> MainWindowSize {
|
||||
let width_limit = work_area_width.max(MAIN_WINDOW_MIN_WIDTH);
|
||||
let height_limit = work_area_height.max(MAIN_WINDOW_MIN_HEIGHT);
|
||||
MainWindowSize {
|
||||
width: size.width.min(width_limit),
|
||||
height: size.height.min(height_limit),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
clamp_main_window_size, default_main_window_size, normalize_main_window_size,
|
||||
MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH,
|
||||
};
|
||||
use crate::ipc::MainWindowSize;
|
||||
|
||||
#[test]
|
||||
fn default_size_matches_the_main_window_configuration() {
|
||||
assert_eq!(default_main_window_size().width, 1280);
|
||||
assert_eq!(default_main_window_size().height, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sizes_outside_the_persisted_bounds() {
|
||||
assert!(normalize_main_window_size(Some(&MainWindowSize {
|
||||
width: MAIN_WINDOW_MIN_WIDTH - 1,
|
||||
height: 800,
|
||||
}))
|
||||
.is_none());
|
||||
assert!(normalize_main_window_size(Some(&MainWindowSize {
|
||||
width: 1280,
|
||||
height: 16_385,
|
||||
}))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_a_valid_size_to_the_available_work_area() {
|
||||
let clamped = clamp_main_window_size(
|
||||
MainWindowSize {
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
},
|
||||
1280,
|
||||
720,
|
||||
);
|
||||
assert_eq!(clamped.width, 1280);
|
||||
assert_eq!(clamped.height, 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_minimum_when_the_work_area_is_shorter_than_the_minimum() {
|
||||
let clamped = clamp_main_window_size(
|
||||
MainWindowSize {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
},
|
||||
800,
|
||||
500,
|
||||
);
|
||||
assert_eq!(clamped.width, MAIN_WINDOW_MIN_WIDTH);
|
||||
assert_eq!(clamped.height, MAIN_WINDOW_MIN_HEIGHT);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.2",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "node scripts/before-tauri-build.js",
|
||||
"beforeBundleCommand": "node scripts/before-tauri-bundle.js",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -16,7 +17,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false
|
||||
@@ -36,14 +37,27 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"engine-dist/": "engine-dist/",
|
||||
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["torrent"],
|
||||
"mimeType": "application/x-bittorrent",
|
||||
"name": "BitTorrent file",
|
||||
"description": "BitTorrent metadata file",
|
||||
"role": "Viewer",
|
||||
"rank": "Alternate",
|
||||
"exportedType": {
|
||||
"identifier": "org.bittorrent.torrent",
|
||||
"conformsTo": ["public.data", "public.item"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["firelink"]
|
||||
"schemes": ["firelink", "magnet"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
"decorations": false
|
||||
"decorations": false,
|
||||
"shadow": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
"decorations": false,
|
||||
"shadow": false
|
||||
"shadow": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
@@ -123,3 +123,73 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
|
||||
);
|
||||
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::{
|
||||
Aria2RecreateOutcome, Aria2RefreshOutcome, Aria2ResolverMode, QueueManager, QueuedTask,
|
||||
SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
|
||||
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -27,15 +27,7 @@ struct CountingSpawner {
|
||||
torrent_peer_options_release: tokio::sync::Notify,
|
||||
add_speed_limits: std::sync::Mutex<Vec<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<(
|
||||
Aria2ResolverMode,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i32>,
|
||||
)>,
|
||||
>,
|
||||
add_transfer_context: std::sync::Mutex<Vec<(Option<String>, Option<String>, Option<i32>)>>,
|
||||
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||
speed_limit_started: tokio::sync::Notify,
|
||||
speed_limit_release: tokio::sync::Notify,
|
||||
@@ -220,7 +212,6 @@ impl CountingSpawner {
|
||||
torrent_peer_options_release: tokio::sync::Notify::new(),
|
||||
add_speed_limits: 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()),
|
||||
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||
speed_limit_started: tokio::sync::Notify::new(),
|
||||
@@ -305,12 +296,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
payload.torrent_max_peers,
|
||||
payload.torrent_peer_speed_limit.clone(),
|
||||
));
|
||||
self.add_resolver_modes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(payload.aria2_resolver_mode);
|
||||
self.add_transfer_context.lock().unwrap().push((
|
||||
payload.aria2_resolver_mode,
|
||||
payload.headers.clone(),
|
||||
payload.proxy.clone(),
|
||||
payload.connections,
|
||||
@@ -2297,16 +2283,14 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
manager.set_aria2_async_dns_supported(true);
|
||||
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.proxy = Some("http://127.0.0.1:8123".to_string());
|
||||
manager.push(task).await.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
@@ -2341,32 +2325,19 @@ async fn resolver_failure_uses_one_system_fallback_without_retry_budget() {
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("resolver failure should re-add once with the system resolver");
|
||||
assert_eq!(
|
||||
*spawner.add_resolver_modes.lock().unwrap(),
|
||||
vec![Aria2ResolverMode::Automatic, Aria2ResolverMode::System]
|
||||
);
|
||||
.expect("resolver failure should re-add once on the non-blocking resolver");
|
||||
assert_eq!(
|
||||
*spawner.add_transfer_context.lock().unwrap(),
|
||||
vec![
|
||||
(
|
||||
Aria2ResolverMode::Automatic,
|
||||
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,
|
||||
),
|
||||
(Some("X-Test: retained".to_string()), None, None),
|
||||
(Some("X-Test: retained".to_string()), None, None),
|
||||
]
|
||||
);
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
|
||||
// A second resolver failure is now on the system mode. With max_tries=0
|
||||
// it must terminate instead of switching back or consuming another add.
|
||||
// The configured retry budget is exhausted after one non-blocking retry.
|
||||
// A repeated DNS error must terminate without entering system DNS or
|
||||
// scheduling another add.
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
|
||||
@@ -94,3 +94,35 @@ async fn production_rpc_client_preserves_http_gateway_context() {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
+305
-134
@@ -10,11 +10,17 @@ import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { flushDownloadPersistence, initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import {
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore,
|
||||
waitForSettingsPersistence
|
||||
} from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { updateDockBadge } from './utils/dockBadge';
|
||||
@@ -33,6 +39,25 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||
import {
|
||||
resolveFallbackFilter,
|
||||
shouldRestoreSidebarRevealFocus,
|
||||
shouldRestoreSidebarToggleFocus
|
||||
} from './utils/sidebarFocus';
|
||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
consumeSchedulerHandoffIds,
|
||||
handoffSupersededSchedulerIds,
|
||||
isSchedulerControlCurrent,
|
||||
registerPostActionCanceller
|
||||
} from './utils/schedulerControl';
|
||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||
import { useWindowFocusState } from './utils/windowFocus';
|
||||
import { useWindowMaximizedState } from './utils/windowMaximized';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
@@ -49,9 +74,6 @@ const SettingsView = lazy(loadSettingsView);
|
||||
const SchedulerView = lazy(loadSchedulerView);
|
||||
const SpeedLimiterView = lazy(loadSpeedLimiterView);
|
||||
const LogsView = lazy(loadLogsView);
|
||||
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
|
||||
default: module.PropertiesModal,
|
||||
})));
|
||||
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
|
||||
default: module.DeleteConfirmationModal,
|
||||
})));
|
||||
@@ -100,7 +122,6 @@ const PageLoadingFallback = () => {
|
||||
};
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
let powerPreferencesSync: Promise<void> = Promise.resolve();
|
||||
|
||||
const waitForSettingsHydration = (): Promise<void> => {
|
||||
@@ -168,6 +189,8 @@ const playCompletionChime = async () => {
|
||||
function App() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const platform = usePlatformInfo();
|
||||
const isWindowActive = useWindowFocusState();
|
||||
const isWindowMaximized = useWindowMaximizedState();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
@@ -187,6 +210,11 @@ function App() {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||
});
|
||||
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreSidebarFocusRef = useRef(false);
|
||||
const restoreRevealFocusRef = useRef(false);
|
||||
|
||||
const theme = useSettingsStore(state => state.theme);
|
||||
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||
@@ -226,9 +254,20 @@ function App() {
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
||||
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 queuedCount = downloads.filter(download =>
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
@@ -247,8 +286,11 @@ function App() {
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const pendingPostActionToastId = useRef<string | null>(null);
|
||||
const pendingForceActionToastId = useRef<string | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const extensionProcessing = useRef(createSerialTaskQueue());
|
||||
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingStartupInputs = useRef<Array<
|
||||
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
||||
@@ -259,7 +301,6 @@ function App() {
|
||||
const preventsDisplaySleepWhileDownloading = useSettingsStore(
|
||||
state => state.preventsDisplaySleepWhileDownloading
|
||||
);
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
|
||||
@@ -293,7 +334,15 @@ function App() {
|
||||
window.clearTimeout(pendingPostActionTimer.current);
|
||||
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 update = frontendReadyUpdate.current
|
||||
@@ -303,22 +352,83 @@ function App() {
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
const acknowledgeExtensionDownload = useCallback(async (requestId?: string) => {
|
||||
if (!requestId) return;
|
||||
try {
|
||||
await invoke('ack_extension_download', { requestId });
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processExtensionDownload = useCallback(async (payload: ExtensionDownloadRequest) => {
|
||||
await useDownloadStore.getState().handleExtensionDownload(payload);
|
||||
await acknowledgeExtensionDownload(payload.request_id);
|
||||
}, [acknowledgeExtensionDownload]);
|
||||
|
||||
const enqueueAddInput = useCallback((task: () => void | Promise<void>) => {
|
||||
return extensionProcessing.current(task);
|
||||
}, []);
|
||||
|
||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||
clearPendingPostActionTimer();
|
||||
|
||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const cancel = () => {
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
const showForceActionToast = () => {
|
||||
if (pendingForceActionToastId.current !== null) {
|
||||
removeToast(pendingForceActionToastId.current);
|
||||
pendingForceActionToastId.current = null;
|
||||
}
|
||||
const proceed = () => {
|
||||
if (pendingForceActionToastId.current !== null) {
|
||||
removeToast(pendingForceActionToastId.current);
|
||||
pendingForceActionToastId.current = null;
|
||||
}
|
||||
invoke('perform_system_action', { action, force: true }).catch(error => {
|
||||
console.error('Forced scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
pendingForceActionToastId.current = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
duration: 0,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{t($ => $.app.systemActionCancelled)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={proceed}
|
||||
>
|
||||
{t($ => $.app.systemActionProceedAnyway)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
};
|
||||
const perform = (force: boolean) => {
|
||||
invoke('perform_system_action', { action, force }).catch(error => {
|
||||
const detail = String(error);
|
||||
if (!force && detail.includes('active or queued')) {
|
||||
showForceActionToast();
|
||||
return;
|
||||
}
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
toastId = addToast({
|
||||
const toastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
onDismiss: clearPendingPostActionTimer,
|
||||
@@ -328,18 +438,19 @@ function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={cancel}
|
||||
onClick={clearPendingPostActionTimer}
|
||||
>
|
||||
{t($ => $.actions.cancel)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
pendingPostActionToastId.current = toastId;
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
if (toastId !== null) {
|
||||
if (pendingPostActionToastId.current === toastId) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
pendingPostActionToastId.current = null;
|
||||
}
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
@@ -350,58 +461,78 @@ function App() {
|
||||
isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionCancelled),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
showForceActionToast();
|
||||
return;
|
||||
}
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
perform(false);
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
sidebarResizeCleanupRef.current?.();
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture is best-effort; the session still listens on window.
|
||||
}
|
||||
const cleanup = createSidebarResizeSession({
|
||||
windowTarget: window,
|
||||
body: document.body,
|
||||
captureTarget: event.currentTarget,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: sidebarWidth,
|
||||
isRight: isSidebarOnRight,
|
||||
onWidth: setSidebarWidth,
|
||||
});
|
||||
sidebarResizeCleanupRef.current = cleanup;
|
||||
};
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const delta = isSidebarOnRight
|
||||
? startX - moveEvent.clientX
|
||||
: moveEvent.clientX - startX;
|
||||
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
useEffect(() => () => {
|
||||
sidebarResizeCleanupRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
document.body.classList.remove('is-resizing');
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isSidebarVisible) {
|
||||
if (restoreSidebarFocusRef.current) {
|
||||
restoreSidebarFocusRef.current = false;
|
||||
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (restoreRevealFocusRef.current) {
|
||||
restoreRevealFocusRef.current = false;
|
||||
sidebarToggleRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [isSidebarVisible]);
|
||||
|
||||
document.body.classList.add('is-resizing');
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
const handleSidebarToggle = () => {
|
||||
const activeElement = document.activeElement;
|
||||
if (isSidebarVisible) {
|
||||
restoreSidebarFocusRef.current = shouldRestoreSidebarRevealFocus(
|
||||
activeElement,
|
||||
document.querySelector('.app-sidebar-shell'),
|
||||
);
|
||||
restoreRevealFocusRef.current = false;
|
||||
} else {
|
||||
restoreRevealFocusRef.current = shouldRestoreSidebarToggleFocus(
|
||||
activeElement,
|
||||
sidebarRevealRef.current,
|
||||
);
|
||||
restoreSidebarFocusRef.current = false;
|
||||
}
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return clearPendingPostActionTimer;
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTransferCount > 0) {
|
||||
const unregister = registerPostActionCanceller(clearPendingPostActionTimer);
|
||||
return () => {
|
||||
unregister();
|
||||
clearPendingPostActionTimer();
|
||||
}
|
||||
}, [activeTransferCount, clearPendingPostActionTimer]);
|
||||
};
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
initMediaDomains();
|
||||
@@ -409,8 +540,57 @@ function App() {
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposePersistence: (() => void) | null = null;
|
||||
let active = true;
|
||||
let exitRequested = false;
|
||||
let exiting = false;
|
||||
let settingsHydrated = useSettingsStore.persist.hasHydrated();
|
||||
let latestSizeBeforeHydration: MainWindowSize | null = null;
|
||||
const unlistenSettingsHydration = settingsHydrated
|
||||
? null
|
||||
: useSettingsStore.persist.onFinishHydration(() => {
|
||||
settingsHydrated = true;
|
||||
const size = latestSizeBeforeHydration;
|
||||
latestSizeBeforeHydration = null;
|
||||
if (size && active && !exitRequested && !exiting) {
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
const mainWindowSizePersistence = createMainWindowSizePersistence({
|
||||
appWindow: getCurrentWindow(),
|
||||
onSize: size => {
|
||||
if (!active || exiting) return;
|
||||
if (!settingsHydrated) {
|
||||
latestSizeBeforeHydration = size;
|
||||
return;
|
||||
}
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
const exitListener = listen('app-exit-requested', async () => {
|
||||
exitRequested = true;
|
||||
try {
|
||||
await mainWindowSizePersistence.flush();
|
||||
await waitForSettingsPersistence();
|
||||
await flushDownloadPersistence();
|
||||
} catch (error) {
|
||||
console.error('Failed to flush download state before exit:', error);
|
||||
} finally {
|
||||
exiting = true;
|
||||
latestSizeBeforeHydration = null;
|
||||
await invoke('ack_frontend_exit').catch(error => {
|
||||
console.error('Failed to acknowledge frontend exit flush:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
void exitListener.then(unlisten => {
|
||||
if (active) unlistenExit = unlisten;
|
||||
else unlisten();
|
||||
}).catch(error => {
|
||||
console.error('Failed to listen for frontend exit flush:', error);
|
||||
});
|
||||
const initialize = async () => {
|
||||
let unlistenDownload: (() => void) | null = null;
|
||||
let unlistenTerminalState: (() => void) | null = null;
|
||||
@@ -418,6 +598,9 @@ function App() {
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
mainWindowSizePersistence.dispose();
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenTerminalState?.();
|
||||
unlistenTerminalState = null;
|
||||
unlistenExtension?.();
|
||||
@@ -558,16 +741,11 @@ function App() {
|
||||
}
|
||||
});
|
||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||
if (event.payload.request_id) {
|
||||
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
});
|
||||
}
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
enqueueAddInput(() => processExtensionDownload(event.payload)).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
@@ -576,7 +754,7 @@ function App() {
|
||||
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(event.payload));
|
||||
});
|
||||
|
||||
cleanupListeners = disposeListeners;
|
||||
@@ -601,6 +779,7 @@ function App() {
|
||||
try {
|
||||
await initializeDownloadState();
|
||||
if (!active) return;
|
||||
disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
|
||||
} catch (error) {
|
||||
disposeListeners();
|
||||
cleanupListeners = null;
|
||||
@@ -624,8 +803,14 @@ function App() {
|
||||
pendingStartupInputs.current = [];
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenSettingsHydration?.();
|
||||
mainWindowSizePersistence.dispose();
|
||||
disposePersistence?.();
|
||||
disposePersistence = null;
|
||||
};
|
||||
}, [addToast, queueFrontendReadyUpdate]);
|
||||
}, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
@@ -647,14 +832,14 @@ function App() {
|
||||
const pendingInputs = pendingStartupInputs.current.splice(0);
|
||||
for (const input of pendingInputs) {
|
||||
if (input.type === 'extension') {
|
||||
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
||||
enqueueAddInput(() => processExtensionDownload(input.payload)).catch(error => {
|
||||
console.error('Failed to handle queued browser extension download:', error);
|
||||
});
|
||||
} else {
|
||||
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
||||
enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(input.payload));
|
||||
}
|
||||
}
|
||||
}, [coreReady, showKeychainModal]);
|
||||
}, [coreReady, enqueueAddInput, processExtensionDownload, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||
@@ -669,17 +854,13 @@ function App() {
|
||||
});
|
||||
}, [addToast, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-family', fontFamily);
|
||||
}, [fontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
}, [appFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-list-density', listRowDensity);
|
||||
}, [listRowDensity]);
|
||||
useEffect(() => synchronizeDocumentAppearance(window, {
|
||||
theme,
|
||||
fontFamily,
|
||||
appFontSize,
|
||||
listRowDensity,
|
||||
locale: resolveAppLocale(i18n.language),
|
||||
}), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdate = () => {
|
||||
@@ -783,6 +964,11 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// Scope duplicate suppression to this listener instance. A module-level
|
||||
// set can retain a key across a webview/listener restart while the old
|
||||
// async handler is still unwinding, causing the replacement listener to
|
||||
// drop the only retry for that scheduled action.
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
const payload = event.payload;
|
||||
@@ -792,6 +978,7 @@ function App() {
|
||||
if (payload.action === 'start') {
|
||||
clearPendingPostActionTimer();
|
||||
const scheduledQueueIds = getScheduledQueueIds();
|
||||
const generation = beginSchedulerControl(scheduledQueueIds);
|
||||
if (scheduledQueueIds.length === 0) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
@@ -808,10 +995,24 @@ function App() {
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = startedResults.flat();
|
||||
if (!isSchedulerControlCurrent(generation)) {
|
||||
const handoffIds = handoffSupersededSchedulerIds(
|
||||
acceptedIds,
|
||||
id => useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID
|
||||
);
|
||||
await Promise.allSettled(
|
||||
acceptedIds
|
||||
.filter(id => !handoffIds.has(id))
|
||||
.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const handoffIds = consumeSchedulerHandoffIds(generation);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
(previouslyTrackedIds.has(download.id) || handoffIds.has(download.id)) &&
|
||||
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
@@ -821,14 +1022,18 @@ function App() {
|
||||
state.setSchedulerRunning(activeIds.length > 0);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
} else if (payload.action === 'stop') {
|
||||
const generation = beginSchedulerControl();
|
||||
// A stop event can race with the completion effect's post-action
|
||||
// countdown after it has already cleared the tracked IDs. Always
|
||||
// cancel that pending action before applying the stop transition.
|
||||
clearPendingPostActionTimer();
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
if (failedPauses > 0 && isSchedulerControlCurrent(generation)) {
|
||||
addToast({
|
||||
message: failedPauses === 1
|
||||
? t($ => $.app.schedulerPauseOneFailed)
|
||||
@@ -838,8 +1043,10 @@ function App() {
|
||||
});
|
||||
}
|
||||
}
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
if (isSchedulerControlCurrent(generation)) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
}
|
||||
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
|
||||
}
|
||||
} finally {
|
||||
@@ -848,6 +1055,7 @@ function App() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
beginSchedulerControl();
|
||||
unlisten.then(f => f()).catch(console.error);
|
||||
};
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
@@ -871,15 +1079,7 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: t($ => $.app.scheduledActionSkippedActive),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
@@ -1025,41 +1225,8 @@ function App() {
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
// Remove all theme classes first
|
||||
root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
root.classList.add(systemDark ? 'theme-dark' : 'theme-light');
|
||||
root.dataset.resolvedTheme = systemDark ? 'dark' : 'light';
|
||||
root.style.colorScheme = systemDark ? 'dark' : 'light';
|
||||
if (systemDark) root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.add(`theme-${theme}`);
|
||||
if (['dark', 'dracula', 'nord'].includes(theme)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
|
||||
root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = () => applyTheme();
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
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'
|
||||
} ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
@@ -1078,6 +1245,8 @@ function App() {
|
||||
} ${
|
||||
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-hidden={!isSidebarVisible}
|
||||
inert={!isSidebarVisible}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
|
||||
@@ -1090,6 +1259,8 @@ function App() {
|
||||
>
|
||||
<Sidebar
|
||||
selectedFilter={filter}
|
||||
toggleButtonRef={sidebarToggleRef}
|
||||
onToggleSidebar={handleSidebarToggle}
|
||||
onSelectFilter={(f) => {
|
||||
setFilter(f);
|
||||
useSettingsStore.getState().setActiveView('downloads');
|
||||
@@ -1113,7 +1284,11 @@ function App() {
|
||||
{!isSidebarVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
ref={sidebarRevealRef}
|
||||
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"
|
||||
title={t($ => $.actions.showSidebar)}
|
||||
aria-label={t($ => $.actions.showSidebar)}
|
||||
@@ -1166,11 +1341,7 @@ function App() {
|
||||
|
||||
{isAddModalOpen && <AddDownloadsModal />}
|
||||
|
||||
{selectedPropertiesDownloadId !== null && (
|
||||
<Suspense fallback={null}>
|
||||
<PropertiesModal />
|
||||
</Suspense>
|
||||
)}
|
||||
<PropertiesWindowBridgeHost />
|
||||
{isDeleteModalOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<DeleteConfirmationModal />
|
||||
|
||||
@@ -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 DownloadAllocationEvent = { id: string, pending: boolean, lifecycleGeneration: string, };
|
||||
@@ -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 DownloadAssetRemovalPolicy = "trash" | "permanentIfUnfinished";
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other";
|
||||
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Torrents" | "Other";
|
||||
|
||||
@@ -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 DownloadErrorKind = "nameResolution" | "destinationAccess";
|
||||
@@ -1,5 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, replaceExistingFingerprint?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, effective_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, };
|
||||
|
||||
@@ -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,3 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStateProgress } from "./DownloadStateProgress";
|
||||
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, };
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, progress?: DownloadStateProgress, };
|
||||
|
||||
@@ -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 DownloadStateProgress = { fraction: number, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, };
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying" | "moving";
|
||||
|
||||
@@ -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 { DownloadTargetKind } from "./DownloadTargetKind";
|
||||
|
||||
export type DownloadTargetInfo = { kind: DownloadTargetKind, fingerprint?: string, ownedBy?: string, };
|
||||
@@ -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 DownloadTargetKind = "missing" | "regularFile" | "directory" | "symlink" | "special";
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 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";
|
||||
|
||||
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, 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, };
|
||||
|
||||
@@ -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 MainWindowSize = { width: number, height: number, };
|
||||
@@ -3,6 +3,7 @@ import type { AppFontSize } from "./AppFontSize";
|
||||
import type { CalendarPreference } from "./CalendarPreference";
|
||||
import type { FontFamily } from "./FontFamily";
|
||||
import type { ListRowDensity } from "./ListRowDensity";
|
||||
import type { MainWindowSize } from "./MainWindowSize";
|
||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||
import type { ProxyMode } from "./ProxyMode";
|
||||
import type { SchedulerSettings } from "./SchedulerSettings";
|
||||
@@ -11,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -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 TorrentAvailabilityBucket = { minimumCopies: number, };
|
||||
@@ -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 { TorrentAvailabilityBucket } from "./TorrentAvailabilityBucket";
|
||||
|
||||
export type TorrentAvailabilitySnapshot = { pieceCount: number, availability: number, connectedPeers: number, buckets: Array<TorrentAvailabilityBucket>, };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user