mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 09:45:44 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa0aa3cfcc | |||
| a5cecb3777 | |||
| 9c0ba106d6 | |||
| 8163e2241a | |||
| 0c65837360 | |||
| 201bb1e07c | |||
| e79c0c1446 | |||
| cfe929680c | |||
| 3a1703889a | |||
| 7fb05e87da | |||
| 62e60938e8 | |||
| c2a6e3d113 | |||
| 4d33f9790f | |||
| ba491ccd7d | |||
| 51db4fc6b7 | |||
| b5ee53140d | |||
| a6b341f9e3 | |||
| 9a7894a3e2 | |||
| b56ec0a3a8 | |||
| 45c25da98e | |||
| 627e24da0f | |||
| c34f2d2ae6 | |||
| 0ff0b28e9f | |||
| d49c7bd292 | |||
| 686bba97a9 | |||
| e88425833f | |||
| 3bcad639e2 | |||
| e6d276e28e | |||
| 101461b97c | |||
| a55cdee7e5 | |||
| d9022add5b | |||
| 55d5a9358b | |||
| e9de193c9e | |||
| 3a740db2f2 | |||
| 1672dce803 | |||
| 1fe12b1fea | |||
| cc74bec9b0 | |||
| f724616cde | |||
| c385c38556 | |||
| 2d265ce7c8 | |||
| c355e99913 | |||
| a478701291 | |||
| 5620110506 | |||
| f5bdd88e5c | |||
| 6be0f5ca5a | |||
| dd04ed40d6 | |||
| 9418aa5564 | |||
| 78e9c9b80f | |||
| 566632b7ad | |||
| 2bce25868c | |||
| bdbc11ad94 | |||
| 6aa07db5df | |||
| d135a17f1b | |||
| 3f3a3514a3 | |||
| 8e7e81277b | |||
| 6245c62d3d | |||
| f77fd0be3f | |||
| 92cfaa26ce | |||
| ca32b772a2 | |||
| de41dd55d6 | |||
| 314f4e2e00 | |||
| 4b43e8ed5c | |||
| e9ad226b93 | |||
| 885e3d0100 | |||
| 64a836f09f | |||
| f7bafdeb0e | |||
| ae6a00304e | |||
| 753d590aee | |||
| 9c6237716e | |||
| f79f9f1edf | |||
| 472da5a681 | |||
| 23991c3dea | |||
| 6d8eab466c | |||
| 67828eea92 | |||
| 6a9b2bc099 | |||
| 807e16a1fe | |||
| 4cd3d50d15 | |||
| 5797db27c5 | |||
| 3b7c454ec7 | |||
| c3755ce886 | |||
| 6c9950a690 | |||
| 27ac8d9083 | |||
| 27da8b1c44 | |||
| 757b849e4e | |||
| 761bbb61d2 | |||
| eb256707b0 | |||
| 92e369c15b | |||
| facb7b3300 | |||
| e402603edb | |||
| e2654510af | |||
| c9046f8273 | |||
| 2b44d1a121 | |||
| 6f9a9e0638 | |||
| ee2448d084 | |||
| 5d90b07fd7 | |||
| b36f55e5b5 | |||
| c1202229ac | |||
| 4a83ac97c7 | |||
| 48a727798c | |||
| 135ba75a69 | |||
| 3905b3ed89 | |||
| f6a7cb7b94 | |||
| c86eb1838c | |||
| 2ab292dd5d | |||
| c342bcd347 | |||
| 579a8f7f80 | |||
| 55a905df14 | |||
| 819a48bd4b | |||
| c4d3a2be51 | |||
| 79c0e48c43 | |||
| 32034e90b3 | |||
| cba485ef44 | |||
| b2c86a2ec4 | |||
| d677f98dd1 | |||
| 7da67c15b5 | |||
| d171f736c5 | |||
| 2d427b96d3 | |||
| 48b4d984a2 | |||
| 1d27b5b0bf | |||
| b4da68655a | |||
| 67023d3f0d | |||
| a46a64994d | |||
| ab9f0507d0 | |||
| 2474d2c1cb | |||
| d0541308c7 | |||
| 0f1f4e8003 | |||
| e31a3fcc90 | |||
| 87d682709e | |||
| ab6c3c372e | |||
| 52082c1e1d | |||
| 9247c5cf9c | |||
| dea6ad1974 | |||
| bb64c4cd52 | |||
| 92092e9844 | |||
| 1a492fc4c8 | |||
| a00d34edc8 | |||
| 18b51ca86e | |||
| 1bf8b675e4 | |||
| ebd11b26f6 | |||
| ae5199132d | |||
| 6336ff6f5d | |||
| a8bd80d557 | |||
| 186e189277 | |||
| 2c1bd9cdf1 | |||
| 292447d417 |
@@ -27,6 +27,7 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: Desktop checks (${{ matrix.target }})
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -52,8 +53,28 @@ jobs:
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
# The hosted Ubuntu image can expose an unreachable Azure mirror in
|
||||
# its apt sources while the public Ubuntu archive is reachable.
|
||||
# Normalize every apt source, including the image's mirror list,
|
||||
# before refreshing indexes so a transient runner-local mirror does
|
||||
# not consume the whole job timeout.
|
||||
sudo find /etc/apt -type f \
|
||||
-exec sed -i \
|
||||
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
{} +
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
update
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
@@ -65,12 +86,34 @@ jobs:
|
||||
if: runner.os != 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets --target ${{ matrix.target }}
|
||||
- name: Test Rust backend
|
||||
- name: Test Windows Torrent RPC integration
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
cargo test --tests --target ${{ matrix.target }}
|
||||
cargo test --lib --no-run --target ${{ matrix.target }}
|
||||
run: cargo test --test torrent_rpc --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Compile Windows queue-manager integration
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test queue_manager --no-run --target ${{ matrix.target }}
|
||||
- name: Compile Windows Rust library tests
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --lib --no-run --target ${{ matrix.target }}
|
||||
- name: Run Windows headless production contracts
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test production_contract --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows atomic Torrent storage
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test atomic_file --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows Torrent cache safety
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test torrent_cache --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows Torrent web-seed normalization
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
@@ -78,3 +121,9 @@ jobs:
|
||||
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
|
||||
- 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' || '' }}
|
||||
- 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' || '' }}
|
||||
|
||||
@@ -45,18 +45,45 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Verify release version
|
||||
- name: Verify tagged release version
|
||||
if: github.event_name == 'push' || inputs.publish_release
|
||||
run: node scripts/verify-release-version.js
|
||||
- name: Verify Companion release identity
|
||||
- name: Verify non-publishing package version
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.publish_release
|
||||
run: node scripts/verify-release-version.js --allow-untagged
|
||||
- name: Verify tagged Companion release identity
|
||||
if: github.event_name == 'push' || inputs.publish_release
|
||||
run: node scripts/verify-companion-release.js
|
||||
- name: Verify non-publishing Companion package metadata
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.publish_release
|
||||
run: node scripts/verify-companion-release.js --allow-untagged
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
# Hosted Ubuntu images can expose an unreachable Azure mirror in
|
||||
# their apt sources while the public Ubuntu archive is reachable.
|
||||
# Keep release jobs bounded and use the same source normalization as
|
||||
# the native CI path so a runner-local mirror cannot hang packaging.
|
||||
sudo find /etc/apt -type f \
|
||||
-exec sed -i \
|
||||
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
{} +
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
update
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
@@ -113,11 +140,25 @@ jobs:
|
||||
- name: Verify macOS packaged engines and launch
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
||||
set -euo pipefail
|
||||
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
||||
npm run verify:macos-signing -- --dmg "$DMG"
|
||||
MOUNT_POINT="$(mktemp -d "$RUNNER_TEMP/firelink-dmg-mount.XXXXXX")"
|
||||
cleanup() {
|
||||
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
|
||||
rmdir "$MOUNT_POINT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null
|
||||
APP_COUNT="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' | wc -l | tr -d ' ')"
|
||||
test "$APP_COUNT" -eq 1
|
||||
APP="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' -print -quit)"
|
||||
FIRELINK_MACOS_APP="$APP" node scripts/app-associations.node-test.js
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
||||
ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||
test -n "$ARIA2"
|
||||
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
- name: Verify Windows installer payload and launch
|
||||
if: runner.os == 'Windows'
|
||||
@@ -130,6 +171,12 @@ jobs:
|
||||
& 7z x $installer.FullName "-o$extractRoot" -y
|
||||
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
$aria2 = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq "aria2c-${{ matrix.target }}.exe" } |
|
||||
Sort-Object FullName |
|
||||
Select-Object -First 1
|
||||
if (-not $aria2) { throw "Packaged Aria2 executable was not found in the installer payload." }
|
||||
node scripts/smoke-aria2-transfers.js --binary $aria2.FullName
|
||||
|
||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
|
||||
@@ -195,6 +242,9 @@ jobs:
|
||||
chmod +x "$APPIMAGE"
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
|
||||
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
|
||||
ARIA2="$(find "$RUNNER_TEMP/squashfs-root" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||
test -n "$ARIA2"
|
||||
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
|
||||
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -11,11 +11,15 @@ skills-lock.json
|
||||
# Local agent and planning notes
|
||||
AGENT.md
|
||||
AGENTS.md
|
||||
TORRENT_FEATURES.md
|
||||
torrent_features.md
|
||||
TORRENT_UI_IMPLEMENTATION_PLAN.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
ARIA2_AVERAGE_USER_AUDIT.md
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
Release Checklist/
|
||||
@@ -61,6 +65,7 @@ build/
|
||||
# Local secrets and signing material
|
||||
.env
|
||||
.env.*
|
||||
implementation_plan.md
|
||||
*.key
|
||||
*.pem
|
||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||
|
||||
@@ -5,6 +5,53 @@ 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
|
||||
|
||||
This release adds built-in Torrent downloads and a dedicated Properties window, while making regular downloads, browser handoffs, and cross-platform packages more dependable.
|
||||
|
||||
### New features
|
||||
|
||||
- **Torrent downloads**
|
||||
- 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.
|
||||
- **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.
|
||||
- **Adaptive mirror selection**
|
||||
- Optionally use recent transfer performance to choose among multiple mirrors. Mirror statistics stay private on this device.
|
||||
- **Transfer and layout visibility**
|
||||
- Show the file-allocation phase while a normal download prepares 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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`.
|
||||
|
||||
## [1.3.1] - 2026-07-30
|
||||
|
||||
This hotfix improves filename handling, localized display details, and release reliability across platforms.
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 6f1b50bee4...3fe0a52a59
@@ -28,9 +28,9 @@ It uses a Rust and Tauri backend with a React and TypeScript interface. Required
|
||||
|
||||
## Status
|
||||
|
||||
Firelink `1.3.1` is the latest desktop release.
|
||||
Firelink `1.4.0` is the latest desktop release.
|
||||
|
||||
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0). The Companion did not change for this hotfix.
|
||||
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).
|
||||
|
||||
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
||||
|
||||
@@ -40,8 +40,12 @@ Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukr
|
||||
|
||||
- Segmented HTTP and HTTPS downloads with retries, speed limits, and connection controls.
|
||||
- Media and playlist downloads through yt-dlp, FFmpeg, and Deno.
|
||||
- BitTorrent downloads from `.torrent` files, magnet links, and browser handoff, with file selection, web seeds, seeding, and live diagnostics.
|
||||
- Standalone Download and Torrent Properties windows with live controls, transfer details, and safe Torrent relocation.
|
||||
- Adaptive mirror selection for multi-source downloads.
|
||||
- An Add window for metadata, duplicate handling, save locations, and download options.
|
||||
- Persistent queues with pause, resume, retry, redownload, and scheduling.
|
||||
- Visible allocation and resume status with clearer transfer diagnostics.
|
||||
- Live speed and connection controls for active downloads.
|
||||
- A customizable download table with sorting, column selection, reordering, and bulk actions.
|
||||
- File organization with categories, default folders, and per-download locations.
|
||||
@@ -63,6 +67,25 @@ Download the [latest Firelink release](https://github.com/nimbold/Firelink/relea
|
||||
|
||||
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separate engine installation is required.
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS security warning and first launch</strong></summary>
|
||||
|
||||
Firelink's macOS release is ad-hoc signed but not notarized by Apple. A paid Apple Developer Program account is required for Apple's Developer ID signing and notarization, so macOS may warn that it cannot verify Firelink or check that it is free of malware. This warning does not by itself mean that the app contains malware.
|
||||
|
||||
Only use these steps for Firelink downloaded from the [official GitHub release page](https://github.com/nimbold/Firelink/releases/latest). After moving `Firelink.app` to Applications, use one of these methods to approve the first launch:
|
||||
|
||||
1. In Finder, Control-click or right-click `Firelink.app`, choose **Open**, then choose **Open** again.
|
||||
2. Try opening Firelink once, then open **System Settings -> Privacy & Security**, scroll down, and choose **Open Anyway** for Firelink.
|
||||
3. In Terminal, remove only the downloaded-file quarantine flag:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Firelink.app
|
||||
```
|
||||
|
||||
If Firelink is stored somewhere else, replace the path with the actual path to `Firelink.app`. The approval is normally needed only once.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||
|
||||
@@ -76,7 +99,7 @@ All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separat
|
||||
|
||||
## Browser integration
|
||||
|
||||
[Firelink Companion `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release; no extension update is needed for Firelink `1.3.1`.
|
||||
[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).
|
||||
|
||||
Captured links open Firelink's Add window for review before they are started or queued.
|
||||
|
||||
|
||||
+16
-2
@@ -50,10 +50,24 @@ cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
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:
|
||||
|
||||
```bash
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
DMG="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --dmg "$DMG"
|
||||
MOUNT_POINT="$(mktemp -d -t firelink-dmg)"
|
||||
cleanup() {
|
||||
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
|
||||
rmdir "$MOUNT_POINT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null
|
||||
APP_COUNT="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' | wc -l | tr -d ' ')"
|
||||
test "$APP_COUNT" -eq 1
|
||||
APP="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' -print -quit)"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
+18
-18
@@ -3,19 +3,19 @@
|
||||
"targets": {
|
||||
"x86_64-pc-windows-msvc": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
"version": "2026.08.19",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_win.zip",
|
||||
"sha256": "30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.4",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "68ed08b05c56cf887e9aa509947dc3f468f7e12f47a13e5c1abd51d46d1453ef"
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-31-g8c9502e9b0",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-28-13-32/ffmpeg-n8.1.2-31-g8c9502e9b0-win64-gpl-8.1.zip",
|
||||
"sha256": "e88033629ebecd56d01e6b5b2e693f07d3ec3ed0d14188fc18d7d0bc9c3e6709"
|
||||
"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"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -25,19 +25,19 @@
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
"version": "2026.08.19",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_linux.zip",
|
||||
"sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.4",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "c24f955d9fbfe0ea5ae2b501c8e71ae76e31e4c9782390a54a284b3364fda725"
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-31-g8c9502e9b0",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-28-13-32/ffmpeg-n8.1.2-31-g8c9502e9b0-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "8f61aa8abfc2174bbb60fe0bcf7a23c7940a96d4e34c6694bf0ca48b23ce67f0"
|
||||
"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"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
|
||||
+9
-9
@@ -4,10 +4,10 @@
|
||||
"aarch64-apple-darwin": {
|
||||
"engines": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"version": "2026.08.19",
|
||||
"source": "https://github.com/yt-dlp/yt-dlp",
|
||||
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
|
||||
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
|
||||
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -16,23 +16,23 @@
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125840-gf6fa0d3fda",
|
||||
"version": "N-125892-g406c5a37aa",
|
||||
"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/1785265034_N-125840-gf6fa0d3fda/ffmpeg.zip",
|
||||
"sha256": "e4fa88b19f85afa620dccc35f2bec5b41746e1bb13f1c7e6fe4c7db88391027d"
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1785661721_N-125892-g406c5a37aa/ffmpeg.zip",
|
||||
"sha256": "734e6b72a0c2d0d5e089b5a0094be74fa058c15158f6b0689207a01fedafd8f5"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.4",
|
||||
"version": "2.9.5",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "433088c827fa0e39ff162ab0e475f1fd4c7690eaedec500cf678edc3865e9287"
|
||||
"sha256": "b5bd08edab254d42d7b05aa5b6cb4c9b8d4dede4975aff76951ce2cce18866fa"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
|
||||
"files": 133,
|
||||
"sha256": "24ac8c7557dbef0ef9e5685365b4620bc441a168daf314b71218276b782dc449"
|
||||
},
|
||||
"aria2-libs": {
|
||||
"files": 7,
|
||||
|
||||
Generated
+509
-303
File diff suppressed because it is too large
Load Diff
+16
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"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",
|
||||
@@ -33,6 +33,11 @@
|
||||
"build": "tsc && vite build",
|
||||
"check:i18n": "vitest run src/i18n/resources.test.ts",
|
||||
"check:updates": "node scripts/check-updates.js",
|
||||
"smoke:torrent": "node scripts/smoke-torrent.js",
|
||||
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
|
||||
"smoke:aria2:resolver": "node scripts/smoke-aria2-resolver.js",
|
||||
"smoke:aria2:transfers": "node scripts/smoke-aria2-transfers.js",
|
||||
"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",
|
||||
@@ -54,23 +59,23 @@
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"i18next": "^26.3.6",
|
||||
"lucide-react": "^1.27.0",
|
||||
"i18next": "^26.4.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"zustand": "^5.0.14"
|
||||
"react-i18next": "^17.0.12",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.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.24",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
const tauriConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(repositoryRoot, 'src-tauri', 'tauri.conf.json'), 'utf8')
|
||||
);
|
||||
|
||||
const torrentAssociation = tauriConfig.bundle.fileAssociations.find(association =>
|
||||
association.ext?.some(extension => extension.toLowerCase() === 'torrent')
|
||||
);
|
||||
|
||||
test('declares the native macOS BitTorrent content type', () => {
|
||||
assert.ok(torrentAssociation, 'the bundle must declare a .torrent association');
|
||||
assert.equal(torrentAssociation.mimeType, 'application/x-bittorrent');
|
||||
assert.deepEqual(torrentAssociation.exportedType, {
|
||||
identifier: 'org.bittorrent.torrent',
|
||||
conformsTo: ['public.data', 'public.item']
|
||||
});
|
||||
assert.equal(
|
||||
torrentAssociation.contentTypes,
|
||||
undefined,
|
||||
'the UTI declaration supplies the extension and MIME tags for clean installs'
|
||||
);
|
||||
});
|
||||
|
||||
test('declares magnet as a desktop deep-link scheme', () => {
|
||||
assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']);
|
||||
});
|
||||
|
||||
const packagedAppPath = process.env.FIRELINK_MACOS_APP;
|
||||
|
||||
if (packagedAppPath) {
|
||||
test('packaged macOS app exports the Torrent UTI and magnet URL scheme', () => {
|
||||
assert.equal(process.platform, 'darwin', 'packaged macOS association checks require macOS');
|
||||
const infoPlistPath = path.join(packagedAppPath, 'Contents', 'Info.plist');
|
||||
assert.ok(fs.existsSync(infoPlistPath), `missing packaged Info.plist: ${infoPlistPath}`);
|
||||
|
||||
const plist = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], {
|
||||
encoding: 'utf8'
|
||||
}));
|
||||
const urlTypes = Array.isArray(plist.CFBundleURLTypes) ? plist.CFBundleURLTypes : [];
|
||||
const schemes = urlTypes.flatMap(entry => (
|
||||
entry && typeof entry === 'object' && Array.isArray(entry.CFBundleURLSchemes)
|
||||
? entry.CFBundleURLSchemes.filter(scheme => typeof scheme === 'string')
|
||||
: []
|
||||
));
|
||||
assert.ok(schemes.includes('firelink'), 'packaged app must retain the Firelink deep-link scheme');
|
||||
assert.ok(schemes.includes('magnet'), 'packaged app must export the magnet URL scheme');
|
||||
|
||||
const documentTypes = Array.isArray(plist.CFBundleDocumentTypes) ? plist.CFBundleDocumentTypes : [];
|
||||
const torrentDocument = documentTypes.find(entry =>
|
||||
entry
|
||||
&& typeof entry === 'object'
|
||||
&& Array.isArray(entry.CFBundleTypeExtensions)
|
||||
&& entry.CFBundleTypeExtensions.some(extension =>
|
||||
typeof extension === 'string' && extension.toLowerCase() === 'torrent'
|
||||
)
|
||||
);
|
||||
assert.ok(torrentDocument, 'packaged app must claim the .torrent extension');
|
||||
assert.ok(
|
||||
Array.isArray(torrentDocument.LSItemContentTypes)
|
||||
&& torrentDocument.LSItemContentTypes.includes('org.bittorrent.torrent'),
|
||||
'packaged app must claim the standard BitTorrent UTI'
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const sourceArgument = process.argv.indexOf('--source');
|
||||
const sourceLocation = sourceArgument >= 0
|
||||
? process.argv[sourceArgument + 1]
|
||||
: 'https://raw.githubusercontent.com/aria2/aria2/release-1.37.0/doc/manual-src/en/aria2c.rst';
|
||||
|
||||
if (!sourceLocation) throw new Error('--source requires a path or URL');
|
||||
|
||||
const source = /^https:\/\//.test(sourceLocation)
|
||||
? await fetch(sourceLocation, { signal: AbortSignal.timeout(15000) }).then(response => {
|
||||
if (!response.ok) throw new Error(`Aria2 manual fetch failed with HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
: fs.readFileSync(sourceLocation, 'utf8');
|
||||
|
||||
function section(start, end) {
|
||||
const startIndex = source.indexOf(`${start}\n`);
|
||||
const endIndex = source.indexOf(`${end}\n`, startIndex + start.length);
|
||||
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find manual section ${start} -> ${end}`);
|
||||
return source.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function options(text) {
|
||||
const names = [...text.matchAll(/^\.\. option:: .*?(--[a-z0-9-]+)/gm)]
|
||||
.map(match => match[1].slice(2));
|
||||
return [...new Set(names)].sort();
|
||||
}
|
||||
|
||||
const normal = options(section('HTTP/FTP/SFTP Options', 'BitTorrent/Metalink Options'));
|
||||
const torrent = options(
|
||||
`${section('BitTorrent/Metalink Options', 'BitTorrent Specific Options')}\n${section('BitTorrent Specific Options', 'Metalink Specific Options')}`,
|
||||
);
|
||||
|
||||
if (torrent.length !== 46) {
|
||||
throw new Error(`Expected 46 unique Aria2 1.37.0 Torrent options, found ${torrent.length}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
source: sourceLocation,
|
||||
aria2Version: '1.37.0',
|
||||
normal: { count: normal.length, options: normal },
|
||||
torrent: { count: torrent.length, options: torrent },
|
||||
}, null, 2));
|
||||
+270
-62
@@ -7,6 +7,14 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const userAgent = 'firelink-update-check';
|
||||
const fetchRetryDelaysMs = [250, 1_000];
|
||||
const fetchTimeoutMs = 30_000;
|
||||
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function httpResponseError(response, url) {
|
||||
const status = [response.status, response.statusText].filter(Boolean).join(' ');
|
||||
return new Error(`${status}: ${url}`);
|
||||
}
|
||||
|
||||
function parseJsonFile(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
|
||||
@@ -32,12 +40,46 @@ function compareVersions(left, right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseSha256Digest(value) {
|
||||
const match = /^sha256:([0-9a-f]{64})$/i.exec(String(value || ''));
|
||||
return match?.[1].toLowerCase();
|
||||
}
|
||||
|
||||
function releaseAssetHashes(release) {
|
||||
return Object.fromEntries(
|
||||
(release?.assets || [])
|
||||
.map(asset => {
|
||||
const digest = parseSha256Digest(asset.digest);
|
||||
return digest && typeof asset.browser_download_url === 'string'
|
||||
? [asset.browser_download_url, digest]
|
||||
: undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function providerAssetHashes({ ytDlp, deno, aria2 }) {
|
||||
return {
|
||||
...releaseAssetHashes(ytDlp),
|
||||
...releaseAssetHashes(deno),
|
||||
...releaseAssetHashes(aria2),
|
||||
};
|
||||
}
|
||||
|
||||
function npmExecutable(platform = process.platform) {
|
||||
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
function npmOutdated(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
throw new Error(`npm workspace is missing package.json: ${cwd}`);
|
||||
}
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
execFileSync(npmExecutable(), ['outdated', '--json'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
@@ -50,25 +92,67 @@ function npmOutdated(cwd) {
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
|
||||
return response.json();
|
||||
return fetchWithContext(url, response => response.json());
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
|
||||
return response.text();
|
||||
return fetchWithContext(url, response => response.text());
|
||||
}
|
||||
|
||||
async function fetchWithContext(url, readResponse) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= fetchRetryDelaysMs.length; attempt += 1) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { 'User-Agent': userAgent },
|
||||
signal: AbortSignal.timeout(fetchTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === fetchRetryDelaysMs.length) break;
|
||||
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = httpResponseError(response, url);
|
||||
if (!retryableHttpStatuses.has(response.status) || attempt === fetchRetryDelaysMs.length) {
|
||||
await response.body?.cancel();
|
||||
throw error;
|
||||
}
|
||||
await response.body?.cancel();
|
||||
lastError = error;
|
||||
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return await readResponse(response);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === fetchRetryDelaysMs.length) break;
|
||||
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
|
||||
}
|
||||
}
|
||||
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
throw new Error(`fetch failed for ${url}: ${detail}`, { cause: lastError });
|
||||
}
|
||||
|
||||
async function githubLatest(repo) {
|
||||
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
||||
const release = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
||||
if (!release || Array.isArray(release) || typeof release.tag_name !== 'string' || !release.tag_name.trim()) {
|
||||
throw new Error(`GitHub latest release response for ${repo} has no usable tag_name`);
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
async function latestFfmpegStable() {
|
||||
const html = await fetchText('https://ffmpeg.org/releases/');
|
||||
const versions = [...html.matchAll(/ffmpeg-(\d+\.\d+(?:\.\d+)?)\.tar\.xz/g)].map(match => match[1]);
|
||||
return [...new Set(versions)].sort(compareVersions).at(-1);
|
||||
const latest = [...new Set(versions)].sort(compareVersions).at(-1);
|
||||
if (!latest) throw new Error('FFmpeg release page contained no usable stable release version');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function latestMartinRiedlMacArm64Release() {
|
||||
@@ -105,15 +189,21 @@ async function latestBtbnFfmpegN81Build() {
|
||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||
version: match[1],
|
||||
url: asset.browser_download_url,
|
||||
sha256: parseSha256Digest(asset.digest),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(assets.map(asset => asset.version))];
|
||||
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
|
||||
if (unique.length === 1 && byTarget.windows && byTarget.linux) {
|
||||
if (
|
||||
unique.length === 1 &&
|
||||
byTarget.windows?.sha256 &&
|
||||
byTarget.linux?.sha256
|
||||
) {
|
||||
return {
|
||||
version: unique[0],
|
||||
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
|
||||
hashes: { windows: byTarget.windows.sha256, linux: byTarget.linux.sha256 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -137,7 +227,7 @@ 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 });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -147,26 +237,58 @@ 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 });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}, latestUrlsByTargetEngine = {}) {
|
||||
function unavailableLatestVersionError(target, engine, targetSpecific) {
|
||||
const error = new Error(
|
||||
`${targetSpecific ? 'Latest provider version' : 'Latest version'} is unavailable for ${target} ${engine}`
|
||||
);
|
||||
error.code = 'LATEST_VERSION_UNAVAILABLE';
|
||||
return error;
|
||||
}
|
||||
|
||||
function checkRows(
|
||||
rows,
|
||||
latestByEngine,
|
||||
latestByTargetEngine = {},
|
||||
latestUrlsByTargetEngine = {},
|
||||
targetSpecificEngines = new Set(),
|
||||
latestHashesByTargetEngine = {},
|
||||
latestHashesByUrl = {},
|
||||
) {
|
||||
let outdated = 0;
|
||||
for (const row of rows) {
|
||||
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
|
||||
if (!latest) continue;
|
||||
const targetSpecific = targetSpecificEngines.has(row.engine);
|
||||
const targetKey = `${row.target}:${row.engine}`;
|
||||
const latest = targetSpecific
|
||||
? latestByTargetEngine[targetKey]
|
||||
: latestByTargetEngine[targetKey] || latestByEngine[row.engine];
|
||||
if (typeof latest !== 'string' || !latest.trim()) {
|
||||
throw unavailableLatestVersionError(row.target, row.engine, targetSpecific);
|
||||
}
|
||||
const current = normalizeVersion(row.version);
|
||||
const wanted = normalizeVersion(latest);
|
||||
const latestUrl = latestUrlsByTargetEngine[`${row.target}:${row.engine}`];
|
||||
const latestUrl = latestUrlsByTargetEngine[targetKey];
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
const status = versionOutdated ? 'outdated' : sourceOutdated ? 'source-outdated' : 'current';
|
||||
const latestHash = latestHashesByTargetEngine[targetKey] || latestHashesByUrl[row.url];
|
||||
const currentHash = typeof row.sha256 === 'string' ? row.sha256.toLowerCase() : '';
|
||||
const hashOutdated = Boolean(latestHash && currentHash !== latestHash);
|
||||
const status = versionOutdated
|
||||
? 'outdated'
|
||||
: sourceOutdated
|
||||
? 'source-outdated'
|
||||
: hashOutdated
|
||||
? 'hash-outdated'
|
||||
: 'current';
|
||||
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}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
@@ -180,72 +302,158 @@ async function main() {
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [
|
||||
ytDlp,
|
||||
deno,
|
||||
aria2,
|
||||
ffmpeg,
|
||||
martinRiedlMacArm64Ffmpeg,
|
||||
martinRiedlMacArm64Snapshot,
|
||||
btbnFfmpegN81Build,
|
||||
] = await Promise.all([
|
||||
githubLatest('yt-dlp/yt-dlp'),
|
||||
githubLatest('denoland/deno'),
|
||||
githubLatest('aria2/aria2'),
|
||||
latestFfmpegStable(),
|
||||
latestMartinRiedlMacArm64Release(),
|
||||
latestMartinRiedlMacArm64Snapshot(),
|
||||
latestBtbnFfmpegN81Build(),
|
||||
]);
|
||||
const providerChecks = [
|
||||
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
|
||||
['Deno latest release', () => githubLatest('denoland/deno')],
|
||||
['aria2 latest release', () => githubLatest('aria2/aria2')],
|
||||
[
|
||||
'FFmpeg stable release',
|
||||
async () => {
|
||||
const version = await latestFfmpegStable();
|
||||
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
],
|
||||
[
|
||||
'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');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
],
|
||||
[
|
||||
'BtbN FFmpeg Windows/Linux build',
|
||||
async () => {
|
||||
const build = await latestBtbnFfmpegN81Build();
|
||||
if (
|
||||
!build?.version ||
|
||||
!build.urls?.windows ||
|
||||
!build.urls?.linux ||
|
||||
!build.hashes?.windows ||
|
||||
!build.hashes?.linux
|
||||
) {
|
||||
throw new Error('BtbN FFmpeg provider response has no complete Windows/Linux build with SHA-256 digests');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
],
|
||||
];
|
||||
const providerResults = await Promise.allSettled(providerChecks.map(([, check]) => check()));
|
||||
const providerFailures = [];
|
||||
for (const [index, [label]] of providerChecks.entries()) {
|
||||
const result = providerResults[index];
|
||||
if (result.status === 'rejected') {
|
||||
const detail = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
providerFailures.push(label);
|
||||
console.error(`provider unavailable: ${label}: ${detail}`);
|
||||
}
|
||||
}
|
||||
const providerValue = index =>
|
||||
providerResults[index].status === 'fulfilled' ? providerResults[index].value : undefined;
|
||||
const ytDlp = providerValue(0);
|
||||
const deno = providerValue(1);
|
||||
const aria2 = providerValue(2);
|
||||
const ffmpeg = providerValue(3);
|
||||
const martinRiedlMacArm64Snapshot = providerValue(5);
|
||||
const btbnFfmpegN81Build = providerValue(6);
|
||||
const latestByEngine = {
|
||||
'yt-dlp': ytDlp.tag_name,
|
||||
deno: deno.tag_name,
|
||||
aria2c: aria2.tag_name,
|
||||
'yt-dlp': ytDlp?.tag_name,
|
||||
deno: deno?.tag_name,
|
||||
aria2c: aria2?.tag_name,
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
const latestUrlsByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.urls.windows,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.urls.linux,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.url,
|
||||
};
|
||||
const latestByTargetEngine = {};
|
||||
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 (martinRiedlMacArm64Snapshot?.version && martinRiedlMacArm64Snapshot.url) {
|
||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.version;
|
||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.url;
|
||||
}
|
||||
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
||||
|
||||
console.log('\nlatest engines:');
|
||||
for (const [engine, version] of Object.entries(latestByEngine)) {
|
||||
console.log(` ${engine}: ${normalizeVersion(version)}`);
|
||||
console.log(` ${engine}: ${displayVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build?.version || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${displayVersion(btbnFfmpegN81Build?.version)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${displayVersion(martinRiedlMacArm64Snapshot?.version)}`);
|
||||
|
||||
const targetSpecificEngines = new Set(['ffmpeg']);
|
||||
const engineCheckFailures = [];
|
||||
const runEngineCheck = (label, rows) => {
|
||||
try {
|
||||
return checkRows(
|
||||
rows,
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine,
|
||||
targetSpecificEngines,
|
||||
latestHashesByTargetEngine,
|
||||
latestHashesByUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'LATEST_VERSION_UNAVAILABLE') throw error;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
engineCheckFailures.push(label);
|
||||
console.error(`engine provider unavailable: ${label}: ${detail}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
outdatedCount += runEngineCheck(
|
||||
'engine source lock',
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
outdatedCount += runEngineCheck(
|
||||
'packaged engine lock',
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
console.error(`\n${outdatedCount} outdated item(s) found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (providerFailures.length > 0) {
|
||||
console.error(`\n${providerFailures.length} provider check(s) unavailable; refusing to claim that all updates are current.`);
|
||||
}
|
||||
if (engineCheckFailures.length > 0) {
|
||||
console.error(`\n${engineCheckFailures.length} engine lock check(s) unavailable; refusing to claim that all engines are current.`);
|
||||
}
|
||||
if (providerFailures.length > 0 || engineCheckFailures.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll checked packages and engines are current.');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { checkRows, fetchJson, fetchText, fetchWithContext, npmExecutable, providerAssetHashes };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { checkRows, fetchJson, fetchText, npmExecutable, providerAssetHashes } from './check-updates.js';
|
||||
|
||||
async function withMockFetch(mockFetch, callback) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test('fetchJson retries transient HTTP responses before succeeding', async () => {
|
||||
let attempts = 0;
|
||||
const result = await withMockFetch(async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) return new Response('temporarily unavailable', { status: 503 });
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}, () => fetchJson('https://example.test/releases'));
|
||||
|
||||
assert.deepEqual(result, { status: 'ok' });
|
||||
assert.equal(attempts, 3);
|
||||
});
|
||||
|
||||
test('fetchText does not retry terminal HTTP responses', async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
withMockFetch(async () => {
|
||||
attempts += 1;
|
||||
return new Response('not found', { status: 404 });
|
||||
}, () => fetchText('https://example.test/missing')),
|
||||
/404: https:\/\/example\.test\/missing/
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('checkRows fails closed when a latest version is unavailable', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
checkRows(
|
||||
[{ target: 'test-target', engine: 'test-engine', version: '1.0.0', url: 'https://example.test/engine' }],
|
||||
{}
|
||||
),
|
||||
/Latest version is unavailable for test-target test-engine/
|
||||
);
|
||||
});
|
||||
|
||||
test('checkRows does not fall back to the generic release for target-specific engines', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
checkRows(
|
||||
[{ target: 'test-target', engine: 'ffmpeg', version: '8.1.2', url: 'https://example.test/engine' }],
|
||||
{ ffmpeg: '9.0.1' },
|
||||
{},
|
||||
{},
|
||||
new Set(['ffmpeg'])
|
||||
),
|
||||
/Latest provider version is unavailable for test-target ffmpeg/
|
||||
);
|
||||
});
|
||||
|
||||
test('checkRows detects a provider hash change when version and URL are current', () => {
|
||||
const outdated = checkRows(
|
||||
[{
|
||||
target: 'test-target',
|
||||
engine: 'test-engine',
|
||||
version: '1.0.0',
|
||||
url: 'https://example.test/engine',
|
||||
sha256: 'a'.repeat(64),
|
||||
}],
|
||||
{ 'test-engine': '1.0.0' },
|
||||
{},
|
||||
{},
|
||||
new Set(),
|
||||
{},
|
||||
{ 'https://example.test/engine': 'b'.repeat(64) },
|
||||
);
|
||||
|
||||
assert.equal(outdated, 1);
|
||||
});
|
||||
|
||||
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);
|
||||
const hashes = providerAssetHashes({
|
||||
aria2: { assets: [{ browser_download_url: url, digest: `sha256:${digest}` }] },
|
||||
});
|
||||
|
||||
const outdated = checkRows(
|
||||
[{
|
||||
target: 'x86_64-pc-windows-msvc',
|
||||
engine: 'aria2c',
|
||||
version: '1.37.0',
|
||||
url,
|
||||
sha256: 'a'.repeat(64),
|
||||
}],
|
||||
{ aria2c: '1.37.0' },
|
||||
{},
|
||||
{},
|
||||
new Set(),
|
||||
{},
|
||||
hashes,
|
||||
);
|
||||
|
||||
assert.equal(outdated, 1);
|
||||
});
|
||||
|
||||
test('npm executable selection uses the Windows command shim when needed', () => {
|
||||
assert.equal(npmExecutable('win32'), 'npm.cmd');
|
||||
assert.equal(npmExecutable('darwin'), 'npm');
|
||||
assert.equal(npmExecutable('linux'), 'npm');
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import fs from 'node:fs';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const DEFAULT_ATTEMPTS = 3;
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000];
|
||||
const FILE_RESET_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function parseContentRange(value) {
|
||||
const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(value || '');
|
||||
if (!match) return undefined;
|
||||
const start = Number(match[1]);
|
||||
const end = Number(match[2]);
|
||||
const total = match[3] === '*' ? undefined : Number(match[3]);
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
|
||||
return undefined;
|
||||
}
|
||||
if (total !== undefined && (!Number.isSafeInteger(total) || end >= total)) {
|
||||
return undefined;
|
||||
}
|
||||
return { start, end, total };
|
||||
}
|
||||
|
||||
function responseLength(response) {
|
||||
const value = response.headers.get('content-length');
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
const length = Number(value);
|
||||
return Number.isSafeInteger(length) ? length : undefined;
|
||||
}
|
||||
|
||||
function createDownloadTimeout(idleTimeoutMs) {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`Download idle for ${idleTimeoutMs}ms`));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
if (signal?.reason instanceof Error) return signal.reason;
|
||||
return new Error('Engine archive download aborted');
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
}
|
||||
|
||||
function combineAbortSignals(signals) {
|
||||
const activeSignals = signals.filter(Boolean);
|
||||
const controller = new AbortController();
|
||||
const listeners = [];
|
||||
const abort = signal => {
|
||||
if (!controller.signal.aborted) controller.abort(abortReason(signal));
|
||||
};
|
||||
|
||||
for (const signal of activeSignals) {
|
||||
const listener = () => abort(signal);
|
||||
listeners.push([signal, listener]);
|
||||
if (signal.aborted) abort(signal);
|
||||
else signal.addEventListener('abort', listener, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose() {
|
||||
for (const [signal, listener] of listeners) {
|
||||
signal.removeEventListener('abort', listener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function archiveSize(archive) {
|
||||
try {
|
||||
return fs.statSync(archive).size;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function checksumMismatchError(name, expected, actual) {
|
||||
const error = new Error(`Archive checksum mismatch for ${name}. Expected ${expected}, got ${actual}`);
|
||||
error.code = 'ARCHIVE_CHECKSUM_MISMATCH';
|
||||
return error;
|
||||
}
|
||||
|
||||
function sleep(milliseconds, signal) {
|
||||
if (!signal) return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const finish = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
timer = setTimeout(finish, milliseconds);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
async function resetArchive(archive) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(archive, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= FILE_RESET_RETRY_DELAYS_MS.length) throw error;
|
||||
await sleep(FILE_RESET_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and checksum an engine archive, resuming an interrupted response
|
||||
* when the provider honors HTTP range requests. A provider that ignores the
|
||||
* range is handled safely by replacing the partial file instead of appending
|
||||
* a second full archive to it.
|
||||
*/
|
||||
export async function downloadEngineArchive({
|
||||
name,
|
||||
url,
|
||||
archive,
|
||||
expectedSha256,
|
||||
attempts = DEFAULT_ATTEMPTS,
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
||||
signal,
|
||||
}) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const partialSize = archiveSize(archive);
|
||||
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
|
||||
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
|
||||
const requestSignal = combineAbortSignals([signal, downloadTimeout.signal]);
|
||||
let resetForRetry = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
|
||||
redirect: 'follow',
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
|
||||
if (response.status === 416 && partialSize > 0) {
|
||||
await response.body?.cancel();
|
||||
resetForRetry = true;
|
||||
throw new Error(`Retained partial archive range is not satisfiable for ${name}`);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const contentRange = response.status === 206
|
||||
? parseContentRange(response.headers.get('content-range'))
|
||||
: undefined;
|
||||
if (response.status === 206 && (!contentRange || contentRange.start !== partialSize)) {
|
||||
await response.body.cancel();
|
||||
throw new Error(`Invalid Content-Range while downloading ${name}`);
|
||||
}
|
||||
|
||||
const append = response.status === 206 && partialSize > 0;
|
||||
const expectedResponseLength = responseLength(response);
|
||||
if (!append && partialSize > 0) {
|
||||
// The provider ignored Range and returned the complete archive.
|
||||
await resetArchive(archive);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadTimeout.refresh();
|
||||
callback(null, chunk, encoding);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
|
||||
{ signal: requestSignal.signal },
|
||||
);
|
||||
|
||||
throwIfAborted(signal);
|
||||
const finalSize = archiveSize(archive);
|
||||
const expectedFinalSize = contentRange?.total
|
||||
?? (expectedResponseLength === undefined
|
||||
? undefined
|
||||
: (append ? partialSize + expectedResponseLength : expectedResponseLength));
|
||||
if (expectedFinalSize !== undefined && finalSize !== expectedFinalSize) {
|
||||
throw new Error(
|
||||
`Incomplete archive for ${name}: expected ${expectedFinalSize} bytes, got ${finalSize}`,
|
||||
);
|
||||
}
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual === expectedSha256) return archive;
|
||||
|
||||
resetForRetry = true;
|
||||
throw checksumMismatchError(name, expectedSha256, actual);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
lastError = error;
|
||||
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
|
||||
await resetArchive(archive);
|
||||
}
|
||||
if (attempt === attempts) {
|
||||
throw new Error(
|
||||
`Failed to download ${name} after ${attempts} attempts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await sleep(retryDelaysMs[attempt - 1] ?? 0, signal);
|
||||
} finally {
|
||||
requestSignal.dispose();
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
|
||||
async function withMockFetch(mockFetch, callback) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function makeBody(chunks, failure) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(Buffer.from(chunk));
|
||||
if (failure) controller.error(failure);
|
||||
else controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeArchivePath() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-download-'));
|
||||
return {
|
||||
directory,
|
||||
archive: path.join(directory, 'engine.zip'),
|
||||
};
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
test('resumes an interrupted archive response from the retained partial file', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive payload');
|
||||
const prefix = full.subarray(0, 8);
|
||||
const middle = full.subarray(8, 15);
|
||||
const suffix = full.subarray(15);
|
||||
fs.writeFileSync(archive, Buffer.concat([prefix, middle]));
|
||||
const ranges = [];
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
ranges.push(options.headers?.Range);
|
||||
if (calls === 1) {
|
||||
return new Response(makeBody([], new Error('connection reset')), {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Length': String(suffix.length),
|
||||
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(makeBody([suffix]), {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Length': String(suffix.length),
|
||||
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
|
||||
},
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 2,
|
||||
retryDelaysMs: [0],
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
ranges,
|
||||
[`bytes=${prefix.length + middle.length}-`, `bytes=${prefix.length + middle.length}-`],
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('replaces a partial archive when the provider ignores the Range request', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive after range fallback');
|
||||
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
|
||||
let requestedRange;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
requestedRange = options.headers?.Range;
|
||||
return new Response(full, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(full.length) },
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(requestedRange, 'bytes=19-');
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts a complete retained archive without issuing an unsatisfiable range', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive retained after a late connection reset');
|
||||
fs.writeFileSync(archive, full);
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async () => {
|
||||
calls += 1;
|
||||
throw new Error('fetch should not be called for a complete retained archive');
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('restarts from zero after an unsatisfiable retained range', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive after a stale range response');
|
||||
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
|
||||
const ranges = [];
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
ranges.push(options.headers?.Range);
|
||||
if (calls === 1) return new Response(null, { status: 416 });
|
||||
return new Response(full, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(full.length) },
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 2,
|
||||
retryDelaysMs: [0],
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(ranges, ['bytes=19-', undefined]);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates external cancellation without retrying an in-flight archive', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const abortController = new AbortController();
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
assert.equal(options.signal.aborted, false);
|
||||
setTimeout(() => {
|
||||
abortController.abort(new Error('provisioning interrupted'));
|
||||
}, 10);
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(Buffer.from('partial archive bytes'));
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '100' },
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(Buffer.from('never completed')),
|
||||
attempts: 3,
|
||||
retryDelaysMs: [500, 500],
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
error => {
|
||||
assert.match(error.message, /provisioning interrupted/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.ok(fs.statSync(archive).size > 0);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function pathExists(value) {
|
||||
try {
|
||||
fs.lstatSync(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function previousPayloadPrefix(destination) {
|
||||
return `.${path.basename(destination)}.previous-`;
|
||||
}
|
||||
|
||||
function previousPayloads(destination) {
|
||||
const parent = path.dirname(destination);
|
||||
const prefix = previousPayloadPrefix(destination);
|
||||
return fs.readdirSync(parent, { withFileTypes: true })
|
||||
.filter(entry => entry.name.startsWith(prefix))
|
||||
.map(entry => path.join(parent, entry.name));
|
||||
}
|
||||
|
||||
function previousPayloadOwner(destination, candidate) {
|
||||
const suffix = path.basename(candidate).slice(previousPayloadPrefix(destination).length);
|
||||
const separator = suffix.indexOf('-');
|
||||
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOrphanedPreviousPayloads(destination) {
|
||||
for (const candidate of previousPayloads(destination)) {
|
||||
const owner = previousPayloadOwner(destination, candidate);
|
||||
if (owner === null || isProcessAlive(owner)) continue;
|
||||
await removePathWithRetry(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function provisioningTemporaryPrefix(target) {
|
||||
return `.firelink-engines-${target}-`;
|
||||
}
|
||||
|
||||
function provisioningTemporaryOwner(target, candidate) {
|
||||
const suffix = path.basename(candidate).slice(provisioningTemporaryPrefix(target).length);
|
||||
const separator = suffix.indexOf('-');
|
||||
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only staging directories created by a provisioner whose PID is no
|
||||
* longer alive. Legacy directories without an owner PID remain untouched so
|
||||
* a concurrent or ambiguous provisioner can never lose its staging tree.
|
||||
*/
|
||||
export async function removeOrphanedProvisioningDirectories(destinationParent, target) {
|
||||
const prefix = provisioningTemporaryPrefix(target);
|
||||
for (const entry of fs.readdirSync(destinationParent, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
||||
const candidate = path.join(destinationParent, entry.name);
|
||||
const owner = provisioningTemporaryOwner(target, candidate);
|
||||
if (owner === null || owner === process.pid || isProcessAlive(owner)) continue;
|
||||
await removePathWithRetry(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the only previous payload left by a process that died after moving
|
||||
* the destination aside but before publishing its replacement. Multiple
|
||||
* candidates are ambiguous and remain untouched for manual recovery.
|
||||
*/
|
||||
export function recoverInterruptedPromotion(destination) {
|
||||
if (pathExists(destination)) return;
|
||||
|
||||
const candidates = previousPayloads(destination);
|
||||
if (candidates.length === 0) return;
|
||||
if (candidates.length > 1) {
|
||||
throw new Error(
|
||||
`Cannot recover engine payload at ${destination}: found ${candidates.length} previous payloads`
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = candidates[0];
|
||||
if (!fs.lstatSync(candidate).isDirectory()) {
|
||||
throw new Error(`Cannot recover engine payload from non-directory backup: ${candidate}`);
|
||||
}
|
||||
fs.renameSync(candidate, destination);
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
export async function removePathWithRetry(value) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(value, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= RETRY_DELAYS_MS.length) throw error;
|
||||
await sleep(RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a fully verified payload without exposing a partially written
|
||||
* directory. The staging directory must share a filesystem with destination.
|
||||
*/
|
||||
export async function promoteDirectory(staging, destination) {
|
||||
recoverInterruptedPromotion(destination);
|
||||
const stagingStats = fs.lstatSync(staging);
|
||||
if (!stagingStats.isDirectory()) {
|
||||
throw new Error(`Engine payload staging path is not a directory: ${staging}`);
|
||||
}
|
||||
|
||||
const parent = path.dirname(destination);
|
||||
const backup = path.join(
|
||||
parent,
|
||||
`.${path.basename(destination)}.previous-${process.pid}-${process.hrtime.bigint()}`
|
||||
);
|
||||
let movedExisting = false;
|
||||
|
||||
try {
|
||||
if (pathExists(destination)) {
|
||||
fs.renameSync(destination, backup);
|
||||
movedExisting = true;
|
||||
}
|
||||
fs.renameSync(staging, destination);
|
||||
} catch (error) {
|
||||
if (movedExisting && !pathExists(destination) && pathExists(backup)) {
|
||||
try {
|
||||
fs.renameSync(backup, destination);
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[error, restoreError],
|
||||
`Failed to publish engine payload and restore the previous payload at ${destination}`
|
||||
);
|
||||
}
|
||||
} else if (movedExisting && pathExists(destination) && pathExists(backup)) {
|
||||
// Another provisioner won the promotion race; discard only our backup.
|
||||
await removePathWithRetry(backup);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (movedExisting) await removePathWithRetry(backup);
|
||||
await removeOrphanedPreviousPayloads(destination);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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 {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
function temporaryDirectory() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-payload-promotion-'));
|
||||
}
|
||||
|
||||
test('promotes a verified staging directory and replaces the previous payload', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
assert.equal(fs.existsSync(staging), false);
|
||||
assert.equal(fs.readdirSync(root).some(name => name.includes('.previous-')), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an invalid staging path without removing the previous payload', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const staging = path.join(root, 'staging-file');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.writeFileSync(staging, 'not a directory');
|
||||
|
||||
await assert.rejects(() => promoteDirectory(staging, destination), /not a directory/);
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'old');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('recovers one interrupted previous payload before publishing a replacement', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const backup = path.join(root, '.target.previous-123-456');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(backup, { recursive: true });
|
||||
fs.writeFileSync(path.join(backup, 'engine'), 'old');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
assert.equal(fs.existsSync(backup), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when interrupted recovery has multiple candidates', () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const firstBackup = path.join(root, '.target.previous-123-456');
|
||||
const secondBackup = path.join(root, '.target.previous-789-012');
|
||||
fs.mkdirSync(firstBackup, { recursive: true });
|
||||
fs.mkdirSync(secondBackup, { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => recoverInterruptedPromotion(destination),
|
||||
/found 2 previous payloads/
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
assert.equal(fs.existsSync(firstBackup), true);
|
||||
assert.equal(fs.existsSync(secondBackup), true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes a temporary payload tree after it becomes disposable', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const temporary = path.join(root, 'temporary');
|
||||
fs.mkdirSync(path.join(temporary, 'nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(temporary, 'nested', 'archive'), 'payload');
|
||||
|
||||
await removePathWithRetry(temporary);
|
||||
|
||||
assert.equal(fs.existsSync(temporary), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes orphaned backups from dead provisioners after a successful publish', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const orphanedBackup = path.join(root, '.target.previous-999999999-123456');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.mkdirSync(orphanedBackup, { recursive: true });
|
||||
fs.writeFileSync(path.join(orphanedBackup, 'engine'), 'orphaned');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.existsSync(orphanedBackup), false);
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes only provisioning staging owned by a dead PID', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const target = 'x86_64-unknown-linux-gnu';
|
||||
const orphaned = path.join(root, `.firelink-engines-${target}-999999999-dead`);
|
||||
const live = path.join(root, `.firelink-engines-${target}-${process.pid}-live`);
|
||||
const legacy = path.join(root, `.firelink-engines-${target}-legacy`);
|
||||
fs.mkdirSync(orphaned, { recursive: true });
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
|
||||
await removeOrphanedProvisioningDirectories(root, target);
|
||||
|
||||
assert.equal(fs.existsSync(orphaned), false);
|
||||
assert.equal(fs.existsSync(live), true);
|
||||
assert.equal(fs.existsSync(legacy), true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
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 { downloadEngineArchive } from './engine-download.js';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
@@ -34,111 +40,61 @@ if (!targetSources) {
|
||||
}
|
||||
|
||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
|
||||
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
|
||||
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
const provisioningAbortController = new AbortController();
|
||||
const signalNames = process.platform === 'win32'
|
||||
? ['SIGINT', 'SIGTERM']
|
||||
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
const signalHandlers = new Map();
|
||||
for (const signalName of signalNames) {
|
||||
const handler = () => {
|
||||
if (!provisioningAbortController.signal.aborted) {
|
||||
provisioningAbortController.abort(new Error(`Engine provisioning interrupted by ${signalName}`));
|
||||
}
|
||||
};
|
||||
signalHandlers.set(signalName, handler);
|
||||
process.on(signalName, handler);
|
||||
}
|
||||
|
||||
async function removeFileWithRetry(file) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
let temporary;
|
||||
let payloadDestination;
|
||||
|
||||
function throwIfProvisioningAborted() {
|
||||
if (provisioningAbortController.signal.aborted) {
|
||||
throw provisioningAbortController.signal.reason;
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadTimeout() {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
throwIfProvisioningAborted();
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
const archive = path.join(
|
||||
temporary,
|
||||
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
|
||||
);
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
const downloadTimeout = createDownloadTimeout();
|
||||
try {
|
||||
const response = await fetch(source.url, {
|
||||
redirect: 'follow',
|
||||
signal: downloadTimeout.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadTimeout.refresh();
|
||||
callback(null, chunk, encoding);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive),
|
||||
{ signal: downloadTimeout.signal }
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await removeFileWithRetry(archive);
|
||||
if (attempt === DOWNLOAD_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
|
||||
} finally {
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError && !fs.existsSync(archive)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== source.sha256) {
|
||||
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
|
||||
}
|
||||
await downloadEngineArchive({
|
||||
name,
|
||||
url: source.url,
|
||||
archive,
|
||||
expectedSha256: source.sha256,
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
throwIfProvisioningAborted();
|
||||
const extracted = path.join(temporary, `${name}-extracted`);
|
||||
fs.mkdirSync(extracted);
|
||||
if (archive.endsWith('.zip') && process.platform !== 'win32') {
|
||||
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
|
||||
await execFileAsync('unzip', ['-q', archive, '-d', extracted], {
|
||||
stdio: 'inherit',
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
} else {
|
||||
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
|
||||
await execFileAsync('tar', ['-xf', archive, '-C', extracted], {
|
||||
stdio: 'inherit',
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
}
|
||||
throwIfProvisioningAborted();
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -160,13 +116,13 @@ function findFile(root, names) {
|
||||
}
|
||||
|
||||
function copyExecutable(source, engine) {
|
||||
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
|
||||
const output = path.join(payloadDestination, `${engine}-${target}${executableSuffix}`);
|
||||
fs.copyFileSync(source, output);
|
||||
if (!isWindows) fs.chmodSync(output, 0o755);
|
||||
}
|
||||
|
||||
function writePayloadManifest() {
|
||||
const files = collectRegularFiles(destination, {
|
||||
const files = collectRegularFiles(payloadDestination, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
});
|
||||
const manifest = {
|
||||
@@ -184,27 +140,36 @@ function writePayloadManifest() {
|
||||
),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(destination, file).split(path.sep).join('/'),
|
||||
path.relative(payloadDestination, file).split(path.sep).join('/'),
|
||||
sha256(file)
|
||||
])
|
||||
)
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(destination, 'payload-manifest.json'),
|
||||
path.join(payloadDestination, 'payload-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const destinationParent = path.dirname(destination);
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
recoverInterruptedPromotion(destination);
|
||||
await removeOrphanedProvisioningDirectories(destinationParent, target);
|
||||
throwIfProvisioningAborted();
|
||||
// Keep staging on the destination filesystem so the final rename is atomic.
|
||||
temporary = fs.mkdtempSync(
|
||||
path.join(destinationParent, `.firelink-engines-${target}-${process.pid}-`)
|
||||
);
|
||||
payloadDestination = path.join(temporary, 'payload');
|
||||
fs.mkdirSync(payloadDestination, { recursive: true });
|
||||
|
||||
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
||||
copyExecutable(
|
||||
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
||||
'yt-dlp'
|
||||
);
|
||||
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
|
||||
fs.cpSync(path.join(ytdlp, '_internal'), path.join(payloadDestination, '_internal'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true
|
||||
});
|
||||
@@ -219,7 +184,12 @@ try {
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
throwIfProvisioningAborted();
|
||||
await promoteDirectory(payloadDestination, destination);
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
if (temporary) await removePathWithRetry(temporary);
|
||||
for (const [signalName, handler] of signalHandlers) {
|
||||
process.removeListener(signalName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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 releaseWorkflow = fs.readFileSync(
|
||||
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('release Linux dependency installation is mirror-normalized and bounded', () => {
|
||||
assert.match(releaseWorkflow, /azure\\\.archive\\\.ubuntu\\\.com/);
|
||||
assert.equal((releaseWorkflow.match(/Acquire::Retries=3/g) || []).length, 2);
|
||||
assert.equal((releaseWorkflow.match(/timeout --foreground --signal=TERM --kill-after=30s 10m apt-get/g) || []).length, 2);
|
||||
assert.doesNotMatch(releaseWorkflow, /^\s*sudo apt-get (update|install)/m);
|
||||
});
|
||||
|
||||
test('macOS release verification uses the app mounted from the final DMG', () => {
|
||||
assert.match(releaseWorkflow, /npm run verify:macos-signing -- --dmg "\$DMG"/);
|
||||
assert.match(releaseWorkflow, /hdiutil attach -nobrowse -readonly -mountpoint "\$MOUNT_POINT" "\$DMG"/);
|
||||
assert.match(releaseWorkflow, /find "\$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink\.app'/);
|
||||
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
|
||||
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
const platform = {
|
||||
darwin: 'apple-darwin',
|
||||
linux: 'unknown-linux-gnu',
|
||||
win32: 'pc-windows-msvc',
|
||||
}[process.platform];
|
||||
if (!arch || !platform) {
|
||||
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
||||
}
|
||||
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' : ''}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
}
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = address && typeof address !== 'string' ? address.port : undefined;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
if (!port) throw new Error('Could not reserve a local port');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function rpc(port, secret, method, params = []) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params: [`token:${secret}`, ...params],
|
||||
}),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs = 3000) {
|
||||
if (childExited(child)) return true;
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRpc(port, secret) {
|
||||
const deadline = Date.now() + 10000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await rpc(port, secret, 'aria2.getVersion');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
function bencode(value) {
|
||||
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||
if (typeof value === 'string') return bencode(Buffer.from(value));
|
||||
if (typeof value === 'number') return Buffer.from(`i${value}e`);
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||
return Buffer.concat([
|
||||
Buffer.from('d'),
|
||||
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
|
||||
Buffer.from('e'),
|
||||
]);
|
||||
}
|
||||
throw new Error(`Unsupported bencode value: ${typeof value}`);
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
let exited = await waitForChildExit(child);
|
||||
if (!exited) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch {
|
||||
// The process may have exited between the timeout and taskkill.
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited && process.platform !== 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited) {
|
||||
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
|
||||
const secret = `firelink-resolver-${crypto.randomUUID()}`;
|
||||
const rpcPort = await availablePort();
|
||||
const contentServer = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-length': '1' });
|
||||
response.end('x');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
contentServer.once('error', reject);
|
||||
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const contentPort = contentServer.address().port;
|
||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--rpc-secret=${secret}`,
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
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'}`);
|
||||
|
||||
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
||||
'async-dns': 'false',
|
||||
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)}`);
|
||||
}
|
||||
|
||||
const torrent = bencode({
|
||||
info: {
|
||||
length: 1,
|
||||
name: 'resolver-torrent.bin',
|
||||
pieces: Buffer.alloc(20),
|
||||
'piece length': 16384,
|
||||
},
|
||||
}).toString('base64');
|
||||
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||
'async-dns': 'false',
|
||||
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)}`);
|
||||
}
|
||||
|
||||
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
} finally {
|
||||
await new Promise(resolve => contentServer.close(resolve));
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
const platform = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
|
||||
if (!arch || !platform) throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
||||
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' : ''}`));
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function listen(server) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Could not allocate local fixture port');
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
const port = await listen(server);
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function rpc(port, secret, method, params = []) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params: [`token:${secret}`, ...params],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
async function waitForRpc(port, secret) {
|
||||
const deadline = Date.now() + 10000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await rpc(port, secret, 'aria2.getVersion');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
async function waitForTerminal(port, secret, gid, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, [
|
||||
'status', 'errorCode', 'errorMessage', 'completedLength', 'totalLength',
|
||||
]]);
|
||||
if (['complete', 'error', 'removed'].includes(latest.status)) return latest;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} did not become terminal: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
async function waitForProgress(port, secret, gid, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, ['status', 'completedLength']]);
|
||||
if (latest.status === 'active' && Number(latest.completedLength) > 0) return latest;
|
||||
await wait(25);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} made no observable progress: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
function serveBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
if (!match) {
|
||||
response.writeHead(200, { 'content-length': String(buffer.length), 'accept-ranges': 'bytes' });
|
||||
response.end(buffer);
|
||||
return;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const requestedEnd = match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(206, {
|
||||
'content-length': String(body.length),
|
||||
'content-range': `bytes ${start}-${end}/${buffer.length}`,
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function serveThrottledBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
const requestedEnd = match && match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(match ? 206 : 200, {
|
||||
'content-length': String(body.length),
|
||||
...(match ? { 'content-range': `bytes ${start}-${end}/${buffer.length}` } : {}),
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
let offset = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || offset >= body.length) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(offset + 32 * 1024, body.length);
|
||||
response.write(body.subarray(offset, next));
|
||||
offset = next;
|
||||
}, 20);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs = 8000) {
|
||||
if (childExited(child)) return true;
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// It may already be stopping.
|
||||
}
|
||||
let exited = await waitForChildExit(child);
|
||||
if (!exited) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch {
|
||||
// It may have exited between the timeout and taskkill.
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited && process.platform !== 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited) throw new Error(`Aria2 process ${child.pid} did not exit after cleanup`);
|
||||
}
|
||||
|
||||
async function removeTempRoot(tempRoot) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100 * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
|
||||
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
|
||||
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
|
||||
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
|
||||
let finalRequests = 0;
|
||||
let finalCredentials = [];
|
||||
let redirectSourceCredentials;
|
||||
let smokePassed = false;
|
||||
let smokeFailure;
|
||||
|
||||
const targetServer = http.createServer((request, response) => {
|
||||
finalRequests += 1;
|
||||
finalCredentials.push({
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
});
|
||||
serveBuffer(request, response, payload);
|
||||
});
|
||||
const targetPort = await listen(targetServer);
|
||||
const fixtureServer = http.createServer((request, response) => {
|
||||
switch (new URL(request.url, 'http://fixture.invalid').pathname) {
|
||||
case '/range':
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
case '/no-range':
|
||||
response.writeHead(200, { 'content-length': String(payload.length) });
|
||||
response.end(payload);
|
||||
break;
|
||||
case '/throttled':
|
||||
serveThrottledBuffer(request, response, payload);
|
||||
break;
|
||||
case '/authenticated': {
|
||||
const expectedAuthorization = `Basic ${Buffer.from('fixture-user:fixture-password').toString('base64')}`;
|
||||
if (request.headers.authorization !== expectedAuthorization
|
||||
|| request.headers.cookie !== 'fixture-cookie=present'
|
||||
|| request.headers['x-firelink-auth'] !== 'present') {
|
||||
response.writeHead(401, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
}
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
}
|
||||
case '/redirect':
|
||||
redirectSourceCredentials = {
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
};
|
||||
response.writeHead(302, { location: `http://127.0.0.1:${targetPort}/final` });
|
||||
response.end();
|
||||
break;
|
||||
case '/missing':
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
case '/malformed':
|
||||
response.writeHead(200, { 'content-length': String(payload.length * 2) });
|
||||
response.write(payload.subarray(0, 1024));
|
||||
response.destroy();
|
||||
break;
|
||||
case '/slow': {
|
||||
const slowLength = 64 * 1024;
|
||||
let written = 0;
|
||||
response.writeHead(200, { 'content-length': String(slowLength) });
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || written >= slowLength) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
response.write(Buffer.alloc(1024, 0x73));
|
||||
written += 1024;
|
||||
}, 500);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
const fixturePort = await listen(fixtureServer);
|
||||
const rpcPort = await availablePort();
|
||||
const unavailableProxyPort = await availablePort();
|
||||
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 environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--conf-path=${configPath}`,
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
`--server-stat-if=${serverStatPath}`,
|
||||
`--server-stat-of=${serverStatPath}`,
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
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`);
|
||||
|
||||
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||
}]);
|
||||
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
||||
if (rangeStatus.status !== 'complete' || !fs.readFileSync(path.join(tempRoot, 'range.bin')).equals(payload)) {
|
||||
throw new Error(`bounded-range transfer failed: ${JSON.stringify(rangeStatus)}`);
|
||||
}
|
||||
|
||||
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
|
||||
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
|
||||
throw new Error('single-connection no-range transfer did not complete');
|
||||
}
|
||||
|
||||
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
|
||||
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
||||
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, authenticatedGid)).status !== 'complete') {
|
||||
throw new Error('authenticated cookie/header transfer did not complete');
|
||||
}
|
||||
|
||||
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'resume.bin', split: '1', continue: 'true',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, resumeGid);
|
||||
await rpc(rpcPort, secret, 'aria2.pause', [resumeGid]);
|
||||
const pausedStatus = await rpc(rpcPort, secret, 'aria2.tellStatus', [resumeGid, ['status', 'completedLength']]);
|
||||
if (pausedStatus.status !== 'paused' || Number(pausedStatus.completedLength) <= 0) {
|
||||
throw new Error(`normal transfer did not pause with resumable progress: ${JSON.stringify(pausedStatus)}`);
|
||||
}
|
||||
await rpc(rpcPort, secret, 'aria2.unpause', [resumeGid]);
|
||||
if ((await waitForTerminal(rpcPort, secret, resumeGid)).status !== 'complete') {
|
||||
throw new Error('paused normal transfer did not resume to completion');
|
||||
}
|
||||
|
||||
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'cancel.bin', split: '1',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, cancelGid);
|
||||
await rpc(rpcPort, secret, 'aria2.remove', [cancelGid]);
|
||||
const cancelStatus = await waitForTerminal(rpcPort, secret, cancelGid);
|
||||
if (cancelStatus.status !== 'removed') {
|
||||
throw new Error(`normal transfer cancellation did not reach removed: ${JSON.stringify(cancelStatus)}`);
|
||||
}
|
||||
|
||||
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' }]);
|
||||
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`], {
|
||||
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`], {
|
||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||
if (mismatchStatus.status !== 'error') throw new Error(`checksum mismatch was not rejected: ${JSON.stringify(mismatchStatus)}`);
|
||||
|
||||
// Model Firelink's manual preflight: credentials reach the original origin,
|
||||
// the redirect is not followed by the HTTP client, and Aria2 receives only
|
||||
// the resolved cross-origin URL without credential options.
|
||||
const redirectProbe = await fetch(`http://127.0.0.1:${fixturePort}/redirect`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-0',
|
||||
Authorization: 'Bearer fixture-secret',
|
||||
Cookie: 'fixture=secret',
|
||||
'X-Firelink-Secret': 'fixture',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (redirectProbe.status !== 302) throw new Error(`redirect preflight returned HTTP ${redirectProbe.status}`);
|
||||
if (!redirectSourceCredentials || Object.values(redirectSourceCredentials).some(value => !value)) {
|
||||
throw new Error(`redirect source did not receive its scoped credentials: ${JSON.stringify(redirectSourceCredentials)}`);
|
||||
}
|
||||
const redirectLocation = redirectProbe.headers.get('location');
|
||||
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()], {
|
||||
out: 'redirect.bin',
|
||||
}]);
|
||||
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
|
||||
if (redirectStatus.status !== 'complete' || finalRequests === 0 || finalCredentials.some(headers => Object.values(headers).some(Boolean))) {
|
||||
throw new Error(`redirect credential boundary failed: ${JSON.stringify({ redirectStatus, finalRequests, finalCredentials })}`);
|
||||
}
|
||||
|
||||
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
|
||||
out: 'missing.bin', 'max-tries': '1',
|
||||
}]);
|
||||
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
|
||||
if (missingStatus.status !== 'error' || !['3', '4'].includes(missingStatus.errorCode)) {
|
||||
throw new Error(`not-found error classification changed: ${JSON.stringify(missingStatus)}`);
|
||||
}
|
||||
|
||||
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
|
||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||
}]);
|
||||
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
||||
if (lowSpeedStatus.status !== 'error' || lowSpeedStatus.errorCode !== '5') {
|
||||
throw new Error(`low-speed error classification changed: ${JSON.stringify(lowSpeedStatus)}`);
|
||||
}
|
||||
|
||||
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
|
||||
out: 'malformed.bin', 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
|
||||
throw new Error('malformed response unexpectedly completed');
|
||||
}
|
||||
|
||||
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||
throw new Error('unavailable proxy unexpectedly completed');
|
||||
}
|
||||
|
||||
smokePassed = true;
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
if (smokePassed) {
|
||||
const stat = fs.readFileSync(serverStatPath, 'utf8');
|
||||
if (!stat.includes('host=127.0.0.1')) {
|
||||
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([
|
||||
new Promise(resolve => fixtureServer.close(resolve)),
|
||||
new Promise(resolve => targetServer.close(resolve)),
|
||||
]);
|
||||
await removeTempRoot(tempRoot);
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (smokeFailure) throw smokeFailure;
|
||||
console.log('[PASS] Aria2 normal transfers, auth/cookies, resume/cancel, mirrors, integrity, redirects, low-speed/not-found classification, malformed responses, proxy failures, and server statistics');
|
||||
@@ -17,6 +17,12 @@ if (!executableArg) {
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const MAX_STABILITY_MS = 60_000;
|
||||
const MAX_CONSECUTIVE_STABILITY_FAILURES = 3;
|
||||
const stabilityMsValue = Number.parseInt(argValue('--stability-ms') || '5000', 10);
|
||||
const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
|
||||
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
||||
: 5000;
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
@@ -83,6 +89,55 @@ async function findReadyPort() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkReadyPort() {
|
||||
if (readyPort === null) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${readyPort}/ping`, {
|
||||
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
|
||||
});
|
||||
const matchesChild = response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
|
||||
await response.body?.cancel();
|
||||
return matchesChild;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStableReady() {
|
||||
const deadline = Date.now() + stabilityMs;
|
||||
let consecutiveFailures = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (spawnError) {
|
||||
throw new Error(`Packaged Firelink failed during stability check: ${spawnError.message}`);
|
||||
}
|
||||
if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
}
|
||||
if (await checkReadyPort()) {
|
||||
consecutiveFailures = 0;
|
||||
} else {
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_STABILITY_FAILURES) {
|
||||
throw new Error('Packaged Firelink stopped exposing its extension ping endpoint during stability check.');
|
||||
}
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
}
|
||||
if (!await checkReadyPort() && !await checkReadyPort()) {
|
||||
throw new Error('Packaged Firelink was not healthy at the end of its stability check.');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoVisibleWindows(rootPid) {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
@@ -277,18 +332,16 @@ async function terminateChild() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
|
||||
const childExited = await waitForChildExit(5000);
|
||||
const processGroupExited = await waitForProcessGroupExit(child.pid, 5000);
|
||||
if (childExited && processGroupExited) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!childWasRunning || childExit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
if (!childExited) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
@@ -338,11 +391,9 @@ try {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
|
||||
}
|
||||
await assertStableReady();
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort} with ${stabilityMs}ms stability`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ function exactVersionTag(extensionRoot, expectedTag) {
|
||||
export function verifyCompanionRelease({
|
||||
repositoryRoot = defaultRepositoryRoot,
|
||||
resolveExactTag = exactVersionTag,
|
||||
requireExactTag = true,
|
||||
} = {}) {
|
||||
const extensionRoot = path.join(repositoryRoot, 'Extensions', 'Browser');
|
||||
const packagePath = path.join(extensionRoot, 'package.json');
|
||||
@@ -53,6 +54,9 @@ export function verifyCompanionRelease({
|
||||
}
|
||||
|
||||
const expectedTag = `v${packageVersion}`;
|
||||
if (!requireExactTag) {
|
||||
return { tag: null, version: packageVersion };
|
||||
}
|
||||
const tag = resolveExactTag(extensionRoot, expectedTag);
|
||||
if (!tag) {
|
||||
throw new Error(
|
||||
@@ -68,8 +72,13 @@ export function verifyCompanionRelease({
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const { tag, version } = verifyCompanionRelease();
|
||||
console.log(`Companion release ${version} matches exact tag ${tag}.`);
|
||||
const requireExactTag = !process.argv.includes('--allow-untagged');
|
||||
const { tag, version } = verifyCompanionRelease({ requireExactTag });
|
||||
console.log(
|
||||
requireExactTag
|
||||
? `Companion release ${version} matches exact tag ${tag}.`
|
||||
: `Non-publishing Companion package metadata agrees at version ${version}.`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -84,6 +84,27 @@ test('rejects an untagged Companion commit', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts aligned untagged Companion metadata for a non-publishing audit', () => {
|
||||
const root = createFixture('2.0.7', '2.0.7');
|
||||
let tagLookupCalled = false;
|
||||
try {
|
||||
assert.deepEqual(
|
||||
verifyCompanionRelease({
|
||||
repositoryRoot: root,
|
||||
requireExactTag: false,
|
||||
resolveExactTag: () => {
|
||||
tagLookupCalled = true;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
{ tag: null, version: '2.0.7' }
|
||||
);
|
||||
assert.equal(tagLookupCalled, false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a Companion tag for another version', () => {
|
||||
const root = createFixture('2.0.7', '2.0.7');
|
||||
try {
|
||||
|
||||
@@ -42,23 +42,11 @@ function versionFromRef(ref) {
|
||||
return version;
|
||||
}
|
||||
|
||||
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
|
||||
const expected = versionFromRef(tag);
|
||||
const versions = {
|
||||
'package.json': readPackageVersion(repositoryRoot),
|
||||
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
|
||||
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
|
||||
};
|
||||
const mismatches = Object.entries(versions)
|
||||
.filter(([, version]) => version !== expected)
|
||||
.map(([file, version]) => `${file}=${version}`);
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
|
||||
for (const mismatch of mismatches) console.error(` ${mismatch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const uniqueVersions = new Set(Object.values(versions));
|
||||
if (uniqueVersions.size !== 1) {
|
||||
console.error('Application version manifests do not agree:');
|
||||
@@ -66,4 +54,21 @@ if (uniqueVersions.size !== 1) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Release version ${expected} matches ${Object.keys(versions).length} manifests.`);
|
||||
const allowUntagged = process.argv.includes('--allow-untagged');
|
||||
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
|
||||
const expected = allowUntagged ? Object.values(versions)[0] : versionFromRef(tag);
|
||||
const mismatches = Object.entries(versions)
|
||||
.filter(([, version]) => version !== expected)
|
||||
.map(([file, version]) => `${file}=${version}`);
|
||||
|
||||
if (!allowUntagged && mismatches.length > 0) {
|
||||
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
|
||||
for (const mismatch of mismatches) console.error(` ${mismatch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
allowUntagged
|
||||
? `Non-publishing package version ${expected} matches ${Object.keys(versions).length} manifests.`
|
||||
: `Release version ${expected} matches ${Object.keys(versions).length} manifests.`
|
||||
);
|
||||
|
||||
@@ -11,8 +11,8 @@ const currentVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
function runVerifier(tag) {
|
||||
return spawnSync(process.execPath, [verifier, '--tag', tag], {
|
||||
function runVerifier(tag, ...extraArguments) {
|
||||
return spawnSync(process.execPath, [verifier, '--tag', tag, ...extraArguments], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
@@ -35,3 +35,9 @@ test('release version verifier rejects non-semver tag names', () => {
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /semantic version tag/);
|
||||
});
|
||||
|
||||
test('release version verifier accepts an untagged non-publishing package audit', () => {
|
||||
const result = runVerifier('main', '--allow-untagged');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`Non-publishing package version ${currentVersion} matches`));
|
||||
});
|
||||
|
||||
Generated
+374
-239
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -40,6 +40,8 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
@@ -63,9 +65,11 @@ keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.1", 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"] }
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -717,35 +717,6 @@ g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN
|
||||
BSeOE6Fuwg==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Atos TrustedRoot 2011 O=Atos
|
||||
# Subject: CN=Atos TrustedRoot 2011 O=Atos
|
||||
# Label: "Atos TrustedRoot 2011"
|
||||
# Serial: 6643877497813316402
|
||||
# MD5 Fingerprint: ae:b9:c4:32:4b:ac:7f:5d:66:cc:77:94:bb:2a:77:56
|
||||
# SHA1 Fingerprint: 2b:b1:f5:3e:55:0c:1d:c5:f1:d4:e6:b7:6a:46:4b:55:06:02:ac:21
|
||||
# SHA256 Fingerprint: f3:56:be:a2:44:b7:a9:1e:b3:5d:53:ca:9a:d7:86:4a:ce:01:8e:2d:35:d5:f8:f9:6d:df:68:a6:f4:1a:a4:74
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UE
|
||||
AwwVQXRvcyBUcnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQG
|
||||
EwJERTAeFw0xMTA3MDcxNDU4MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMM
|
||||
FUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsGA1UECgwEQXRvczELMAkGA1UEBhMC
|
||||
REUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVhTuXbyo7LjvPpvMp
|
||||
Nb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr54rM
|
||||
VD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+
|
||||
SZFhyBH+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ
|
||||
4J7sVaE3IqKHBAUsR320HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0L
|
||||
cp2AMBYHlT8oDv3FdU9T1nSatCQujgKRz3bFmx5VdJx4IbHwLfELn8LVlhgf8FQi
|
||||
eowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7Rl+lwrrw7GWzbITAPBgNV
|
||||
HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZbNshMBgG
|
||||
A1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8j
|
||||
vZfza1zv7v1Apt+hk6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kP
|
||||
DpFrdRbhIfzYJsdHt6bPWHJxfrrhTZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pc
|
||||
maHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a961qn8FYiqTxlVMYVqL2Gns2D
|
||||
lmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G3mB/ufNPRJLv
|
||||
KrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=QuoVadis Root CA 1 G3 O=QuoVadis Limited
|
||||
# Subject: CN=QuoVadis Root CA 1 G3 O=QuoVadis Limited
|
||||
# Label: "QuoVadis Root CA 1 G3"
|
||||
@@ -3861,3 +3832,128 @@ y7Ve7hekm9sEdu4pKeiv+62sUvTXK9Z3hBC9xdIoaDQCQTV2WnXzkoYI9bIeCvZl
|
||||
C9p2x1L/Cx6AcCIwwzPbGO2E14vs7dOoY4G1VnxHx1YwlGhza9IuqbnZLBwpvQy6
|
||||
uWWL
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SECOM TLS RSA Root CA 2024 O=SECOM Trust Systems Co., Ltd.
|
||||
# Subject: CN=SECOM TLS RSA Root CA 2024 O=SECOM Trust Systems Co., Ltd.
|
||||
# Label: "SECOM TLS RSA Root CA 2024"
|
||||
# Serial: 17188327524208271538
|
||||
# MD5 Fingerprint: d0:a4:db:32:eb:44:98:d2:62:0b:3e:bc:4d:7c:5c:e9
|
||||
# SHA1 Fingerprint: fb:97:96:7c:ef:8d:98:63:06:c0:3b:b6:11:f8:e0:13:97:a2:98:d3
|
||||
# SHA256 Fingerprint: 14:35:f2:25:c5:d2:52:d7:a2:19:48:cc:3c:e6:2a:ec:fa:88:00:1e:3d:d7:2d:1c:c3:55:51:00:eb:37:2f:93
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFmjCCA4KgAwIBAgIJAO6JNNDLgOCyMA0GCSqGSIb3DQEBDAUAMFoxCzAJBgNV
|
||||
BAYTAkpQMSYwJAYDVQQKEx1TRUNPTSBUcnVzdCBTeXN0ZW1zIENvLiwgTHRkLjEj
|
||||
MCEGA1UEAxMaU0VDT00gVExTIFJTQSBSb290IENBIDIwMjQwHhcNMjQwMTMxMDUx
|
||||
MTU1WhcNNDkwMTE0MDUxMTU1WjBaMQswCQYDVQQGEwJKUDEmMCQGA1UEChMdU0VD
|
||||
T00gVHJ1c3QgU3lzdGVtcyBDby4sIEx0ZC4xIzAhBgNVBAMTGlNFQ09NIFRMUyBS
|
||||
U0EgUm9vdCBDQSAyMDI0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
|
||||
4TjizUwzxbInq8Tx11gaFYNk5fO+34y7TyM4neh0UgL5JIZbJNLTz2x//L/B71+5
|
||||
m6X6nGIr7d4lFJBGtjO677hXOz93zkcWaUTm3VbOAjBlt4YWxlcccBHXuZ7o3Q+4
|
||||
R+ormrBdHeJ1CTUEG8ttQbKIl3G7OZYbnH8/pP8cjPub/0kDVNuMzp7xsVRROOis
|
||||
Qt53fMoJLlYgoebbuMphOqMCtjkJ7R6efEMfLp8UAVi9ZaLRn76ET/CJkk925ndu
|
||||
uufC4BatS4mnXFmxN0vUXb0ij9B8O/D8gixQEsVSD4GK8FWRPh3bVd/6bzdkHGJj
|
||||
y21XI0yejVomZUbRrOfNuz0boPGV1pt18fFC39IHQEth3OFqb5NDO3L+A9bNqTgA
|
||||
yUgRmIn4ucgDc/Ri/Km3V51ueZjy1/yk0qwJVadAVVrCt56iNeXOyEvzJADGgDQ8
|
||||
E1Pdaqct8Cynz/47ReQM62vFYO08wcQkrjmX/tesiko1V1yyaf6EfPzUFzmaGy9x
|
||||
vkCwdbm15EdTolOjE0H2Vb5/APDOyCFEokiYGmXTLdAUl0wKZ4IyjkHGzy0jhpaX
|
||||
EXE/GJcEvI6VzEchjaBL03EJ0h9pG4OqeIOycKvAo3A+TbetyfsrgYyHzU0a7/qU
|
||||
jGat1AAq1nVljMpKqpinPTsf/d9H39FTUeJL7TpzzjUCAwEAAaNjMGEwHQYDVR0O
|
||||
BBYEFCzrchKOWHdkNRVWNQFXB6l9DTbmMB8GA1UdIwQYMBaAFCzrchKOWHdkNRVW
|
||||
NQFXB6l9DTbmMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
|
||||
SIb3DQEBDAUAA4ICAQAVwsvluSafaez5tFPR/hRTBzRxEyMMQF3XJXCVi3yegZyK
|
||||
oec7hmE6jx2ZM8KgM1kn2yJRwFXHX8zUW9nBLEDWc4wuE8LrlZqhGZM9pJQXGmGz
|
||||
ResDJV6JgRBna+j4sA1M7yIdlvL0sAfFXFCTRaWTD4E1V99RLrFzWfTcC+e180hD
|
||||
uNMpqOEo46+lMeW/Wvh7ifOQs+kiK0O2gHxQDNxslSavnCs4V7l8HRDJ2La10o70
|
||||
Bo7VLzf1W8MBvv0VTnxB+NjT5qTAbhGFh9Gvp4BaJpmdUf0C5CEP6dbQlfgxWfzY
|
||||
r69yVT6dPQB+GFEaY03IMY+AcBCs+om1fNxrQXt9zoofMBNFbLhvpNH/JsXWdGUz
|
||||
fNbO12uswTa5wah8LB18FTQN2/zPHYmvBEoLuyUgZ09VNLJo5YA0kXItVYkLjMe2
|
||||
SixzK4scUHv81IK99I91DWx7FwMVKw2xgFp+ZLYB2dnpQQrqwlW64glHUcK2N9BD
|
||||
snjLSxeZ+UPECh9RxH4WAcKiZW+cqaKMmhP2WBfR4IcR7NOL32ml11ds87hhV1CZ
|
||||
WWFCJAcCidYZz6CZa8exzHojP9SB5RH0/v1KdHAisqhSjtJl/UIAHIQ48elOn8wr
|
||||
TdFap4Yb5aHglmMeNx+fAIhDluWVfxTO7H4dTPU+SFVRMLAh+wwKZfqb94nMeQ==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SECOM TLS ECC Root CA 2024 O=SECOM Trust Systems Co., Ltd.
|
||||
# Subject: CN=SECOM TLS ECC Root CA 2024 O=SECOM Trust Systems Co., Ltd.
|
||||
# Label: "SECOM TLS ECC Root CA 2024"
|
||||
# Serial: 9329818985461676612
|
||||
# MD5 Fingerprint: 99:d3:9d:e4:d2:b1:2d:f0:2a:04:67:85:f3:df:46:d6
|
||||
# SHA1 Fingerprint: 7a:1f:22:2d:72:b2:c3:19:87:44:db:61:69:e8:a6:4b:d7:0d:44:0e
|
||||
# SHA256 Fingerprint: 6a:b2:ab:75:f5:1c:b4:f4:f0:15:62:03:fb:f6:f6:46:23:2f:51:4b:e0:59:f6:28:33:30:8b:82:b4:d7:2d:b1
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICTDCCAdGgAwIBAgIJAIF6LO+PI3pEMAoGCCqGSM49BAMDMFoxCzAJBgNVBAYT
|
||||
AkpQMSYwJAYDVQQKEx1TRUNPTSBUcnVzdCBTeXN0ZW1zIENvLiwgTHRkLjEjMCEG
|
||||
A1UEAxMaU0VDT00gVExTIEVDQyBSb290IENBIDIwMjQwHhcNMjQwMTMxMDU1MjM0
|
||||
WhcNNDkwMTE0MDU1MjM0WjBaMQswCQYDVQQGEwJKUDEmMCQGA1UEChMdU0VDT00g
|
||||
VHJ1c3QgU3lzdGVtcyBDby4sIEx0ZC4xIzAhBgNVBAMTGlNFQ09NIFRMUyBFQ0Mg
|
||||
Um9vdCBDQSAyMDI0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7NzFMtu9dzQXSNC1
|
||||
2fabk0+GlC5finB3R7XaZonRUd20aFiWObtuNBCLUZSfk6QXAE55BjEXsXQ/NG8y
|
||||
UqicXjsu9ksDK3JZBgCwLOVh6+nwJXTvso/dEj/GUYH5mBdoo2MwYTAdBgNVHQ4E
|
||||
FgQUO3YReyl04k4GTFaCQNAhL3qzydUwHwYDVR0jBBgwFoAUO3YReyl04k4GTFaC
|
||||
QNAhL3qzydUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZI
|
||||
zj0EAwMDaQAwZgIxAN3ib8fi1pMYtAPjMilB5e5/H+t5CL0xPL+cZ5oTTZuSCjpA
|
||||
n1v7F/VAr8bFxQXAowIxAKsBVO1ACFp7skwzPvdv1EUY5a897WGLT4lb+bjxFAWy
|
||||
l8wDcZJdwGZ/pAHxt1AJ1g==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Telia EC TLS Root CA v3 O=Telia Company AB
|
||||
# Subject: CN=Telia EC TLS Root CA v3 O=Telia Company AB
|
||||
# Label: "Telia EC TLS Root CA v3"
|
||||
# Serial: 8028200332079443110287896228123317
|
||||
# MD5 Fingerprint: b6:fa:6a:5c:42:fb:c5:67:72:c0:e0:2f:72:fb:5a:44
|
||||
# SHA1 Fingerprint: b4:d6:07:c2:a5:95:bc:5b:f4:67:4d:c9:dc:6f:6f:0a:00:7a:a5:35
|
||||
# SHA256 Fingerprint: 09:8e:08:a9:1d:bb:f7:74:78:b9:6c:ce:b8:9b:14:13:a5:da:37:b7:c8:62:60:6a:95:5d:eb:07:17:9f:43:26
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICMjCCAbegAwIBAgIPAYvSIlRjTQSLbOVHH9K1MAoGCCqGSM49BAMDMEoxCzAJ
|
||||
BgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSAwHgYDVQQDDBdU
|
||||
ZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzAeFw0yMzExMTUwODU1MjZaFw00ODA1MjMx
|
||||
MTAwMDBaMEoxCzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFC
|
||||
MSAwHgYDVQQDDBdUZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzB2MBAGByqGSM49AgEG
|
||||
BSuBBAAiA2IABMHIlhVDLbmFKUpW0iK4dpryT6emYOeS31JPwWnWPmkWRrAkTbPX
|
||||
40sQfHI9mpR7Rbktu3ngg6W+BBSXSechtMCnBmWXj/EaVlmV5cY1jD2HoTfhBQ3A
|
||||
acpCNMLJK4NpZaNjMGEwHwYDVR0jBBgwFoAU1GToQ4g6cy/QGnGCNgtehd7H3kMw
|
||||
HQYDVR0OBBYEFNRk6EOIOnMv0BpxgjYLXoXex95DMA4GA1UdDwEB/wQEAwIBBjAP
|
||||
BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2kAMGYCMQCXAUdS/9bbJ8A1JYaG
|
||||
f/bWt/s7Ta0ot5Ulno8OjSNYRWQIlS4tVWldvTAVA7heOFgCMQCvKr8+Z2Rn+OBr
|
||||
5UHzlgBObpad1LuwNTRcdNgUJxIWadcki+UBLEi1/AURKV5md2M=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=Telia RSA TLS Root CA v3 O=Telia Company AB
|
||||
# Subject: CN=Telia RSA TLS Root CA v3 O=Telia Company AB
|
||||
# Label: "Telia RSA TLS Root CA v3"
|
||||
# Serial: 8028214673410753541795188766939845
|
||||
# MD5 Fingerprint: f4:8b:9c:f3:f8:63:cf:dc:25:8f:b4:bb:a2:e9:9d:e2
|
||||
# SHA1 Fingerprint: b5:2e:88:4e:40:c1:11:fb:50:c7:e2:4f:ac:18:2b:bd:68:15:d2:34
|
||||
# SHA256 Fingerprint: d1:3d:b1:29:4c:45:eb:c6:fc:86:c6:bb:f6:9f:a2:9b:df:e6:92:df:f7:c7:13:c2:43:c7:a9:56:c6:a2:28:4c
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFgjCCA2qgAwIBAgIPAYvSUKtCVSxHWr2h3BrFMA0GCSqGSIb3DQEBDAUAMEsx
|
||||
CzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSEwHwYDVQQD
|
||||
DBhUZWxpYSBSU0EgVExTIFJvb3QgQ0EgdjMwHhcNMjMxMTE1MDk0NzQyWhcNNDgw
|
||||
NTIzMTEwMDAwWjBLMQswCQYDVQQGEwJTRTEZMBcGA1UECgwQVGVsaWEgQ29tcGFu
|
||||
eSBBQjEhMB8GA1UEAwwYVGVsaWEgUlNBIFRMUyBSb290IENBIHYzMIICIjANBgkq
|
||||
hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsV89KG19hCf4S1Fvk8D3TyDERhmcvx8F
|
||||
7Kmb4WATx3ije1id3KHxRE0TKmcNCbAQ57bvHFEYa4hR2l20VjVadExqOW+2ld99
|
||||
MbEiO+jRVOz+BbxLxJnmGwCqI+BfuTjjVReDxsxjQvjgBsClaO/sm5i70nlZcWGR
|
||||
tIkvWDK3NNkT5RtwXc/O8NTFVpbUqT6cRjIj3olAblR+lRf4Ffy5o+Q9fabjYn9Z
|
||||
9S4itruElcEFf9Ljk7fwdTycT/rvJW9w/B3G2a3r0f/zXNOVruIBcqE6pkSospAC
|
||||
U2bG42fYKrbM/GWnp7u+p9Frz4jaNwpb4YHuEeS8BratNcP8X62jXIvvKHxlsMDJ
|
||||
Cnb4U8JzFOLsU6mohVY58BdZrvi0Gk9UOuqmgoG6dskHoksjZTlK61D/InzmEoA1
|
||||
yAYJFDVysjRxDUOu9cAwANbqmq77WIFL6BpnZgVqPtMfG6wN8BrTKdapvilVsYR5
|
||||
9BFgIsAVBMxrGh+W+QcvmJafUpASvlArKvVG2FI4i6PiLjSBT0+6F6EQLrYqefOQ
|
||||
F/fBNEXb+njUQ0SUVrAqtH4Y+OjCI/a4/JJQppxeemZcQ0SUShgiI5AM5xHO5iya
|
||||
UrTjYH4zxUz9j+1FEbDH/xpstr1gXBykspup+hRTaJcbA+UbpJqtWZndAPddJmt6
|
||||
YJQ+dU3pDu8CAwEAAaNjMGEwHwYDVR0jBBgwFoAUsMep0t2yKFZzBJSMFFxIbzdS
|
||||
kqgwHQYDVR0OBBYEFLDHqdLdsihWcwSUjBRcSG83UpKoMA4GA1UdDwEB/wQEAwIB
|
||||
BjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQBdYzFsNGDRk7bR
|
||||
/AgRKq+5637YuOW+w6uhpoS0VnKMUpyHCwku86hEvqivakPtfmlm4bFwt++sb/8O
|
||||
XsWBqtfbXMaBNDTZl8XRMJuLWOW2JrbKkRzgG0eBUcvsadG1rrhbmZqYvFXaAZO7
|
||||
o4TdOZzxhBB5GOAWWXB3IeraNP4J63zyo9n8Gqw3sJBG44em5hoYjBffP+npibys
|
||||
lnslRi4L6xHsCYj/Pab+OlqbMCB6v+sTCLeEIukRVzoR9aQ45pEK7Z1QBnSsbAKQ
|
||||
tss0JKD9d/mX143H1xePjPhTXlv5JCkhrcj+SShz0P9+EHoWe6m9lyUEOIVn0rp+
|
||||
yVJWNbmyDv3VkwFxHC1ApSQsgSimjGQ4wtr6cSmordYxkV+Ro8lOIIhRksXPyDk2
|
||||
7gW6IjUXCkZKpxFjkL3jiBSc8SkxnwCWtXg8xwNwdFVNBGLCCuJnsneYXjJNqzRq
|
||||
UcoGwzsvF3Qi/ZnHUNvISdevlgIAXL4Wvrxaqvoa01wB+GCfs57RTGE4TvAGhKNK
|
||||
us8K3hRT1BSpigzMIRzSxtAOrqPN6j//QSmW9f8Jcncri4j2ihSpVrFU0NdNkMhZ
|
||||
eAKidTFPsxCVFuW4Aniz7jqiw5sWtjbQrlW035izIEU4sYwQoC1Nx0Svy+mMTRai
|
||||
50LqFQ+A1/Hq6xHHDNx7CI83d23Erw==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,60 +0,0 @@
|
||||
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
|
||||
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
|
||||
curl_cffi-0.15.0.dist-info/RECORD,,
|
||||
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
|
||||
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=ORhsskaIg_jwHIDYD7oRH5nzN3fKYa7xVFg-8WceJ5E,313
|
||||
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
|
||||
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
|
||||
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
|
||||
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
|
||||
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
|
||||
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/aio.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/const.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/curl.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
|
||||
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
|
||||
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
|
||||
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
|
||||
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
|
||||
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
|
||||
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
|
||||
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
|
||||
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
|
||||
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
|
||||
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
|
||||
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
|
||||
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
|
||||
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
|
||||
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
|
||||
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
|
||||
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
|
||||
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
|
||||
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
|
||||
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
|
||||
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
|
||||
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
|
||||
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
|
||||
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
|
||||
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
|
||||
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
|
||||
@@ -1 +0,0 @@
|
||||
{"archive_info": {"hash": "sha256=630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550", "hashes": {"sha256": "630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
|
||||
+54
-53
@@ -1,6 +1,6 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: curl_cffi
|
||||
Version: 0.15.0
|
||||
Version: 0.16.0
|
||||
Summary: libcurl ffi bindings for Python, with impersonation support.
|
||||
Author-email: lexiforest <infinitesheldon@gmail.com>
|
||||
License-Expression: MIT
|
||||
@@ -19,11 +19,12 @@ Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: cffi>=2.0.0
|
||||
Requires-Dist: certifi>=2024.2.2
|
||||
Requires-Dist: rich
|
||||
Provides-Extra: extra
|
||||
Requires-Dist: readability-lxml>=0.8.1; extra == "extra"
|
||||
Requires-Dist: markdownify>=1.1.0; extra == "extra"
|
||||
Requires-Dist: lxml_html_clean; extra == "extra"
|
||||
Provides-Extra: cli
|
||||
Requires-Dist: rich; extra == "cli"
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: charset_normalizer<4.0,>=3.3.2; extra == "dev"
|
||||
Requires-Dist: coverage<7.0,>=6.4.1; extra == "dev"
|
||||
@@ -90,12 +91,6 @@ Python 3.10 is the minimum supported version since v0.14.
|
||||
|
||||
If you’re looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=lexiforest-curl_cffi), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
## Residential Proxies
|
||||
|
||||
<a href="https://www.thordata.com/?ls=github&lk=curl_" target="_blank"><img src="https://raw.githubusercontent.com/lexiforest/curl_cffi/main/assets/thordata.png" alt="Thordata" height="126" width="240"></a>
|
||||
|
||||
Thordata: A reliable and cost-effective proxy service provider. One-click collection of public network data, providing enterprises and developers with stable, efficient, and compliant global proxy IP services. Register for a free trial of [residential proxies](https://www.thordata.com/?ls=github&lk=curl_) and receive 2000 free SERP API calls.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Maintenance of this project is made possible by all the <a href="https://github.com/lexiforest/curl_cffi/graphs/contributors">contributors</a> and <a href="https://github.com/sponsors/lexiforest">sponsors</a>. If you'd like to sponsor this project and have your avatar or company logo appear below <a href="https://github.com/sponsors/lexiforest">click here</a>. 💖
|
||||
@@ -124,6 +119,15 @@ No browser automation. Just simple API calls that return the exact cookies and h
|
||||
|
||||
------
|
||||
|
||||
## Impersonate Suite
|
||||
|
||||
`curl-cffi` is part of the impersonate suite.
|
||||
|
||||
- [curl-impersonate](https://github.com/lexiforest/curl-impersonate). A curl distribution that impersonates browsers.
|
||||
- [curl_cffi](https://github.com/lexiforest/curl_cffi). Python binding to curl-impersonate.
|
||||
- [impers](https://github.com/lexiforest/impers). Node.js binding to curl-impersonate.
|
||||
- [impersonate.pro](https://impersonate.pro). Commercial support, more fingerprints and integrated solutions.
|
||||
|
||||
## Features
|
||||
|
||||
- Supports JA3/TLS and http2 fingerprints impersonation, including recent browsers and custom fingerprints.
|
||||
@@ -163,25 +167,28 @@ replacement for "claws" and "agents".
|
||||
|http/2|✅|❌|✅|
|
||||
|http/3|☑️<sup>1</sup>|❌|✅|
|
||||
|human-friendly|☑️<sup>2</sup>|✅|✅|
|
||||
|colorful|❌|✅|✅|
|
||||
|colorful|❌|✅|✅<sup>3</sup>|
|
||||
|fingerprints|❌|❌|✅|
|
||||
|
||||
Notes:
|
||||
|
||||
1. You need an http/3 enabled curl build, it's not enabled by default, at leat on my machine.
|
||||
2. As a long time command line user, I personally feel very comfortable using `curl -X POST httpbin.org`, but some users may prefer `http GET httpbin.org` syntax. If you prefer the curl syntax, you can keep using `curl-impersonate`.
|
||||
3. Install `curl_cffi[cli]` for colorful CLI output. Without `rich`, the CLI uses plain text output.
|
||||
|
||||
## Install
|
||||
|
||||
pip install curl_cffi --upgrade
|
||||
|
||||
This should work on Linux, macOS and Windows out of the box.
|
||||
If it does not work on you platform, you may need to compile and install `curl-impersonate`
|
||||
first and set some environment variables like `LD_LIBRARY_PATH`.
|
||||
|
||||
Android support, including Termux, is currently in beta, you can install the beta release for testing.
|
||||
On macOS, you can also install via Homebrew:
|
||||
|
||||
brew install lexiforest/tap/curl-cffi
|
||||
|
||||
<small>Android support, including Termux, is currently in beta, you can install the beta release for testing.
|
||||
For BSD systems, we need to get libcurl-impersonate compile first, and then add support in curl_cffi.
|
||||
If you are using these OSes, please lend an hand.
|
||||
If you are using these OSes, please lend an hand.</small>
|
||||
|
||||
To install beta releases:
|
||||
|
||||
@@ -235,11 +242,6 @@ r = curl_cffi.get(
|
||||
impersonate="chrome"
|
||||
)
|
||||
|
||||
# Randomly choose a browser version based on current market share in real world
|
||||
# from: https://caniuse.com/usage-table
|
||||
# NOTE: this is a pro feature.
|
||||
r = curl_cffi.get("https://example.com", impersonate="realworld")
|
||||
|
||||
# To pin a specific version, use version numbers together.
|
||||
r = curl_cffi.get("https://tls.browserleaks.com/json", impersonate="chrome124")
|
||||
|
||||
@@ -273,45 +275,42 @@ print(r.json())
|
||||
|
||||
### Supported impersonate browsers
|
||||
|
||||
`curl_cffi` supports the same browser versions as supported by my [fork](https://github.com/lexiforest/curl-impersonate) of [curl-impersonate](https://github.com/lwthiker/curl-impersonate):
|
||||
`curl_cffi` supports the same browser versions preset as supported by our [fork](https://github.com/lexiforest/curl-impersonate) of [curl-impersonate](https://github.com/lwthiker/curl-impersonate):
|
||||
|
||||
Open source version of curl_cffi includes versions whose fingerprints differ from previous versions.
|
||||
If you see a version, e.g. `chrome135`, were skipped, you can simply impersonate it with your own headers and the previous version.
|
||||
The open source version of `curl_cffi` includes versions when we are adding new capabilities for impersonating.
|
||||
If you see a version, e.g. `chrome135`, was skipped, it's simply because there's nothing new or we were busy at that time.
|
||||
You can simply impersonate it with your own headers and the previous browser target.
|
||||
|
||||
If you don't want to look up the headers etc, by yourself, consider buying commercial support from [impersonate.pro](https://impersonate.pro),
|
||||
we have comprehensive browser fingerprints database for almost all the browser versions on various platforms.
|
||||
For a full list of preset fingerprints, see the [curl-impersonate docs](https://curl-impersonate.readthedocs.io/en/latest/fingerprints.html).
|
||||
We will no longer put duplicated and outdated info here.
|
||||
|
||||
If you are trying to impersonate a target other than a browser, use `ja3=...` and `akamai=...`
|
||||
If you don't want to look up the headers/etc by yourself, consider buying commercial support from [impersonate.pro](https://impersonate.pro).
|
||||
We have comprehensive browser tls, http and JavaScript fingerprints database for almost all the browser versions on various platforms.
|
||||
|
||||
Since v0.15.1, you can use `curl-cffi update` to retrieve the latest fingerprints, without updating to a new version.
|
||||
We offer the Safari, Chrome, Firefox updates for free and others as part of the [commercial plan](https://impersonate.pro).
|
||||
|
||||
The current number of fingerprints:
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
To see the current list of fingerprints on your device, use the command line:
|
||||
|
||||
```sh
|
||||
curl-cffi list
|
||||
```
|
||||
|
||||
To update fingerprints from impersonate.pro, use the command line:
|
||||
|
||||
```sh
|
||||
curl-cffi update
|
||||
```
|
||||
|
||||
If you are trying to impersonate a target other than a browser, use `ja3=...`, `akamai=...`, `extra_fp=...`, and `perk=...`
|
||||
to specify your own customized fingerprints. See the [docs on impersonation](https://curl-cffi.readthedocs.io/en/latest/impersonate/_index.html) for details.
|
||||
|
||||
|Browser|Open Source| Pro version|
|
||||
|---|---|---|
|
||||
|Chrome|chrome99, chrome100, chrome101, chrome104, chrome107, chrome110, chrome116<sup>[1]</sup>, chrome119<sup>[1]</sup>, chrome120<sup>[1]</sup>, chrome123<sup>[3]</sup>, chrome124<sup>[3]</sup>, chrome131<sup>[4]</sup>, chrome133a<sup>[5][6]</sup>, chrome136<sup>[6]</sup>, chrome142, chrome145<sup>[9]</sup>, chrome146<sup>[9]</sup>|chrome132, chrome134, chrome135|
|
||||
|Chrome Android| chrome99_android, chrome131_android <sup>[4]</sup>|chrome132_android, chrome133_android, chrome134_android, chrome135_android|
|
||||
|Chrome iOS|N/A|coming soon|
|
||||
|Safari <sup>[7]</sup>|safari153 <sup>[2]</sup>, safari155 <sup>[2]</sup>, safari170 <sup>[1]</sup>, safari180 <sup>[4]</sup>, safari184 <sup>[6]</sup>, safari260 <sup>[8]</sup>|coming soon|
|
||||
|Safari iOS <sup>[7]</sup>| safari172_ios<sup>[1]</sup>, safari180_ios<sup>[4]</sup>, safari184_ios <sup>[6]</sup>, safari260_ios <sup>[8]</sup>|coming soon|
|
||||
|Firefox|firefox133<sup>[5]</sup>, firefox135<sup>[7]</sup>, firefox144, firefox147<sup>[9]</sup>|coming soon|
|
||||
|Firefox Android|N/A|firefox135_android|
|
||||
|Tor|tor145 <sup>[7]</sup>|coming soon|
|
||||
|Edge|edge99, edge101|edge133, edge135|
|
||||
|Opera|N/A|coming soon|
|
||||
|Brave|N/A|coming soon|
|
||||
|
||||
|
||||
Notes:
|
||||
|
||||
1. Added in version `0.6.0`.
|
||||
2. Fixed in version `0.6.0`, previous http2 fingerprints were [not correct](https://github.com/lwthiker/curl-impersonate/issues/215).
|
||||
3. Added in version `0.7.0`.
|
||||
4. Added in version `0.8.0`.
|
||||
5. Added in version `0.9.0`.
|
||||
6. The version postfix `-a`(e.g. `chrome133a`) means that this is an alternative version, i.e. the fingerprint has not been officially updated by browser, but has been observed because of A/B testing.
|
||||
5. Added in version `0.10.0`.
|
||||
6. Added in version `0.11.0`.
|
||||
7. Since `0.11.0`, the format `safari184_ios` is preferred over `safari18_4_ios`, both are supported, but the latter is quite confusing and hard to parse.
|
||||
8. Added in `0.12.0`.
|
||||
9. http3 support included.
|
||||
|
||||
### Asyncio
|
||||
|
||||
@@ -370,11 +369,13 @@ async with AsyncSession() as session:
|
||||
print(message)
|
||||
```
|
||||
|
||||
See the WebSocket [docs](https://curl-cffi.readthedocs.io/en/latest/websockets.html) for full details and advanced options.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- Integrating with Scrapy: [divtiply/scrapy-curl-cffi](https://github.com/divtiply/scrapy-curl-cffi), [jxlil/scrapy-impersonate](https://github.com/jxlil/scrapy-impersonate) and [tieyongjie/scrapy-fingerprint](https://github.com/tieyongjie/scrapy-fingerprint).
|
||||
- Integrating with [requests](https://github.com/el1s7/curl-adapter), [httpx](https://github.com/vgavro/httpx-curl-cffi) as adapter.
|
||||
- Integrating with captcha resolvers: [YesCaptcha](https://yescaptcha.atlassian.net/wiki/spaces/YESCAPTCHA/overview). Please see the head area for promo code and link.
|
||||
- Integrating with captcha resolvers: [YesCaptcha](https://yescaptcha.com/i/stfnIO).
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
|
||||
curl_cffi-0.16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
curl_cffi-0.16.0.dist-info/METADATA,sha256=2xKv2aRl-HtOcwKKcucrZQ-F51XFo7VpRXQtYLJ0F-c,17463
|
||||
curl_cffi-0.16.0.dist-info/RECORD,,
|
||||
curl_cffi-0.16.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
curl_cffi-0.16.0.dist-info/WHEEL,sha256=mLbZYCWBc4fJzW1y2K2PE77e-mZLwFI4W61tEVJsa_c,140
|
||||
curl_cffi-0.16.0.dist-info/direct_url.json,sha256=foVsUM-Bj-Y-YxcNjF92n-aYB3GyM3Vsbf5rTFvl84g,313
|
||||
curl_cffi-0.16.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
|
||||
curl_cffi-0.16.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
|
||||
curl_cffi-0.16.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
|
||||
curl_cffi/__init__.py,sha256=6YJwgd_t8GbuHk2mqe7PWNqmkYVTjhX9gCVxzTIyYdc,1986
|
||||
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
|
||||
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/aio.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/const.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/curl.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/fingerprints.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
|
||||
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
|
||||
curl_cffi/_wrapper.abi3.so,sha256=SGLe3OukfFgEvCILm0yzeoQ2emxeRX0pgLKMjJGQ88M,13622944
|
||||
curl_cffi/aio.py,sha256=BxbiWI2ugesL6sHfKk_34A25hzTWAy4mf2G4kRKq29Q,12123
|
||||
curl_cffi/cli/__init__.py,sha256=6is7D4E46XsOYGYUnfey9maSG4DrARwEk3I4kJv1d-8,7852
|
||||
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/pro.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
|
||||
curl_cffi/cli/doctor.py,sha256=XduqJ51u99yOZ2njyAIIppTSqZJnd_Z8KnsGOXUkip8,1341
|
||||
curl_cffi/cli/output.py,sha256=IGBa2cN4jdSe7EScutghBXe9mcHcj59RsfIzzPLpx-s,7434
|
||||
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
|
||||
curl_cffi/cli/pro.py,sha256=pu2lnZUD91T0oayJMgPs6w6eLYKM6ZFlhHLE-g75UaM,3575
|
||||
curl_cffi/cli/request.py,sha256=ixFJEhHoymZFukc4dqw_AgNuR7fEtSkd3RZaHZdxOnk,3571
|
||||
curl_cffi/cli/run.py,sha256=zrxDpPfZPmeY3ADhjR2It2wj3hsOpqaxh88pe11-VMU,7616
|
||||
curl_cffi/const.py,sha256=gzF7UzT41jfTF7-GGkKAEhG0CIXmeIZtbNAMEZobuig,19101
|
||||
curl_cffi/curl.py,sha256=GmEF9TW0nYZ8PEcvc-Jsn_7vbymJlqGgmV0P5kzgMoY,30648
|
||||
curl_cffi/fingerprints.py,sha256=WB25rx-f_ZYdZiEMWF_egnJb8wACZrIncAqu1sHRJf8,20666
|
||||
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
|
||||
curl_cffi/requests/__init__.py,sha256=jt8mwSVLOmFgTUOQoxrXEj-qpst8cJbLdVQXVq-U6rw,6449
|
||||
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/cache.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/streams.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
|
||||
curl_cffi/requests/cache.py,sha256=sMZB7BA66Ain2VUZ7qWNN-3Fmd2TiPNaxzCybHgnLxY,15629
|
||||
curl_cffi/requests/cookies.py,sha256=75TLz2wTcIz7NnETCpVVZUk8lvo02y_WFzwOa7A3aZE,13525
|
||||
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
|
||||
curl_cffi/requests/exceptions.py,sha256=WM3EAhADLPf_Kh4nzZOtJplTw9Nwv-vCIyeXx7FVT9g,6225
|
||||
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
|
||||
curl_cffi/requests/impersonate.py,sha256=MIIBJxlk86nPxFyhfseyEuvY17BZTNYMafKNgGf2eXc,11188
|
||||
curl_cffi/requests/models.py,sha256=iz5k97OLrOthxAswA9abNQxiWDSQ4lWJrujUAZAMaIo,13173
|
||||
curl_cffi/requests/session.py,sha256=Mi7Wduep5maFi8p7KBsxTUfa6KB5ArLz5wWGn4j6cXE,66489
|
||||
curl_cffi/requests/streams.py,sha256=un9rtp5GL-U5A5ivyFIDcfXwxk6MMVrO2CGCbzDdpso,7090
|
||||
curl_cffi/requests/utils.py,sha256=z2Ee4gBmfUxdv_SOTQrdUtb0uUKaDAxk4kgrcVLqB3Y,37762
|
||||
curl_cffi/requests/websockets.py,sha256=Hn2Z6Jqf5SKJ-qyQyisr9Iu4vVHQYr-iT9ws_7jPHDI,72783
|
||||
curl_cffi/utils.py,sha256=xiwsOd-5W3IGNvO6yeDboOkw9wLPD2hx_SYD9i4SqVU,390
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (82.0.1)
|
||||
Generator: setuptools (83.0.0)
|
||||
Root-Is-Purelib: false
|
||||
Generator: delocate 0.13.0
|
||||
Tag: cp310-abi3-macosx_10_9_universal2
|
||||
@@ -0,0 +1 @@
|
||||
{"archive_info": {"hash": "sha256=66b0c44d84b669c513c4798475b5702c2dbd2f061f708bff0b2ee4c9c9563e02", "hashes": {"sha256": "66b0c44d84b669c513c4798475b5702c2dbd2f061f708bff0b2ee4c9c9563e02"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.16.0-cp310-abi3-macosx_10_9_universal2.whl"}
|
||||
Binary file not shown.
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
uv
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: importlib_metadata
|
||||
Version: 8.7.1
|
||||
Summary: Read metadata from Python packages
|
||||
Author-email: "Jason R. Coombs" <jaraco@jaraco.com>
|
||||
License-Expression: Apache-2.0
|
||||
Project-URL: Source, https://github.com/python/importlib_metadata
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: zipp>=3.20
|
||||
Provides-Extra: test
|
||||
Requires-Dist: pytest!=8.1.*,>=6; extra == "test"
|
||||
Requires-Dist: packaging; extra == "test"
|
||||
Requires-Dist: pyfakefs; extra == "test"
|
||||
Requires-Dist: flufl.flake8; extra == "test"
|
||||
Requires-Dist: pytest-perf>=0.9.2; extra == "test"
|
||||
Requires-Dist: jaraco.test>=5.4; extra == "test"
|
||||
Provides-Extra: doc
|
||||
Requires-Dist: sphinx>=3.5; extra == "doc"
|
||||
Requires-Dist: jaraco.packaging>=9.3; extra == "doc"
|
||||
Requires-Dist: rst.linker>=1.9; extra == "doc"
|
||||
Requires-Dist: furo; extra == "doc"
|
||||
Requires-Dist: sphinx-lint; extra == "doc"
|
||||
Requires-Dist: jaraco.tidelift>=1.4; extra == "doc"
|
||||
Provides-Extra: perf
|
||||
Requires-Dist: ipython; extra == "perf"
|
||||
Provides-Extra: check
|
||||
Requires-Dist: pytest-checkdocs>=2.4; extra == "check"
|
||||
Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check"
|
||||
Provides-Extra: cover
|
||||
Requires-Dist: pytest-cov; extra == "cover"
|
||||
Provides-Extra: enabler
|
||||
Requires-Dist: pytest-enabler>=3.4; extra == "enabler"
|
||||
Provides-Extra: type
|
||||
Requires-Dist: pytest-mypy>=1.0.1; extra == "type"
|
||||
Requires-Dist: mypy<1.19; platform_python_implementation == "PyPy" and extra == "type"
|
||||
Dynamic: license-file
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/importlib_metadata.svg
|
||||
:target: https://pypi.org/project/importlib_metadata
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg
|
||||
|
||||
.. image:: https://github.com/python/importlib_metadata/actions/workflows/main.yml/badge.svg
|
||||
:target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22
|
||||
:alt: tests
|
||||
|
||||
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
||||
:target: https://github.com/astral-sh/ruff
|
||||
:alt: Ruff
|
||||
|
||||
.. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest
|
||||
:target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://img.shields.io/badge/skeleton-2025-informational
|
||||
:target: https://blog.jaraco.com/skeleton
|
||||
|
||||
.. image:: https://tidelift.com/badges/package/pypi/importlib-metadata
|
||||
:target: https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=readme
|
||||
|
||||
Library to access the metadata for a Python package.
|
||||
|
||||
This package supplies third-party access to the functionality of
|
||||
`importlib.metadata <https://docs.python.org/3/library/importlib.metadata.html>`_
|
||||
including improvements added to subsequent Python versions.
|
||||
|
||||
|
||||
Compatibility
|
||||
=============
|
||||
|
||||
New features are introduced in this third-party library and later merged
|
||||
into CPython. The following table indicates which versions of this library
|
||||
were contributed to different versions in the standard library:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - importlib_metadata
|
||||
- stdlib
|
||||
* - 7.0
|
||||
- 3.13
|
||||
* - 6.5
|
||||
- 3.12
|
||||
* - 4.13
|
||||
- 3.11
|
||||
* - 4.6
|
||||
- 3.10
|
||||
* - 1.4
|
||||
- 3.8
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
See the `online documentation <https://importlib-metadata.readthedocs.io/>`_
|
||||
for usage details.
|
||||
|
||||
`Finder authors
|
||||
<https://docs.python.org/3/reference/import.html#finders-and-loaders>`_ can
|
||||
also add support for custom package installers. See the above documentation
|
||||
for details.
|
||||
|
||||
|
||||
Caveats
|
||||
=======
|
||||
|
||||
This project primarily supports third-party packages installed by PyPA
|
||||
tools (or other conforming packages). It does not support:
|
||||
|
||||
- Packages in the stdlib.
|
||||
- Packages installed without metadata.
|
||||
|
||||
Project details
|
||||
===============
|
||||
|
||||
* Project home: https://github.com/python/importlib_metadata
|
||||
* Report bugs at: https://github.com/python/importlib_metadata/issues
|
||||
* Code hosting: https://github.com/python/importlib_metadata
|
||||
* Documentation: https://importlib-metadata.readthedocs.io/
|
||||
|
||||
For Enterprise
|
||||
==============
|
||||
|
||||
Available as part of the Tidelift Subscription.
|
||||
|
||||
This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use.
|
||||
|
||||
`Learn more <https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=referral&utm_campaign=github>`_.
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
importlib_metadata-8.7.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
||||
importlib_metadata-8.7.1.dist-info/METADATA,sha256=o-OLnuQyYonUhkcE8w4pnudp4jCc6fSnXw3hpQrQo1Y,4670
|
||||
importlib_metadata-8.7.1.dist-info/RECORD,,
|
||||
importlib_metadata-8.7.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
importlib_metadata-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
||||
importlib_metadata-8.7.1.dist-info/licenses/LICENSE,sha256=RYUC4S2Xu_ZEOGBqIARKqF6wX7CoqAe7NdvsJT_R_AQ,10278
|
||||
importlib_metadata-8.7.1.dist-info/top_level.txt,sha256=CO3fD9yylANiXkrMo4qHLV_mqXL2sC5JFKgt1yWAT-A,19
|
||||
importlib_metadata/__init__.py,sha256=u7Ew4-UkpzNY-ka6k-WRkDhQZS1akkLMfWs2eEnUmGo,37734
|
||||
importlib_metadata/_adapters.py,sha256=r5i8XLrKT6xmrpoREZhZrfczOYDmrVZeJBW5u0HzIGU,3797
|
||||
importlib_metadata/_collections.py,sha256=CxAhzlF3g1rwu_fMiB53JtRQiUFh0RgiMpoOvmK_ocg,760
|
||||
importlib_metadata/_compat.py,sha256=VC5ZDLlT-BcshauCShdFJvMNLntJJfZzNK1meGa-enw,1313
|
||||
importlib_metadata/_functools.py,sha256=0pA2OoiVK6wnsGq8HvVIzgdkvLiZ0nfnfw7IsndjoHk,3510
|
||||
importlib_metadata/_itertools.py,sha256=nMvp9SfHAQ_JYwK4L2i64lr3GRXGlYlikGTVzWbys_E,5351
|
||||
importlib_metadata/_meta.py,sha256=EtHyiJ5kGzWFDfKyQ2XQp6Vu113CeadKW1Vf6aGc1B4,1765
|
||||
importlib_metadata/_text.py,sha256=HCsFksZpJLeTP3NEk_ngrAeXVRRtTrtyh9eOABoRP4A,2166
|
||||
importlib_metadata/_typing.py,sha256=EQKhhsEgz_Sa-FnePI-faC72rNOOQwopjA1i5pG8FDU,367
|
||||
importlib_metadata/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
importlib_metadata/compat/py311.py,sha256=uqm-K-uohyj1042TH4a9Er_I5o7667DvulcD-gC_fSA,608
|
||||
importlib_metadata/compat/py39.py,sha256=J3W7PUVRPNYMmcvT12RF8ndBU9e8_T0Ac4U87Bsrq70,1187
|
||||
importlib_metadata/diagnose.py,sha256=nkSRMiowlmkhLYhKhvCg9glmt_11Cox-EmLzEbqYTa8,379
|
||||
importlib_metadata/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (80.9.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2025 [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-1
@@ -1 +0,0 @@
|
||||
importlib_metadata
|
||||
@@ -1,2 +0,0 @@
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula eu tempor congue, eros est euismod turpis, id tincidunt sapien risus a quam. Maecenas fermentum consequat mi. Donec fermentum. Pellentesque malesuada nulla a mi. Duis sapien sem, aliquet nec, commodo eget, consequat quis, neque. Aliquam faucibus, elit ut dictum aliquet, felis nisl adipiscing sapien, sed malesuada diam lacus eget erat. Cras mollis scelerisque nunc. Nullam arcu. Aliquam consequat. Curabitur augue lorem, dapibus quis, laoreet et, pretium ac, nisi. Aenean magna nisl, mollis quis, molestie eu, feugiat in, orci. In hac habitasse platea dictumst.
|
||||
@@ -1,110 +0,0 @@
|
||||
../../../bin/websockets,sha256=Aec6LJ-SgqOC29VvGIAfJo3DbDp5-znjkFZaF7aEUTM,188
|
||||
websockets-16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
websockets-16.0.dist-info/METADATA,sha256=JcDvWo8DVSw5uoDAFbk9N8fJXuRJvnrcLXVBFyBjwN8,6799
|
||||
websockets-16.0.dist-info/RECORD,,
|
||||
websockets-16.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets-16.0.dist-info/WHEEL,sha256=MzHA8aA11K4m-mjpjTobbWUqTlhv5RdoytLBFWQb_k4,142
|
||||
websockets-16.0.dist-info/direct_url.json,sha256=CKQvi9piWBTuZxzuK4J8f71FvL_6alBevkUUKBa4QCg,310
|
||||
websockets-16.0.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
|
||||
websockets-16.0.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
|
||||
websockets-16.0.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
|
||||
websockets/__init__.py,sha256=AC2Hq92uSc_WOo9_xvITpGshJ7Dy0Md5m2_ywsdSt_Y,7058
|
||||
websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
||||
websockets/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/__pycache__/__main__.cpython-314.pyc,,
|
||||
websockets/__pycache__/auth.cpython-314.pyc,,
|
||||
websockets/__pycache__/cli.cpython-314.pyc,,
|
||||
websockets/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/__pycache__/datastructures.cpython-314.pyc,,
|
||||
websockets/__pycache__/exceptions.cpython-314.pyc,,
|
||||
websockets/__pycache__/frames.cpython-314.pyc,,
|
||||
websockets/__pycache__/headers.cpython-314.pyc,,
|
||||
websockets/__pycache__/http.cpython-314.pyc,,
|
||||
websockets/__pycache__/http11.cpython-314.pyc,,
|
||||
websockets/__pycache__/imports.cpython-314.pyc,,
|
||||
websockets/__pycache__/protocol.cpython-314.pyc,,
|
||||
websockets/__pycache__/proxy.cpython-314.pyc,,
|
||||
websockets/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/__pycache__/streams.cpython-314.pyc,,
|
||||
websockets/__pycache__/typing.cpython-314.pyc,,
|
||||
websockets/__pycache__/uri.cpython-314.pyc,,
|
||||
websockets/__pycache__/utils.cpython-314.pyc,,
|
||||
websockets/__pycache__/version.cpython-314.pyc,,
|
||||
websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/asyncio/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/async_timeout.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/compatibility.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/messages.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/router.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/asyncio/async_timeout.py,sha256=N-6Mubyiaoh66PAXGvCzhgxCM-7V2XiRnH32Xi6J6TE,8971
|
||||
websockets/asyncio/client.py,sha256=e4xlgtzb3v29M2vN-UDiyoUtThg--d5GqKg3lt2pDdE,30850
|
||||
websockets/asyncio/compatibility.py,sha256=gkenDDhzNbm6_iXV5Edvbvp6uHZYdrTvGNjt8P_JtyQ,786
|
||||
websockets/asyncio/connection.py,sha256=87RdVURijJk8V-ShWAWfTEyhW5Z1YUXKV8ezUzxt5L0,49099
|
||||
websockets/asyncio/messages.py,sha256=u2M5WKY9xPyw8G3nKoXfdO5K41hrTnf4MdizVHzgdM4,11129
|
||||
websockets/asyncio/router.py,sha256=S-69vszK-SqUCcZbXXPOnux-eH2fTHYC2JNh7tOtmmA,7520
|
||||
websockets/asyncio/server.py,sha256=wQ9oBc0WBOIzbXKDYJ8UhXRTeoXrSfLu6CWCrUl-vck,37941
|
||||
websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
|
||||
websockets/cli.py,sha256=YnegH59z93JxSVIGiXiWhR3ktgI6k1_pf_BRLanxKrQ,5336
|
||||
websockets/client.py,sha256=fljI5k5oQ-Sfm53MCoyTlr2jFtOOIuO13H9bbtpBPes,13789
|
||||
websockets/connection.py,sha256=OLiMVkNd25_86sB8Q7CrCwBoXy9nA0OCgdgLRA8WUR8,323
|
||||
websockets/datastructures.py,sha256=Uq2CpjmXak9_pPWcOqh36rzJMo8eCi2lVPTFWDvK5sA,5518
|
||||
websockets/exceptions.py,sha256=bgaMdqQGGZosAEULeCB30XW2YnwomWa3c8YOrEfeOoY,12859
|
||||
websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
|
||||
websockets/extensions/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/extensions/__pycache__/base.cpython-314.pyc,,
|
||||
websockets/extensions/__pycache__/permessage_deflate.cpython-314.pyc,,
|
||||
websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
|
||||
websockets/extensions/permessage_deflate.py,sha256=AkuhkAKFo5lqJQMXnckbSs9b2KBBrOFsE1DHIcbLL3k,25770
|
||||
websockets/frames.py,sha256=5IK4GZpl8ukr0bZ_UA_jjjztK09yYQAl9m5NVmGLiK0,12889
|
||||
websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
|
||||
websockets/http.py,sha256=T1tNLmbkFCneXQ6qepBmsVVDXyP9i500IVzTJTeBMR4,659
|
||||
websockets/http11.py,sha256=T8ai5BcBGkV0n9It63oDeNpmtQMyg8Cpav5rf_yT0r4,15619
|
||||
websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
|
||||
websockets/legacy/__init__.py,sha256=wQ5zRIENGUS_5eKNAX9CRE7x1TwKapKimrQFFWN9Sxs,276
|
||||
websockets/legacy/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/auth.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/exceptions.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/framing.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/handshake.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/http.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/protocol.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/legacy/auth.py,sha256=DcQcCSeVeP93JcH8vFWE0HIJL-X-f23LZ0DsJpav1So,6531
|
||||
websockets/legacy/client.py,sha256=fV2mbiU9rciXhJfAEKVSm0GztJDUbDpRQ-K5EMbkuQ0,26815
|
||||
websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
|
||||
websockets/legacy/framing.py,sha256=r9P1wiXv_1XuAVQw8SOPkuE9d4eZ0r_JowAkz9-WV4w,6366
|
||||
websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
|
||||
websockets/legacy/http.py,sha256=cOCQmDWhIKQmm8UWGXPW7CDZg03wjogCsb0LP9oetNQ,7061
|
||||
websockets/legacy/protocol.py,sha256=ajtVXDb-lEm9BN0NF3iEaTI_b1q5fBCKTB9wvUoGOxY,63632
|
||||
websockets/legacy/server.py,sha256=7mwY-yD0ljNF93oPYumTWD7OIVbCWtaEOw1FFJBhIAM,45251
|
||||
websockets/protocol.py,sha256=vTqjPIg2HmO-bSxsczuEmWMxPTxPXU1hmVUjqnahV44,27247
|
||||
websockets/proxy.py,sha256=oFrbEYtasYWv-WDcniObD9nBR5Q5qkHpyCVLngx7WMQ,4969
|
||||
websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/server.py,sha256=E4SWBA8WZRmAOpsUm-oCqacBGZre9e0iDmDIrfpV21Q,21790
|
||||
websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
|
||||
websockets/speedups.cpython-314-darwin.so,sha256=G3h5TQnbLQ1QpRmDTLznIO5RkWVjFwHMJFy_2-5zXwM,84144
|
||||
websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
|
||||
websockets/streams.py,sha256=pXqga7ttjuF6lChWYiWLSfUlt3FCaQpEX1ae_jvcCeQ,4071
|
||||
websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/sync/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/messages.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/router.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/utils.cpython-314.pyc,,
|
||||
websockets/sync/client.py,sha256=_2Erytw1f3f9O_u2jLtS1oNV4HsHUi_h3lGvT9ZEaDQ,22108
|
||||
websockets/sync/connection.py,sha256=1pJYEMRHLWIN7538vJcIeFVnvSXVrD0n1xrfX7wDNSc,41868
|
||||
websockets/sync/messages.py,sha256=yZV1zhY07ZD0vRF5b1yDa7ug0rbA5UDOCCCQmWwAcds,12858
|
||||
websockets/sync/router.py,sha256=BqKSAKNZYtRWiOxol9qYeyfgyXRrMNJ6FrTTZLNcXMg,7172
|
||||
websockets/sync/server.py,sha256=s07HNK_2s1kLN62Uqc77uvND0z7C0YTXGePsCiBtXaE,27655
|
||||
websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
|
||||
websockets/typing.py,sha256=A6xh4m65pRzKAbuOs0kFuGhL4DWIIko-ppS4wvJVc0Q,1946
|
||||
websockets/uri.py,sha256=2fFMw-AbKJ5HVHNCuw1Rx1MnkCkNWRpogxWhhM30EU4,3125
|
||||
websockets/utils.py,sha256=AwhS4UmlbKv7meAaR7WNbUqD5JFoStOP1bAyo9sRMus,1197
|
||||
websockets/version.py,sha256=IhaztWxysdY-pd-0nOubnnPduvySSvdoBwrQdJKtZ2g,3202
|
||||
@@ -1 +0,0 @@
|
||||
{"archive_info": {"hash": "sha256=8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", "hashes": {"sha256": "8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/wheels/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl"}
|
||||
+8
-17
@@ -1,6 +1,6 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: websockets
|
||||
Version: 16.0
|
||||
Version: 17.0.1
|
||||
Summary: An implementation of the WebSocket Protocol (RFC 6455 & 7692)
|
||||
Author-email: Aymeric Augustin <aymeric.augustin@m4x.org>
|
||||
License-Expression: BSD-3-Clause
|
||||
@@ -16,12 +16,12 @@ Classifier: Intended Audience :: Developers
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: 3.13
|
||||
Classifier: Programming Language :: Python :: 3.14
|
||||
Requires-Python: >=3.10
|
||||
Classifier: Programming Language :: Python :: 3.15
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Dynamic: description
|
||||
@@ -32,7 +32,7 @@ Dynamic: license-file
|
||||
:width: 480px
|
||||
:alt: websockets
|
||||
|
||||
|licence| |version| |pyversions| |tests| |docs| |openssf|
|
||||
|licence| |version| |pyversions|
|
||||
|
||||
.. |licence| image:: https://img.shields.io/pypi/l/websockets.svg
|
||||
:target: https://pypi.python.org/pypi/websockets
|
||||
@@ -43,15 +43,6 @@ Dynamic: license-file
|
||||
.. |pyversions| image:: https://img.shields.io/pypi/pyversions/websockets.svg
|
||||
:target: https://pypi.python.org/pypi/websockets
|
||||
|
||||
.. |tests| image:: https://img.shields.io/github/checks-status/python-websockets/websockets/main?label=tests
|
||||
:target: https://github.com/python-websockets/websockets/actions/workflows/tests.yml
|
||||
|
||||
.. |docs| image:: https://img.shields.io/readthedocs/websockets.svg
|
||||
:target: https://websockets.readthedocs.io/
|
||||
|
||||
.. |openssf| image:: https://bestpractices.coreinfrastructure.org/projects/6475/badge
|
||||
:target: https://bestpractices.coreinfrastructure.org/projects/6475
|
||||
|
||||
What is ``websockets``?
|
||||
-----------------------
|
||||
|
||||
@@ -63,8 +54,8 @@ with a focus on correctness, simplicity, robustness, and performance.
|
||||
Built on top of ``asyncio``, Python's standard asynchronous I/O framework, the
|
||||
default implementation provides an elegant coroutine-based API.
|
||||
|
||||
An implementation on top of ``threading`` and a Sans-I/O implementation are also
|
||||
available.
|
||||
Implementations on top of ``threading`` and ``trio`` are also provided, as well
|
||||
as a Sans-I/O layer for integration in third-party projects.
|
||||
|
||||
`Documentation is available on Read the Docs. <https://websockets.readthedocs.io/>`_
|
||||
|
||||
@@ -84,8 +75,8 @@ Here's an echo server with the ``asyncio`` API:
|
||||
await websocket.send(message)
|
||||
|
||||
async def main():
|
||||
async with serve(echo, "localhost", 8765) as server:
|
||||
await server.serve_forever()
|
||||
server = await serve(echo, "localhost", 8765)
|
||||
await server.serve_forever()
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
../../../bin/websockets,sha256=Aec6LJ-SgqOC29VvGIAfJo3DbDp5-znjkFZaF7aEUTM,188
|
||||
websockets-17.0.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
websockets-17.0.1.dist-info/METADATA,sha256=Z_btpoffz0ubiHVmim0rYjFMxNdOn_LhaVusK8CZCIA,6342
|
||||
websockets-17.0.1.dist-info/RECORD,,
|
||||
websockets-17.0.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets-17.0.1.dist-info/WHEEL,sha256=dgBSXaLX3dDB-DVpHT16BsaK8hj0ZwMOLjJBjWhNK_4,142
|
||||
websockets-17.0.1.dist-info/direct_url.json,sha256=tKRa4U_R4U5WX4GMQa3dJ7UPcGjl5stIKOYKaN-F0pw,312
|
||||
websockets-17.0.1.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
|
||||
websockets-17.0.1.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
|
||||
websockets-17.0.1.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
|
||||
websockets/__init__.py,sha256=zmBCJj7EIa6ZJEQDp4dIdznfzAJUA8dW41-j7Em-QDQ,7450
|
||||
websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
||||
websockets/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/__pycache__/__main__.cpython-314.pyc,,
|
||||
websockets/__pycache__/auth.cpython-314.pyc,,
|
||||
websockets/__pycache__/cli.cpython-314.pyc,,
|
||||
websockets/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/__pycache__/datastructures.cpython-314.pyc,,
|
||||
websockets/__pycache__/exceptions.cpython-314.pyc,,
|
||||
websockets/__pycache__/frames.cpython-314.pyc,,
|
||||
websockets/__pycache__/headers.cpython-314.pyc,,
|
||||
websockets/__pycache__/http11.cpython-314.pyc,,
|
||||
websockets/__pycache__/imports.cpython-314.pyc,,
|
||||
websockets/__pycache__/protocol.cpython-314.pyc,,
|
||||
websockets/__pycache__/proxy.cpython-314.pyc,,
|
||||
websockets/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/__pycache__/streams.cpython-314.pyc,,
|
||||
websockets/__pycache__/typing.cpython-314.pyc,,
|
||||
websockets/__pycache__/uri.cpython-314.pyc,,
|
||||
websockets/__pycache__/utils.cpython-314.pyc,,
|
||||
websockets/__pycache__/version.cpython-314.pyc,,
|
||||
websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/asyncio/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/messages.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/router.cpython-314.pyc,,
|
||||
websockets/asyncio/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/asyncio/client.py,sha256=oAvlgEjRXixmIrttbQZ4AgiA8oetA8EjxmM_36bqoVE,32062
|
||||
websockets/asyncio/connection.py,sha256=V3L-PrXG8jZ_6IytI-9ftA-JOlmKXlnI9oCS1gWxsIM,49596
|
||||
websockets/asyncio/messages.py,sha256=vBPd93IuL3LE0OSxeaICIjzvyoht53kmh-_Zho-bGzU,11096
|
||||
websockets/asyncio/router.py,sha256=gUdYn2dc--kT-kNEFOa_qwo_d5vaX34WyoRNuPOSVrU,7743
|
||||
websockets/asyncio/server.py,sha256=6Xop-Re8SqhKsBUEtrpc89etFuQiWPPyc9gz7Acrhmo,34111
|
||||
websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
|
||||
websockets/cli.py,sha256=fYf0YqrWIx_iJDA-RWdczh5vjO9p6ZxZvmE761bUQg0,6892
|
||||
websockets/client.py,sha256=Dp4j89uLnzXsItZasDs8hV045YfRpX6fYJVuhmuEDA0,14050
|
||||
websockets/connection.py,sha256=OLiMVkNd25_86sB8Q7CrCwBoXy9nA0OCgdgLRA8WUR8,323
|
||||
websockets/datastructures.py,sha256=XGTvvgFdZSevNV_T6-Lo-Dxm2VHxJcgKl4l1DNQ6fU8,7238
|
||||
websockets/exceptions.py,sha256=tp0_EyqQfd7CYNOHmxAieOH8Ed9PSQFt18e-CuReTOY,14308
|
||||
websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
|
||||
websockets/extensions/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/extensions/__pycache__/base.cpython-314.pyc,,
|
||||
websockets/extensions/__pycache__/permessage_deflate.cpython-314.pyc,,
|
||||
websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
|
||||
websockets/extensions/permessage_deflate.py,sha256=IAXdCGY60zfdjatKd6fDfTMizOeQYsnh0zBtL9vPuzA,25840
|
||||
websockets/frames.py,sha256=Twiqn_buvQiblERugcT-MDmnPZ7kt-FMXESe2DhOT9o,16709
|
||||
websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
|
||||
websockets/http11.py,sha256=tLVuNvNADwt8d1ui5KMFKZCZMTtJ6CqXZveogLaPbf0,18245
|
||||
websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
|
||||
websockets/legacy/__init__.py,sha256=wQ5zRIENGUS_5eKNAX9CRE7x1TwKapKimrQFFWN9Sxs,276
|
||||
websockets/legacy/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/auth.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/exceptions.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/framing.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/handshake.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/http.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/protocol.cpython-314.pyc,,
|
||||
websockets/legacy/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/legacy/auth.py,sha256=DcQcCSeVeP93JcH8vFWE0HIJL-X-f23LZ0DsJpav1So,6531
|
||||
websockets/legacy/client.py,sha256=5oJurUczPddXi8Rde5fA2hD3V4pnv__Z64pRRXP6HBs,27226
|
||||
websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
|
||||
websockets/legacy/framing.py,sha256=b0wMx83XTIXVZoO60pl3AibKHbQlDb7rBAyhFEkaMCk,6374
|
||||
websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
|
||||
websockets/legacy/http.py,sha256=cOCQmDWhIKQmm8UWGXPW7CDZg03wjogCsb0LP9oetNQ,7061
|
||||
websockets/legacy/protocol.py,sha256=1WUIzDHBew-NZ1ewhmct_WT0ycdTxT4HDdg-Im71z4Y,63456
|
||||
websockets/legacy/server.py,sha256=hSk4nFTsI3eMRbu5JcI4gXei9Ubw07RSrSjtL4eM2gc,44835
|
||||
websockets/protocol.py,sha256=3HuCBMLTJMxp9JHd5JMBqtTF84SsY29iuHH0yznuD_Q,27204
|
||||
websockets/proxy.py,sha256=oFrbEYtasYWv-WDcniObD9nBR5Q5qkHpyCVLngx7WMQ,4969
|
||||
websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/server.py,sha256=7kEJL1QEFO8pTB90wP4fUbkzYGDcbkTa7jTvdiSZtQk,24679
|
||||
websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
|
||||
websockets/speedups.cpython-314-darwin.so,sha256=pJidRkiyZA-9quLsUEgjRjs8b8bpobn1FV25pvq38KM,84144
|
||||
websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
|
||||
websockets/streams.py,sha256=rJrDNSL9hy3EJYc1PFHjYx4rdd1vELCua0CwdFiHN_8,4610
|
||||
websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/sync/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/messages.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/router.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/sync/__pycache__/utils.cpython-314.pyc,,
|
||||
websockets/sync/client.py,sha256=j9fvsOwCzJgQnaAyOaVYw6iT_IdPEM70JFLjeqXz2jw,22216
|
||||
websockets/sync/connection.py,sha256=xJeJZZZnbILJUhcNg_lBWL26Imv-t2jsnnO5y_ecHf4,48298
|
||||
websockets/sync/messages.py,sha256=q8K1vxdrqVk1bvn3zW1Jx09TlakIWSyYcOHC7ueFQmk,12826
|
||||
websockets/sync/router.py,sha256=BqKSAKNZYtRWiOxol9qYeyfgyXRrMNJ6FrTTZLNcXMg,7172
|
||||
websockets/sync/server.py,sha256=fVZZiaWW8ISoqFcJY5ZNa-MBxtLCYCP4ALJAtncgF3A,34346
|
||||
websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
|
||||
websockets/trio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
websockets/trio/__pycache__/__init__.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/client.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/connection.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/messages.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/router.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/server.cpython-314.pyc,,
|
||||
websockets/trio/__pycache__/utils.cpython-314.pyc,,
|
||||
websockets/trio/client.py,sha256=VwpJ9bYECIPMrssX7OQ2rriNVjB3LitDqXSHEfEJwQI,29509
|
||||
websockets/trio/connection.py,sha256=lalwJYhFNZqYDP7e69oo9KXebFNsbyM8wlmoqsMcf0s,49218
|
||||
websockets/trio/messages.py,sha256=goDc5rfINExnvyfETWYYDT3-thgRJd7DPr4ujwJD3P8,10269
|
||||
websockets/trio/router.py,sha256=qZL9-44mi9SOOTGU3CLBPuaCNzafQGqZ-fJl9hVpv-s,7196
|
||||
websockets/trio/server.py,sha256=jRtrfZf3hHp1aQo6ffNMN7siaWzvZVhqC1iq1_Pj0sA,25805
|
||||
websockets/trio/utils.py,sha256=A12NYUPOjQaiGusak43oWzPIcCxhikKTkAJLXtZJvcY,956
|
||||
websockets/typing.py,sha256=zL4Pi_pQHJGVlL4cTyFauqxgMBAFI5B6dfvMVn7SFYs,1710
|
||||
websockets/uri.py,sha256=2fFMw-AbKJ5HVHNCuw1Rx1MnkCkNWRpogxWhhM30EU4,3125
|
||||
websockets/utils.py,sha256=Wdtsh2qQiHLrXUBEnp3LatMLJTZjB2CErbeOFvrdXvE,2022
|
||||
websockets/version.py,sha256=TJPgFOcL4xsH8I2KiQu1V3j8kSHbOd_EWKNVnnOu8SE,3204
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (80.9.0)
|
||||
Generator: setuptools (83.0.0)
|
||||
Root-Is-Purelib: false
|
||||
Tag: cp314-cp314-macosx_10_15_universal2
|
||||
Generator: delocate 0.13.0
|
||||
@@ -0,0 +1 @@
|
||||
{"archive_info": {"hash": "sha256=53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", "hashes": {"sha256": "53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/wheels/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl"}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1771
-70
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub fn resolve_bundled_binary_path(
|
||||
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 +20,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 +49,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 +119,7 @@ fn executable_relative_candidates(
|
||||
candidates
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -141,7 +163,7 @@ 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};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
@@ -171,4 +193,19 @@ mod tests {
|
||||
Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe")
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,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 = "5";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
@@ -75,6 +75,8 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
#[serde(default)]
|
||||
torrent: bool,
|
||||
#[serde(default)]
|
||||
batch: bool,
|
||||
#[serde(default)]
|
||||
batch_name: Option<String>,
|
||||
@@ -100,6 +102,7 @@ pub struct ExtensionDownload {
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
torrent: bool,
|
||||
batch: bool,
|
||||
batch_name: Option<String>,
|
||||
}
|
||||
@@ -305,18 +308,16 @@ async fn download_handler(
|
||||
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 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 {
|
||||
@@ -428,6 +429,20 @@ 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
|
||||
|| 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,6 +497,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
torrent,
|
||||
batch,
|
||||
batch_name,
|
||||
})
|
||||
@@ -548,18 +564,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 +574,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> {
|
||||
@@ -752,7 +766,7 @@ mod tests {
|
||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||
assert_eq!(
|
||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||
"4"
|
||||
"5"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
@@ -816,6 +830,7 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
});
|
||||
@@ -836,6 +851,7 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
});
|
||||
@@ -891,12 +907,13 @@ 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,
|
||||
})
|
||||
@@ -904,7 +921,10 @@ mod tests {
|
||||
|
||||
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,6 +938,7 @@ mod tests {
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -930,6 +951,94 @@ 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()),
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.expect("legacy magnet handoff");
|
||||
assert!(legacy_magnet.torrent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -954,6 +1063,7 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -984,6 +1094,7 @@ mod tests {
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -1007,6 +1118,7 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||
})
|
||||
@@ -1030,6 +1142,7 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery".to_string()),
|
||||
})
|
||||
|
||||
+565
-3
@@ -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)]
|
||||
@@ -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,69 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+10247
-306
File diff suppressed because it is too large
Load Diff
+27
-2
@@ -6,7 +6,8 @@ use ts_rs::TS;
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
match native_system_proxy() {
|
||||
Ok(Some(proxy)) => Ok(Some(proxy)),
|
||||
Ok(None) => Ok(proxy_from_environment()),
|
||||
@@ -485,7 +486,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 +542,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 +611,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();
|
||||
|
||||
@@ -684,3 +691,21 @@ pub fn is_supported_media(url: String) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::get_file_category;
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+431
-9
@@ -1,6 +1,255 @@
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 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_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,
|
||||
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!(
|
||||
"{}:{}:{}",
|
||||
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 +357,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 +490,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 +524,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,786 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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 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> {
|
||||
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());
|
||||
}
|
||||
|
||||
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)
|
||||
.transparent(true);
|
||||
#[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 {
|
||||
// Two rapid main-window requests can race between the native lookup
|
||||
// above and builder creation. If the first request won, retain the
|
||||
// registry entry and focus its window instead of treating the second
|
||||
// request as a failed open.
|
||||
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 fn close_download_properties_window(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
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 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());
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
#[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(""));
|
||||
}
|
||||
}
|
||||
+9462
-251
File diff suppressed because it is too large
Load Diff
+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]
|
||||
|
||||
+109
-5
@@ -16,13 +16,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 +34,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 +48,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 +58,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 +90,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 +103,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 +147,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 +180,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 +208,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,
|
||||
@@ -195,6 +264,7 @@ mod tests {
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
@@ -204,12 +274,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 +320,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 +332,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 +344,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",
|
||||
|
||||
+782
-4
@@ -7,6 +7,118 @@ use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TorrentStartupSettings {
|
||||
pub listen_port: String,
|
||||
pub dht_listen_port: String,
|
||||
pub external_ip: String,
|
||||
pub dht_entry_point: String,
|
||||
pub dht_entry_point6: String,
|
||||
pub dht_listen_addr6: String,
|
||||
pub lpd_interface: String,
|
||||
pub peer_id_prefix: String,
|
||||
pub peer_agent: String,
|
||||
pub dht_message_timeout: u32,
|
||||
pub ipv6_enabled: bool,
|
||||
pub bind_address: String,
|
||||
pub disk_cache: String,
|
||||
}
|
||||
|
||||
fn normalize_torrent_startup_value(
|
||||
field: &str,
|
||||
value: &str,
|
||||
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
|
||||
) -> String {
|
||||
match normalize(Some(value)) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => String::new(),
|
||||
Err(error) => {
|
||||
log::error!("invalid persisted {field}; using Aria2 default: {error}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> TorrentStartupSettings {
|
||||
let Some(settings) = settings else {
|
||||
return TorrentStartupSettings::default();
|
||||
};
|
||||
let bind_address = normalize_torrent_startup_value(
|
||||
"Torrent bind address",
|
||||
&settings.torrent_bind_address,
|
||||
crate::queue::normalize_torrent_bind_address,
|
||||
);
|
||||
let bind_address = if !settings.torrent_ipv6_enabled
|
||||
&& bind_address
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_ipv6())
|
||||
{
|
||||
log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled");
|
||||
String::new()
|
||||
} else {
|
||||
bind_address
|
||||
};
|
||||
|
||||
TorrentStartupSettings {
|
||||
listen_port: normalize_torrent_startup_value(
|
||||
"TCP listen ports",
|
||||
&settings.torrent_listen_port,
|
||||
|value| crate::queue::normalize_torrent_port_spec(value, "TCP listen ports"),
|
||||
),
|
||||
dht_listen_port: normalize_torrent_startup_value(
|
||||
"UDP listen ports",
|
||||
&settings.torrent_dht_listen_port,
|
||||
|value| crate::queue::normalize_torrent_port_spec(value, "UDP listen ports"),
|
||||
),
|
||||
external_ip: normalize_torrent_startup_value(
|
||||
"Torrent external IP",
|
||||
&settings.torrent_external_ip,
|
||||
crate::queue::normalize_torrent_external_ip,
|
||||
),
|
||||
dht_entry_point: normalize_torrent_startup_value(
|
||||
"IPv4 DHT entry point",
|
||||
&settings.torrent_dht_entry_point,
|
||||
|value| crate::queue::normalize_torrent_dht_entry_point(value, false),
|
||||
),
|
||||
dht_entry_point6: normalize_torrent_startup_value(
|
||||
"IPv6 DHT entry point",
|
||||
&settings.torrent_dht_entry_point6,
|
||||
|value| crate::queue::normalize_torrent_dht_entry_point(value, true),
|
||||
),
|
||||
dht_listen_addr6: normalize_torrent_startup_value(
|
||||
"IPv6 DHT listen address",
|
||||
&settings.torrent_dht_listen_addr6,
|
||||
crate::queue::normalize_torrent_dht_listen_addr6,
|
||||
),
|
||||
lpd_interface: normalize_torrent_startup_value(
|
||||
"Torrent LPD interface",
|
||||
&settings.torrent_lpd_interface,
|
||||
crate::queue::normalize_torrent_lpd_interface,
|
||||
),
|
||||
peer_id_prefix: normalize_torrent_startup_value(
|
||||
"Torrent peer ID prefix",
|
||||
&settings.torrent_peer_id_prefix,
|
||||
crate::queue::normalize_torrent_peer_id_prefix,
|
||||
),
|
||||
peer_agent: normalize_torrent_startup_value(
|
||||
"Torrent peer agent",
|
||||
&settings.torrent_peer_agent,
|
||||
crate::queue::normalize_torrent_peer_agent,
|
||||
),
|
||||
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
|
||||
ipv6_enabled: settings.torrent_ipv6_enabled,
|
||||
bind_address,
|
||||
disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
||||
.unwrap_or_else(|error| {
|
||||
log::error!("invalid persisted Aria2 disk cache; using default: {error}");
|
||||
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_settings<R: tauri::Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<PersistedSettings, String> {
|
||||
@@ -32,6 +144,139 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn canonicalize_torrent_network_value(
|
||||
state: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
|
||||
) {
|
||||
let Some(value) = state.get(key).and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let normalized = normalize(Some(value)).ok().flatten().unwrap_or_default();
|
||||
state.insert(key.to_string(), Value::String(normalized));
|
||||
}
|
||||
|
||||
pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, String> {
|
||||
let mut document = decode_document(&Value::String(stored.to_string()))?;
|
||||
let state = settings_state_mut(&mut document)?;
|
||||
canonicalize_torrent_network_value(state, "torrentListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(value, "TCP listen ports")
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(value, "UDP listen ports")
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentExternalIp", crate::queue::normalize_torrent_external_ip);
|
||||
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(value, false)
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint6", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(value, true)
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtListenAddr6", crate::queue::normalize_torrent_dht_listen_addr6);
|
||||
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
|
||||
canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address);
|
||||
let disk_cache = state
|
||||
.get("aria2DiskCache")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok())
|
||||
.unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
||||
state.insert("aria2DiskCache".to_string(), Value::String(disk_cache));
|
||||
let dht_message_timeout = state
|
||||
.get("torrentDhtMessageTimeout")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
state.insert(
|
||||
"torrentDhtMessageTimeout".to_string(),
|
||||
Value::Number(serde_json::Number::from(dht_message_timeout)),
|
||||
);
|
||||
let max_concurrent_seeds = state
|
||||
.get("torrentMaxConcurrentSeeds")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
state.insert(
|
||||
"torrentMaxConcurrentSeeds".to_string(),
|
||||
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
|
||||
);
|
||||
if !state
|
||||
.get("torrentSeparateSeedSlots")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
|
||||
}
|
||||
if !state
|
||||
.get("torrentIpv6Enabled")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true));
|
||||
}
|
||||
if state
|
||||
.get("torrentIpv6Enabled")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false)
|
||||
&& state
|
||||
.get("torrentBindAddress")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.is_some_and(|address| address.is_ipv6())
|
||||
{
|
||||
return Err(
|
||||
"IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(),
|
||||
);
|
||||
}
|
||||
// Renderer snapshots are also a persistence boundary. Remove malformed
|
||||
// scalar values before the document is written so a recoverable default
|
||||
// is not hidden behind a hostile value that will fail on the next save or
|
||||
// restart. Keep the network canonicalization above first so invalid text
|
||||
// fields retain their established empty-string representation.
|
||||
let state_value = if document.get("state").is_some() {
|
||||
document
|
||||
.get_mut("state")
|
||||
.ok_or_else(|| "persisted settings state is missing".to_string())?
|
||||
} else {
|
||||
&mut document
|
||||
};
|
||||
sanitize_persisted_setting_values(state_value);
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode canonical settings: {error}"))
|
||||
}
|
||||
|
||||
/// Normalize one text setting before the frontend commits it to durable state.
|
||||
/// Keep this on the native boundary so interactive validation and persisted
|
||||
/// settings use exactly the same Aria2-compatible rules.
|
||||
pub fn canonicalize_torrent_network_setting(field: &str, value: &str) -> Result<String, String> {
|
||||
let normalized = match field {
|
||||
"torrentListenPort" => {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports")?
|
||||
}
|
||||
"torrentDhtListenPort" => {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports")?
|
||||
}
|
||||
"torrentExternalIp" => crate::queue::normalize_torrent_external_ip(Some(value))?,
|
||||
"torrentDhtEntryPoint" => {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), false)?
|
||||
}
|
||||
"torrentDhtEntryPoint6" => {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), true)?
|
||||
}
|
||||
"torrentDhtListenAddr6" => {
|
||||
crate::queue::normalize_torrent_dht_listen_addr6(Some(value))?
|
||||
}
|
||||
"torrentLpdInterface" => crate::queue::normalize_torrent_lpd_interface(Some(value))?,
|
||||
"torrentPeerIdPrefix" => crate::queue::normalize_torrent_peer_id_prefix(Some(value))?,
|
||||
"torrentPeerAgent" => crate::queue::normalize_torrent_peer_agent(Some(value))?,
|
||||
"torrentBindAddress" => crate::queue::normalize_torrent_bind_address(Some(value))?,
|
||||
"aria2DiskCache" => return crate::queue::normalize_aria2_disk_cache(Some(value)),
|
||||
_ => return Err("unknown Torrent network setting".to_string()),
|
||||
};
|
||||
Ok(normalized.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn update_settings_state(
|
||||
app_handle: &AppHandle,
|
||||
update: impl FnOnce(&mut Map<String, Value>),
|
||||
@@ -70,7 +315,11 @@ pub fn preserve_scheduler_runtime_keys(
|
||||
};
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
for key in ["schedulerLastStartKey", "schedulerLastStopKey"] {
|
||||
for key in [
|
||||
"schedulerLastStartKey",
|
||||
"schedulerTriggeredStartKey",
|
||||
"schedulerLastStopKey",
|
||||
] {
|
||||
if let Some(value) = existing_state.get(key) {
|
||||
incoming_state.insert(key.to_string(), value.clone());
|
||||
}
|
||||
@@ -185,9 +434,101 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
return;
|
||||
};
|
||||
|
||||
let main_window_size = state
|
||||
.get("mainWindowSize")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<crate::ipc::MainWindowSize>(value).ok())
|
||||
.and_then(|size| crate::window_geometry::normalize_main_window_size(Some(&size)));
|
||||
match main_window_size {
|
||||
Some(size) => {
|
||||
state.insert(
|
||||
"mainWindowSize".to_string(),
|
||||
serde_json::to_value(size).expect("main window size is serializable"),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
state.remove("mainWindowSize");
|
||||
}
|
||||
}
|
||||
|
||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "proxyPort", |value| {
|
||||
value
|
||||
.as_u64()
|
||||
.is_some_and(|value| (1..=u16::MAX as u64).contains(&value))
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxOpenFiles", |value| {
|
||||
value
|
||||
.as_u64()
|
||||
.is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_OPEN_FILES as u64..=
|
||||
crate::queue::MAX_TORRENT_MAX_OPEN_FILES as u64)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
for key in [
|
||||
"isSidebarVisible",
|
||||
"torrentEnableDht",
|
||||
"torrentEnableDht6",
|
||||
"torrentEnablePex",
|
||||
"torrentEnableLpd",
|
||||
"torrentSeparateSeedSlots",
|
||||
"torrentIpv6Enabled",
|
||||
] {
|
||||
sanitize_boolean_setting(state, key);
|
||||
}
|
||||
for key in ["proxyHost", "customUserAgent"] {
|
||||
sanitize_string_setting(state, key);
|
||||
}
|
||||
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports").is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentExternalIp", |value| {
|
||||
crate::queue::normalize_torrent_external_ip(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtEntryPoint", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), false).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtEntryPoint6", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), true).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtListenAddr6", |value| {
|
||||
crate::queue::normalize_torrent_dht_listen_addr6(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentLpdInterface", |value| {
|
||||
crate::queue::normalize_torrent_lpd_interface(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentPeerIdPrefix", |value| {
|
||||
crate::queue::normalize_torrent_peer_id_prefix(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentPeerAgent", |value| {
|
||||
crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentBindAddress", |value| {
|
||||
crate::queue::normalize_torrent_bind_address(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "aria2DiskCache", |value| {
|
||||
crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"theme",
|
||||
@@ -273,6 +614,32 @@ fn sanitize_integer_setting(
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_boolean_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
||||
if state.get(key).is_some_and(|value| !value.is_boolean()) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_string_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
||||
if state.get(key).is_some_and(|value| !value.is_string()) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_torrent_network_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
is_valid: impl Fn(&str) -> bool,
|
||||
) {
|
||||
if state
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !is_valid(value))
|
||||
{
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_allowed_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
@@ -288,12 +655,113 @@ fn sanitize_allowed_string(
|
||||
}
|
||||
|
||||
fn validate_settings(settings: &mut PersistedSettings) {
|
||||
settings.main_window_size = crate::window_geometry::normalize_main_window_size(
|
||||
settings.main_window_size.as_ref(),
|
||||
);
|
||||
if settings.max_concurrent_downloads == 0 {
|
||||
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
||||
}
|
||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||
settings.minimum_normal_download_speed_ki_b =
|
||||
crate::queue::normalize_minimum_normal_download_speed_kib(
|
||||
settings.minimum_normal_download_speed_ki_b,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit)
|
||||
.unwrap_or_default();
|
||||
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
|
||||
&settings.torrent_overall_upload_limit,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files(
|
||||
settings.torrent_max_open_files,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
|
||||
settings.torrent_max_concurrent_seeds,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address(
|
||||
Some(&settings.torrent_bind_address),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
if !settings.torrent_ipv6_enabled
|
||||
&& settings
|
||||
.torrent_bind_address
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_ipv6())
|
||||
{
|
||||
log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled");
|
||||
settings.torrent_bind_address.clear();
|
||||
}
|
||||
settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
||||
.unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
||||
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_listen_port),
|
||||
"TCP listen ports",
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_dht_listen_port),
|
||||
"UDP listen ports",
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_external_ip = crate::queue::normalize_torrent_external_ip(
|
||||
Some(&settings.torrent_external_ip),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_entry_point = crate::queue::normalize_torrent_dht_entry_point(
|
||||
Some(&settings.torrent_dht_entry_point),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_entry_point6 = crate::queue::normalize_torrent_dht_entry_point(
|
||||
Some(&settings.torrent_dht_entry_point6),
|
||||
true,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_listen_addr6 = crate::queue::normalize_torrent_dht_listen_addr6(
|
||||
Some(&settings.torrent_dht_listen_addr6),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_lpd_interface = crate::queue::normalize_torrent_lpd_interface(
|
||||
Some(&settings.torrent_lpd_interface),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_peer_id_prefix = crate::queue::normalize_torrent_peer_id_prefix(
|
||||
Some(&settings.torrent_peer_id_prefix),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_peer_agent = crate::queue::normalize_torrent_peer_agent(
|
||||
Some(&settings.torrent_peer_agent),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
if !matches!(
|
||||
settings.last_custom_speed_limit_unit.as_str(),
|
||||
"KB/s" | "MB/s"
|
||||
@@ -310,6 +778,7 @@ fn default_category_subfolders() -> HashMap<String, String> {
|
||||
("Documents", "Documents"),
|
||||
("Pictures", "Pictures"),
|
||||
("Applications", "Applications"),
|
||||
("Torrents", "Torrents"),
|
||||
("Other", "Other"),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -379,6 +848,7 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
|
||||
("Documents", "Documents"),
|
||||
("Pictures", "Images"),
|
||||
("Applications", "Apps"),
|
||||
("Torrents", "Torrents"),
|
||||
("Other", "Other"),
|
||||
];
|
||||
for (category, alias) in aliases {
|
||||
@@ -449,9 +919,12 @@ fn default_settings() -> PersistedSettings {
|
||||
approved_download_roots: Vec::new(),
|
||||
max_concurrent_downloads: 3,
|
||||
global_speed_limit: String::new(),
|
||||
torrent_overall_upload_limit: String::new(),
|
||||
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
||||
logs_enabled: false,
|
||||
is_sidebar_visible: true,
|
||||
is_folders_collapsed: false,
|
||||
main_window_size: None,
|
||||
sidebar_position: "auto".to_string(),
|
||||
active_settings_tab: SettingsTab::Downloads,
|
||||
scheduler: SchedulerSettings {
|
||||
@@ -467,11 +940,15 @@ fn default_settings() -> PersistedSettings {
|
||||
scheduler_running: false,
|
||||
scheduler_active_download_ids: Vec::new(),
|
||||
scheduler_last_start_key: String::new(),
|
||||
scheduler_triggered_start_key: None,
|
||||
scheduler_last_stop_key: String::new(),
|
||||
last_custom_speed_limit_ki_b: 1024,
|
||||
last_custom_speed_limit_unit: "MB/s".to_string(),
|
||||
per_server_connections: 16,
|
||||
max_automatic_retries: 3,
|
||||
minimum_normal_download_speed_ki_b: 0,
|
||||
retry_not_found_errors: false,
|
||||
adaptive_mirror_selection: true,
|
||||
show_notifications: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
@@ -482,6 +959,26 @@ fn default_settings() -> PersistedSettings {
|
||||
proxy_mode: ProxyMode::None,
|
||||
proxy_host: String::new(),
|
||||
proxy_port: 8080,
|
||||
torrent_enable_dht: true,
|
||||
torrent_enable_dht6: false,
|
||||
torrent_enable_pex: true,
|
||||
torrent_enable_lpd: false,
|
||||
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
torrent_separate_seed_slots: false,
|
||||
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
torrent_ipv6_enabled: true,
|
||||
torrent_listen_port: String::new(),
|
||||
torrent_dht_listen_port: String::new(),
|
||||
torrent_external_ip: String::new(),
|
||||
torrent_dht_entry_point: String::new(),
|
||||
torrent_dht_entry_point6: String::new(),
|
||||
torrent_dht_listen_addr6: String::new(),
|
||||
torrent_lpd_interface: String::new(),
|
||||
torrent_peer_id_prefix: String::new(),
|
||||
torrent_peer_agent: String::new(),
|
||||
torrent_bind_address: String::new(),
|
||||
aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(),
|
||||
custom_user_agent: String::new(),
|
||||
ask_where_to_save_each_file: false,
|
||||
remember_last_used_download_directory: false,
|
||||
@@ -498,8 +995,10 @@ fn default_settings() -> PersistedSettings {
|
||||
mod tests {
|
||||
use crate::ipc::{FontFamily, WindowControlStyle};
|
||||
use super::{
|
||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||
preserve_scheduler_runtime_keys,
|
||||
canonicalize_torrent_network_setting, canonicalize_torrent_network_settings,
|
||||
decode_stored_settings, default_settings,
|
||||
preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
|
||||
torrent_startup_settings,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -508,6 +1007,7 @@ mod tests {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "2026-06-22-start",
|
||||
"schedulerTriggeredStartKey": "2026-06-22-start",
|
||||
"schedulerLastStopKey": "2026-06-22-stop"
|
||||
},
|
||||
"version": 3
|
||||
@@ -516,6 +1016,7 @@ mod tests {
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "",
|
||||
"schedulerTriggeredStartKey": "",
|
||||
"schedulerLastStopKey": "",
|
||||
"theme": "system"
|
||||
},
|
||||
@@ -526,6 +1027,10 @@ mod tests {
|
||||
let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap();
|
||||
let merged: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start");
|
||||
assert_eq!(
|
||||
merged["state"]["schedulerTriggeredStartKey"],
|
||||
"2026-06-22-start"
|
||||
);
|
||||
assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop");
|
||||
}
|
||||
|
||||
@@ -535,6 +1040,7 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 7,
|
||||
"globalSpeedLimit": "2M",
|
||||
"torrentOverallUploadLimit": "1.5M",
|
||||
"sidebarPosition": "right",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
@@ -553,6 +1059,7 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 7);
|
||||
assert_eq!(settings.global_speed_limit, "2M");
|
||||
assert_eq!(settings.torrent_overall_upload_limit, "1.5M");
|
||||
assert_eq!(settings.sidebar_position, "right");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
@@ -578,6 +1085,7 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
assert_eq!(settings.global_speed_limit, "512K");
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
@@ -600,6 +1108,33 @@ mod tests {
|
||||
assert!(settings.logs_enabled);
|
||||
assert!(!settings.scheduler.enabled);
|
||||
assert!(settings.global_speed_limit.is_empty());
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_invalid_torrent_overall_upload_limit_to_unlimited() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentOverallUploadLimit": "not-a-rate"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_invalid_global_speed_limit_to_unlimited() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"globalSpeedLimit": "not-a-rate"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(settings.global_speed_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -619,6 +1154,7 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
||||
assert_eq!(settings.category_subfolders["Movies"], "Movies");
|
||||
assert_eq!(settings.category_subfolders["Torrents"], "Torrents");
|
||||
assert!(!settings.category_directory_overrides.contains_key("Movies"));
|
||||
assert_eq!(
|
||||
settings.category_directory_overrides["Documents"],
|
||||
@@ -750,7 +1286,8 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 99,
|
||||
"perServerConnections": -4,
|
||||
"maxAutomaticRetries": 99
|
||||
"maxAutomaticRetries": 99,
|
||||
"minimumNormalDownloadSpeedKiB": 2000000
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
@@ -760,6 +1297,17 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||
assert_eq!(settings.per_server_connections, 1);
|
||||
assert_eq!(settings.max_automatic_retries, 10);
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_reliability_defaults_are_migration_safe() {
|
||||
let stored = json!({ "state": {}, "version": 5 });
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
assert!(!settings.retry_not_found_errors);
|
||||
assert!(settings.adaptive_mirror_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -770,6 +1318,20 @@ mod tests {
|
||||
"maxConcurrentDownloads": "not-a-number",
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"torrentEnableDht": "yes",
|
||||
"torrentEnableDht6": 1,
|
||||
"torrentEnablePex": null,
|
||||
"torrentEnableLpd": [],
|
||||
"torrentMaxOpenFiles": 0,
|
||||
"torrentListenPort": "7000-6999",
|
||||
"torrentDhtListenPort": "6881,\n",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentDhtEntryPoint": "bootstrap.example",
|
||||
"torrentDhtEntryPoint6": "2001:db8::1:6881",
|
||||
"torrentDhtListenAddr6": "127.0.0.1",
|
||||
"torrentLpdInterface": "en0\n--bad",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": "agent\nname",
|
||||
"theme": "not-a-theme",
|
||||
"calendarPreference": "lunar",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
@@ -783,6 +1345,27 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(settings.torrent_enable_dht);
|
||||
assert!(!settings.torrent_enable_dht6);
|
||||
assert!(settings.torrent_enable_pex);
|
||||
assert!(!settings.torrent_enable_lpd);
|
||||
assert_eq!(
|
||||
settings.torrent_max_open_files,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
);
|
||||
assert_eq!(
|
||||
settings.torrent_dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert!(settings.torrent_listen_port.is_empty());
|
||||
assert!(settings.torrent_dht_listen_port.is_empty());
|
||||
assert!(settings.torrent_external_ip.is_empty());
|
||||
assert!(settings.torrent_dht_entry_point.is_empty());
|
||||
assert!(settings.torrent_dht_entry_point6.is_empty());
|
||||
assert!(settings.torrent_dht_listen_addr6.is_empty());
|
||||
assert!(settings.torrent_lpd_interface.is_empty());
|
||||
assert!(settings.torrent_peer_id_prefix.is_empty());
|
||||
assert!(settings.torrent_peer_agent.is_empty());
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert!(matches!(
|
||||
settings.calendar_preference,
|
||||
@@ -792,10 +1375,160 @@ mod tests {
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_proxy_and_user_agent_values_fall_back_to_safe_defaults() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"proxyMode": "custom",
|
||||
"proxyHost": 123,
|
||||
"proxyPort": 70000,
|
||||
"customUserAgent": ["not-a-string"],
|
||||
"isSidebarVisible": "yes"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(matches!(settings.proxy_mode, crate::ipc::ProxyMode::Custom));
|
||||
assert!(settings.proxy_host.is_empty());
|
||||
assert_eq!(settings.proxy_port, 8080);
|
||||
assert!(settings.custom_user_agent.is_empty());
|
||||
assert!(settings.is_sidebar_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_valid_torrent_network_settings() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentDhtListenPort": "6881",
|
||||
"torrentExternalIp": "203.0.113.7",
|
||||
"torrentDhtEntryPoint": "Bootstrap.Example:6881",
|
||||
"torrentDhtEntryPoint6": "[2001:db8::1]:6881",
|
||||
"torrentDhtListenAddr6": "2001:db8::2",
|
||||
"torrentLpdInterface": "en0",
|
||||
"torrentPeerIdPrefix": "-FL-1-3-1-",
|
||||
"torrentPeerAgent": "Firelink/1.3.1"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.torrent_listen_port, "6881-6999");
|
||||
assert_eq!(settings.torrent_dht_listen_port, "6881");
|
||||
assert_eq!(settings.torrent_external_ip, "203.0.113.7");
|
||||
assert_eq!(settings.torrent_dht_entry_point, "bootstrap.example:6881");
|
||||
assert_eq!(settings.torrent_dht_entry_point6, "[2001:db8::1]:6881");
|
||||
assert_eq!(settings.torrent_dht_listen_addr6, "2001:db8::2");
|
||||
assert_eq!(settings.torrent_lpd_interface, "en0");
|
||||
assert_eq!(settings.torrent_peer_id_prefix, "-FL-1-3-1-");
|
||||
assert_eq!(settings.torrent_peer_agent, "Firelink/1.3.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_torrent_network_settings_for_frontend_hydration() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": " Firelink/1.3.1 ",
|
||||
"torrentDhtMessageTimeout": 601,
|
||||
"torrentMaxConcurrentSeeds": 65,
|
||||
"torrentSeparateSeedSlots": "yes",
|
||||
"proxyPort": 70000,
|
||||
"proxyHost": 123,
|
||||
"customUserAgent": ["not-a-string"]
|
||||
},
|
||||
"version": 6
|
||||
});
|
||||
|
||||
let canonical = canonicalize_torrent_network_settings(&stored.to_string()).unwrap();
|
||||
let canonical: Value = serde_json::from_str(&canonical).unwrap();
|
||||
assert_eq!(canonical["state"]["torrentListenPort"], "6881-6999");
|
||||
assert_eq!(canonical["state"]["torrentExternalIp"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentDhtMessageTimeout"],
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentMaxConcurrentSeeds"],
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
|
||||
assert!(canonical["state"].get("proxyPort").is_none());
|
||||
assert!(canonical["state"].get("proxyHost").is_none());
|
||||
assert!(canonical["state"].get("customUserAgent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_individual_torrent_network_inputs_with_shared_rules() {
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentListenPort", " 6881-6999 ").unwrap(),
|
||||
"6881-6999"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentDhtEntryPoint6", "[2001:db8::1]:6881")
|
||||
.unwrap(),
|
||||
"[2001:db8::1]:6881"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("aria2DiskCache", " 256m ").unwrap(),
|
||||
"256M"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentBindAddress", " ").unwrap(),
|
||||
""
|
||||
);
|
||||
assert!(canonicalize_torrent_network_setting("torrentListenPort", "61").is_err());
|
||||
assert!(canonicalize_torrent_network_setting("unknown", "value").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_bind_address_when_transport_is_disabled() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentIpv6Enabled": false,
|
||||
"torrentBindAddress": "2001:db8::10"
|
||||
}
|
||||
});
|
||||
|
||||
let error = canonicalize_torrent_network_settings(&stored.to_string())
|
||||
.expect_err("IPv6 bind must not be accepted with IPv6 transport disabled");
|
||||
assert!(error.contains("IPv6 Torrent bind address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": "not-a-port",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": "Firelink/1.3.1"
|
||||
}
|
||||
});
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
let startup = torrent_startup_settings(Some(&settings));
|
||||
assert!(startup.listen_port.is_empty());
|
||||
assert!(startup.peer_id_prefix.is_empty());
|
||||
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
startup.dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
assert!(!default_settings().torrent_separate_seed_slots);
|
||||
assert_eq!(
|
||||
default_settings().torrent_max_concurrent_seeds,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -803,6 +1536,51 @@ mod tests {
|
||||
assert!(!default_settings().remember_last_used_download_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_geometry_use_no_persisted_size() {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({ "state": { "theme": "system" }, "version": 0 }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(settings.main_window_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_main_window_geometry_round_trips() {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({
|
||||
"state": { "mainWindowSize": { "width": 1440, "height": 900 } },
|
||||
"version": 6
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
settings
|
||||
.main_window_size
|
||||
.as_ref()
|
||||
.map(|size| (size.width, size.height)),
|
||||
Some((1440, 900))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_out_of_range_geometry_is_dropped() {
|
||||
for geometry in [
|
||||
json!({ "width": "1440", "height": 900 }),
|
||||
json!({ "width": 959, "height": 900 }),
|
||||
json!({ "width": 1440, "height": 16_385 }),
|
||||
] {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({ "state": { "mainWindowSize": geometry }, "version": 6 }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(settings.main_window_size.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_disabled_last_used_download_directory_setting() {
|
||||
let stored = json!({
|
||||
|
||||
+277
-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 {
|
||||
@@ -104,10 +109,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 +298,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 +326,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 +455,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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
@@ -16,7 +16,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false
|
||||
@@ -38,12 +38,26 @@
|
||||
"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,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
@@ -23,3 +23,38 @@ cd src-tauri
|
||||
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
|
||||
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Run the real local Torrent runtime smoke test against the host bundled Aria2
|
||||
binary. It starts a local tracker and two Aria2 daemons, then covers magnet
|
||||
metadata resolution, saved-metadata hash validation, selected-file output,
|
||||
pause/resume, ownership reporting, and cancel/remove:
|
||||
|
||||
```sh
|
||||
npm run smoke:torrent
|
||||
```
|
||||
|
||||
Use `node scripts/smoke-torrent.js --binary /path/to/aria2c` when validating a
|
||||
packaged or target-specific Aria2 binary.
|
||||
|
||||
Run the HTTP-boundary Torrent RPC integration test. It drives the production
|
||||
Aria2 RPC client through a local JSON-RPC server and verifies successful
|
||||
requests plus HTTP gateway errors:
|
||||
|
||||
```sh
|
||||
npm run test:torrent:rpc
|
||||
```
|
||||
|
||||
Run the deterministic unavailable-tracker and Aria2-daemon-exit checks with:
|
||||
|
||||
```sh
|
||||
npm run smoke:torrent:failure-paths
|
||||
```
|
||||
|
||||
Native CI runs this failure-path smoke after staging the target-specific
|
||||
bundled engines on macOS, Windows, and Linux. Windows executes the Torrent RPC,
|
||||
atomic-storage, canonical-cache, and web-seed normalization targets, while
|
||||
compiling (but not executing) the queue-manager and library test binaries;
|
||||
the Tauri mock harness exits before running on the Windows runner. The
|
||||
headless `production_contract` target still executes queue admission,
|
||||
normalization, credential-boundary, and retry contracts on Windows without
|
||||
constructing a Tauri mock application.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use firelink_lib::atomic_write_replace;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tempfile::tempdir_in;
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_replacement_replaces_existing_file_repeatedly() {
|
||||
let directory = tempdir().expect("temporary directory should be created");
|
||||
let root = fs::canonicalize(directory.path()).expect("temporary directory should canonicalize");
|
||||
let destination = root.join("download.torrent");
|
||||
|
||||
for value in [
|
||||
b"reserved".as_slice(),
|
||||
b"copied".as_slice(),
|
||||
b"databaseCommitted".as_slice(),
|
||||
b"sourceCleanupPending".as_slice(),
|
||||
] {
|
||||
atomic_write_replace(&destination, value)
|
||||
.await
|
||||
.expect("atomic replacement should succeed");
|
||||
assert_eq!(
|
||||
fs::read(&destination).expect("destination should exist"),
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn atomic_replacement_rejects_symbolic_link_destinations() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let directory = tempdir().expect("temporary directory should be created");
|
||||
let root = fs::canonicalize(directory.path()).expect("temporary directory should canonicalize");
|
||||
let target = root.join("target");
|
||||
let destination = root.join("download.torrent");
|
||||
fs::write(&target, b"protected").expect("target should be written");
|
||||
symlink(&target, &destination).expect("symbolic link should be created");
|
||||
|
||||
assert!(atomic_write_replace(&destination, b"replacement")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(&target).expect("target should remain readable"),
|
||||
b"protected"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn atomic_replacement_rejects_symbolic_link_parent_components() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let directory = tempdir().expect("temporary directory should be created");
|
||||
let root = fs::canonicalize(directory.path()).expect("temporary directory should canonicalize");
|
||||
let target_directory = root.join("target");
|
||||
let linked_directory = root.join("linked");
|
||||
let target_child = target_directory.join("child");
|
||||
std::fs::create_dir(&target_directory).expect("target directory should be created");
|
||||
std::fs::create_dir(&target_child).expect("target child directory should be created");
|
||||
symlink(&target_directory, &linked_directory).expect("symbolic link should be created");
|
||||
let destination = linked_directory.join("child/download.torrent");
|
||||
|
||||
assert!(atomic_write_replace(&destination, b"replacement")
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!target_child.join("download.torrent").exists());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn atomic_replacement_accepts_macos_system_path_aliases() {
|
||||
let directory = tempdir_in("/tmp").expect("temporary directory should be created");
|
||||
let destination = directory.path().join("download.torrent");
|
||||
|
||||
atomic_write_replace(&destination, b"replacement")
|
||||
.await
|
||||
.expect("the fixed macOS /tmp alias should be accepted");
|
||||
assert_eq!(fs::read(&destination).unwrap(), b"replacement");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_replacement_recovers_after_non_regular_destination_failure() {
|
||||
let directory = tempdir().expect("temporary directory should be created");
|
||||
let root = fs::canonicalize(directory.path()).expect("temporary directory should canonicalize");
|
||||
let destination = root.join("download.torrent");
|
||||
fs::create_dir(&destination).expect("non-regular destination should be created");
|
||||
|
||||
assert!(atomic_write_replace(&destination, b"replacement")
|
||||
.await
|
||||
.is_err());
|
||||
fs::remove_dir(&destination).expect("failed destination should be removable");
|
||||
|
||||
atomic_write_replace(&destination, b"recovered")
|
||||
.await
|
||||
.expect("atomic replacement should recover after the failed attempt");
|
||||
assert_eq!(
|
||||
fs::read(&destination).expect("destination should exist"),
|
||||
b"recovered"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user