mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 09:45:44 +00:00
Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8800805f | |||
| 6b42d878c2 | |||
| 8fd73a145e | |||
| 5f76277aaf | |||
| 1cfda45334 | |||
| 8cbc1c8768 | |||
| eca4efb1ad | |||
| 33613e8679 | |||
| d8a6440998 | |||
| 164f9f32a1 | |||
| 74eb2f84cf | |||
| 9ac3dc27a3 | |||
| 752c86c89b | |||
| 2858b4c757 | |||
| d3345d34e1 | |||
| 3624db280c | |||
| d4dcaf38b7 | |||
| c3afc414a3 | |||
| 1ddfaec338 | |||
| bbe5445933 | |||
| f5e228a589 | |||
| f65d98bbd0 | |||
| 006f941bae | |||
| 2606502293 | |||
| bbfc6e9eb1 | |||
| d329a5ad91 | |||
| 0b620b46f3 | |||
| 86c9c49be2 | |||
| efe9ffa3c2 | |||
| 6440d8ad40 | |||
| ddde763133 | |||
| 69a818d621 | |||
| ec3bd05547 | |||
| 248b4ac460 | |||
| 41b525ea18 | |||
| 3f4f344620 | |||
| 4814c8e92b | |||
| c87d4a5ec5 | |||
| 050932dc27 | |||
| 4777f1e3c3 | |||
| 725f5e40ec | |||
| b564e92532 | |||
| 1d629873b5 | |||
| 09ad412047 | |||
| f3cf70ab20 | |||
| a4a9949513 | |||
| 9bf02f2ee2 | |||
| 300e225a3a | |||
| 9d1e8d994a | |||
| 7477a26378 | |||
| e4a698eb7f | |||
| 6b9919c5a1 | |||
| 639f5bf091 | |||
| 97f15dee37 | |||
| 8d1cde8d2c | |||
| 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 | |||
| 31fbc5495b | |||
| 33dd2cb9c6 | |||
| 924e108f3c | |||
| 1634118f11 | |||
| 31cf4a1c50 | |||
| 4e504b8e74 | |||
| 4df6315be1 | |||
| 29bbb009b5 | |||
| 546be23c91 | |||
| ce8a5499c3 | |||
| 0e50bdfe9e | |||
| f4ef1a4b28 | |||
| 1044b6c619 | |||
| 07f45959d0 | |||
| 26e0096215 | |||
| 197918eab5 | |||
| 23288ca4e8 | |||
| 807663cf03 | |||
| 62f8c83c57 | |||
| 7e8f1c7d7b | |||
| 5633896d14 | |||
| 7795b3bcf8 | |||
| edc76a7fab | |||
| eb73dde911 | |||
| bff7d2782c | |||
| 8cf1bf7897 | |||
| 5db9388d07 | |||
| 61dfcf5d8a | |||
| d5c3fc8b27 | |||
| d895fb1b73 | |||
| 5372a76eda | |||
| 54693456b1 | |||
| edc0c1c6ad | |||
| c10b889a41 | |||
| 530324ab9c | |||
| 240055497d | |||
| 7787f4e6c0 | |||
| cf09bfa2e4 | |||
| 931e1f2f1e | |||
| 956b70dc10 | |||
| dd7c2196fe | |||
| d62e0429a4 | |||
| f3af5b0584 | |||
| 5614022dbd | |||
| b1659fa3d0 | |||
| 9a40b30a0f | |||
| 4f89a6c79d | |||
| f5d918ec1e | |||
| b579c35e42 | |||
| 1f1ec828f9 | |||
| 228db1eaa2 | |||
| 9a790073f5 | |||
| f3eff02069 | |||
| 4374219685 | |||
| 4e3ceb21a5 | |||
| 6adbccdf43 | |||
| 454fad712b | |||
| 7f450a802f | |||
| f2b1fb9d4a | |||
| 9302911ac7 | |||
| 18e9200b74 | |||
| 3559c69968 | |||
| 3587fb0c0d | |||
| b60818d3af | |||
| 5463d27ed3 | |||
| 3f298715a6 | |||
| ceab8a5fdf | |||
| f4e5e211cc | |||
| 9d737598c6 | |||
| d3e5e8c208 | |||
| 17eb8e5d39 |
@@ -0,0 +1,2 @@
|
||||
scripts/aria2/firelink.patch text eol=lf
|
||||
scripts/aria2/build.sh text eol=lf
|
||||
+161
-9
@@ -9,6 +9,18 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rust-security:
|
||||
name: Rust advisory audit
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install locked cargo-audit
|
||||
run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
- name: Reject vulnerable resolved dependencies
|
||||
working-directory: src-tauri
|
||||
run: cargo audit
|
||||
|
||||
frontend:
|
||||
name: Frontend checks
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -18,7 +30,7 @@ jobs:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: node --test scripts/*.node-test.js
|
||||
@@ -27,6 +39,7 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: Desktop checks (${{ matrix.target }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -44,16 +57,40 @@ jobs:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Cache Rust dependencies and build targets
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri -> target
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
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,16 +102,131 @@ 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 }}
|
||||
- name: Provision locked engines
|
||||
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: Install Aria2 source build dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||
- name: Install Aria2 source build dependencies (Windows)
|
||||
id: aria2-msys
|
||||
if: runner.os == 'Windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: >-
|
||||
base-devel autoconf automake libtool gettext-devel pkgconf
|
||||
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||
- name: Fingerprint engine toolchain
|
||||
id: engine-toolchain
|
||||
if: runner.os != 'macOS'
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||
run: node scripts/engine-toolchain-fingerprint.js
|
||||
- name: Restore verified engine payload cache
|
||||
id: engine-cache
|
||||
if: runner.os != 'macOS'
|
||||
# v4.2.0 pinned to an immutable commit; this cache is an optimization,
|
||||
# and a miss always falls back to source provisioning below.
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||
# The target, toolchain fingerprint, lockfiles, provisioning code,
|
||||
# payload validators, and runner package lists all invalidate the key.
|
||||
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||
- name: Validate restored engine payload
|
||||
id: engine-cache-validation
|
||||
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
|
||||
run: |
|
||||
node scripts/stage-engines.js
|
||||
node scripts/verify-binaries.js --staged
|
||||
- name: Restore verified Aria2 build cache
|
||||
id: aria2-cache
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||
steps.engine-cache-validation.outcome != 'success')
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||
- name: Provision locked engines
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||
steps.engine-cache-validation.outcome != 'success')
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Stage and verify engines
|
||||
env:
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||
run: |
|
||||
node scripts/stage-engines.js --target ${{ matrix.target }}
|
||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||
- name: Run Torrent process smoke
|
||||
env:
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||
run: node scripts/smoke-torrent.js --failure-paths
|
||||
- name: Run Aria2 resolver smoke
|
||||
env:
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||
run: node scripts/smoke-aria2-resolver.js
|
||||
- name: Run Aria2 normal-transfer smoke
|
||||
env:
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
|
||||
run: node scripts/smoke-aria2-transfers.js
|
||||
- name: Save verified Aria2 build cache
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
steps.aria2-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||
- name: Save verified engine payload cache
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
steps.engine-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||
|
||||
+126
-13
@@ -40,20 +40,50 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.12
|
||||
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 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 \
|
||||
@@ -70,17 +100,74 @@ jobs:
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
- name: Install Aria2 source build dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||
- name: Install Aria2 source build dependencies (Windows)
|
||||
id: aria2-msys
|
||||
if: runner.os == 'Windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: >-
|
||||
base-devel autoconf automake libtool gettext-devel pkgconf
|
||||
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||
- name: Fingerprint engine toolchain
|
||||
id: engine-toolchain
|
||||
if: runner.os != 'macOS'
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||
run: node scripts/engine-toolchain-fingerprint.js
|
||||
- name: Restore verified engine payload cache
|
||||
id: engine-cache
|
||||
if: runner.os != 'macOS'
|
||||
# v4.2.0 pinned to an immutable commit; release jobs never write cache
|
||||
# entries, so only trusted CI pushes can populate the shared payload.
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/${{ matrix.target }}
|
||||
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
|
||||
- name: Validate restored engine payload
|
||||
id: engine-cache-validation
|
||||
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
|
||||
run: |
|
||||
node scripts/stage-engines.js
|
||||
node scripts/verify-binaries.js --staged
|
||||
- name: Restore verified Aria2 build cache
|
||||
id: aria2-cache
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||
steps.engine-cache-validation.outcome != 'success')
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
|
||||
with:
|
||||
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
|
||||
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
|
||||
- name: Provision locked engines
|
||||
if: >-
|
||||
runner.os != 'macOS' &&
|
||||
(steps.engine-cache.outputs.cache-hit != 'true' ||
|
||||
steps.engine-cache-validation.outcome != 'success')
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
@@ -90,12 +177,11 @@ jobs:
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
- name: Build Linux AppImage from existing binary
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
@@ -111,11 +197,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'
|
||||
@@ -128,6 +228,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"
|
||||
@@ -193,6 +299,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:
|
||||
@@ -269,8 +378,12 @@ jobs:
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
checksum_tmp="$RUNNER_TEMP/Firelink-SHA256SUMS"
|
||||
trap 'rm -f "$checksum_tmp"' EXIT
|
||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > "$checksum_tmp"
|
||||
mv "$checksum_tmp" SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-assets/**
|
||||
|
||||
+5
-1
@@ -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/
|
||||
@@ -41,7 +45,6 @@ lerna-debug.log*
|
||||
target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/engine-dist/
|
||||
src-tauri/provisioned-engines/
|
||||
|
||||
# Locally provisioned native engines
|
||||
@@ -61,6 +64,7 @@ build/
|
||||
# Local secrets and signing material
|
||||
.env
|
||||
.env.*
|
||||
implementation_plan.md
|
||||
*.key
|
||||
*.pem
|
||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||
|
||||
+103
-5
@@ -5,26 +5,124 @@ 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.2] - 2026-09-08
|
||||
|
||||
This is the stable follow-up to the 1.4.0 pre-release and includes all work since 1.3.1. It is a major release focused on built-in Torrents, clearer transfer controls, safer recovery, and better browser and VPN support.
|
||||
|
||||
### New features
|
||||
|
||||
- **BitTorrent downloads and browser handoff**
|
||||
- Add `.torrent` files and magnet links from the Add window, file associations, `magnet:` links, and Firelink Companion.
|
||||
- Review remote metadata before queueing; choose files and priorities, allocate or preallocate data, verify existing files, remove unselected files safely, and add per-file web seeds.
|
||||
- Configure trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
|
||||
- See per-file and piece progress, availability, peers, seeders, info hash, and upload activity.
|
||||
- Set upload and seeding limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data.
|
||||
- Manage Torrents in a dedicated category with pause, resume, retry, redownload, and safe cleanup.
|
||||
- Send browser magnets, direct `.torrent` links, and browser-local Torrent attachments to the Add window for review.
|
||||
- **Download and Torrent Properties windows**
|
||||
- Open a selected download in its own window with overview, transfer, and advanced controls.
|
||||
- Use Torrent tabs for file selection, trackers, peers, options, and live diagnostics.
|
||||
- Change supported transfer, Torrent, seeding, verification, allocation, and encryption settings while work is active.
|
||||
- Inspect exact progress, allocation, destinations, resume failures, and diagnostics; copy long URLs or paths and export magnet links.
|
||||
- Keep the Properties window size during the session while theme and locale follow the app.
|
||||
- **Adaptive mirror selection**
|
||||
- Optionally choose among mirrors using recent transfer performance; history remains private on this device.
|
||||
- **Transfer and layout visibility**
|
||||
- See when a normal download is allocating its destination.
|
||||
- Remember the main-window size and position and the Folders collapse preference between launches.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Make normal downloads recover more reliably across restarts, redirects, retries, resumed transfers, missing credentials, and connection slowdowns.
|
||||
- Improve media recovery and resume behavior, preserve exact final progress, and restore adaptive YouTube formats after interruptions, addressing [#36](https://github.com/nimbold/Firelink/issues/36).
|
||||
- Keep browser and deep-link inputs in order; make magnet clipboard handoffs and Add-window destination and metadata validation clearer.
|
||||
- Make the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior more usable at narrow window sizes.
|
||||
- Document the macOS first-launch security warning and safe approval steps after the report in [#34](https://github.com/nimbold/Firelink/issues/34).
|
||||
- Refresh bundled engines and dependencies, resume interrupted engine downloads safely, and strengthen cross-platform package and release verification.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the Windows 1.4.0 startup failure, focus recursion, and Properties-window deadlock reported in [#37](https://github.com/nimbold/Firelink/issues/37) and [#41](https://github.com/nimbold/Firelink/issues/41).
|
||||
- Fix immediate download failures after the Add window showed **Ready** when a VPN or TUN's DNS path could not reach its servers; retry through the system resolver for affected transfers, addressing [#35](https://github.com/nimbold/Firelink/issues/35).
|
||||
- Fix false **Unsafe URL** failures under V2RayN, Proxifier, and other TUN or proxy setups by letting hostname lookups follow the active network route while continuing to block literal local and private targets, addressing [#38](https://github.com/nimbold/Firelink/issues/38).
|
||||
- Protect persisted downloads during startup and recover schema-v3 records instead of wiping or losing them.
|
||||
- Prevent stale or duplicate pause, resume, retry, completion, and removal actions from reviving items, misreporting progress, or leaving queue ownership behind.
|
||||
- Make replacements and cleanup safe when downloads are queued, retried, canceled, paused, completed, or removed, including multi-file assets.
|
||||
- Prevent browser capture races from duplicating a download, resuming it too early, or applying cleanup to the wrong item.
|
||||
- Fix network and Settings panel overlap plus Windows frame, shadow, focus, and cross-platform control issues.
|
||||
- Restore edited-download credentials without stale keychain conflicts.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Use [Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2), or the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest), with Firelink `1.4.2`. The Companion includes the shared Chromium package and localized Edge Add-ons submission material requested in [#39](https://github.com/nimbold/Firelink/issues/39); the public Edge listing still requires Microsoft's certification.
|
||||
|
||||
## [1.3.1] - 2026-07-30
|
||||
|
||||
This hotfix improves filename handling, localized display details, and release reliability across platforms.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prevent filename errors for long downloads and Windows-reserved names such as `CON` and `COM1`. Names are shortened safely while keeping their file extensions, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
|
||||
- Correct Persian date formatting in affected settings and timezone cases, while preserving Hebrew locale formatting.
|
||||
- Fix remaining light-theme overlay and contrast details reported with the broader appearance and localization work in [#31](https://github.com/nimbold/Firelink/issues/31).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Make Linux AppImage packaging more reliable and avoid compiling the same Rust release binary twice.
|
||||
- Strengthen cross-platform release checks so Windows and Linux test builds handle macOS-only code correctly.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Firelink Companion `2.1.0` remains the current compatible extension. No extension update is required for this hotfix; use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest).
|
||||
|
||||
## [1.3.0] - 2026-07-29
|
||||
|
||||
This release focuses on smoother queue control, a more comfortable interface, and safer recovery when downloads or browser handoffs are interrupted.
|
||||
|
||||
### New features
|
||||
|
||||
- **Live speed limits and connection controls** can be adjusted while a download is active.
|
||||
- **Queue reordering and bulk actions** are easier to use, with paused downloads kept behind work that is ready to run.
|
||||
- **Appearance preferences** now include a Windows 11-style option, sidebar-aware window controls, curated fonts for supported languages, and optional localized calendar dates. These improvements respond to [#31](https://github.com/nimbold/Firelink/issues/31).
|
||||
- **Firelink Companion `2.1.0`** is included in the `Extensions/Browser` submodule. Get the latest extension from the [Firelink-Extension releases](https://github.com/nimbold/Firelink-Extension/releases/latest).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Resume, retry, pause, and redownload actions now recover more reliably when an earlier attempt has disappeared or a background event arrives late, addressing the failed-resume report in [#28](https://github.com/nimbold/Firelink/issues/28).
|
||||
- Browser captures recover more safely when Firelink is starting, the extension restarts, or capture settings change during handoff. Captures remain in the Add window for review.
|
||||
- macOS minimize and Dock, Spotlight, and Raycast reopen behavior are more consistent, addressing [#30](https://github.com/nimbold/Firelink/issues/30).
|
||||
- The downloads table stays readable at narrow widths: row actions remain contained, columns resize consistently, and controls no longer cover Date Added text, addressing [#32](https://github.com/nimbold/Firelink/issues/32).
|
||||
- Add-window metadata, duplicate resolution, playlist selection, floating menus, page navigation, and table interactions provide clearer feedback and better keyboard behavior.
|
||||
- Startup consent and keychain access are more predictable, with clearer permission feedback and safer modal focus.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
|
||||
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the user’s settings.
|
||||
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
|
||||
- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
|
||||
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
|
||||
|
||||
## [1.2.0] - 2026-07-22
|
||||
|
||||
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
|
||||
|
||||
### New features
|
||||
|
||||
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
|
||||
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, responding to the Chinese-language request in [#17](https://github.com/nimbold/Firelink/issues/17). These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
|
||||
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
|
||||
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
|
||||
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies.
|
||||
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table.
|
||||
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies, addressing [#23](https://github.com/nimbold/Firelink/issues/23).
|
||||
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. This also follows the connection-setting report in [#20](https://github.com/nimbold/Firelink/issues/20).
|
||||
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
|
||||
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows.
|
||||
- Reuse unfinished downloads when their filenames match, addressing [#26](https://github.com/nimbold/Firelink/issues/26), while reducing the chance of stale state creating duplicate or misleading rows.
|
||||
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
|
||||
- Improve RTL behavior while keeping the download table's physical file columns readable.
|
||||
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path.
|
||||
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path, addressing the media reports in [#22](https://github.com/nimbold/Firelink/issues/22) and [#24](https://github.com/nimbold/Firelink/issues/24).
|
||||
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
|
||||
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
|
||||
- Refine localized wording, including Persian status labels.
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 8a9a4c4a63...f20954fe0a
@@ -1,107 +1,114 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
|
||||
<p align="center">
|
||||
<img src="src/assets/app-icon.png" alt="Firelink app icon" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
# Firelink
|
||||
# Firelink
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
> A fast, focused desktop download manager for macOS, Windows, and Linux.
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](LICENSE)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
[](https://github.com/nimbold/Firelink/releases/latest)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](#installation)
|
||||
[](#installation)
|
||||
[](#installation)
|
||||
|
||||
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md)
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<p align="center">
|
||||
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
|
||||
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
|
||||
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
|
||||
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><b>View more screenshots</b></summary>
|
||||
<br/>
|
||||
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
|
||||
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
|
||||
</details>
|
||||
</div>
|
||||
## What is Firelink?
|
||||
|
||||
## Overview
|
||||
Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
|
||||
|
||||
Firelink manages direct downloads, browser captures, media extraction, playlists, scheduling, and file placement from one desktop app. It uses a Rust/Tauri backend with a React and TypeScript interface, and bundles the engines needed for its download and media workflows.
|
||||
It uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
|
||||
|
||||
The current desktop release is **1.2.0**, paired with [Firelink Companion 2.0.6](https://github.com/nimbold/Firelink-Extension/releases). The release focuses on a more dependable download table and queue, better browser handoffs, broader localization, and safer packaged builds.
|
||||
## Status
|
||||
|
||||
Firelink `1.4.2` is the latest desktop release.
|
||||
|
||||
Use [the latest Firelink Companion release, `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) with Firelink. The Companion is maintained in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension).
|
||||
|
||||
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
||||
|
||||
Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian. Translation corrections are welcome.
|
||||
|
||||
## Features
|
||||
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Playlist downloads** for YouTube playlists with queueing and efficient rendering for large lists.
|
||||
- **Customizable download table** with selectable columns, sorting, drag-to-reorder, and bulk actions.
|
||||
- **Add window** for metadata, duplicate handling, captured links, clipboard-prefilled URLs, save locations, and live connection limits.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, scheduling, and multi-select actions.
|
||||
- **Optional batch folders** for grouping related multi-link downloads in a new, editable folder.
|
||||
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add-window review, replay protection, and server checks.
|
||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- **Localization** for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian, with RTL support. Non-English translations are LLM-assisted and welcome user review.
|
||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
||||
- 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.
|
||||
- Appearance preferences with window styles, multilingual fonts, and localized calendar dates.
|
||||
- Browser handoff through local pairing and signed requests.
|
||||
- Tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- RTL support for Hebrew and Persian.
|
||||
|
||||
## Installation
|
||||
|
||||
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
|
||||
Download the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
||||
|
||||
| Platform | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
|
||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the notes below. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. |
|
||||
| macOS Apple silicon | `.dmg` | Ad-hoc signed and not notarized. macOS may require approval in **System Settings -> Privacy & Security**. |
|
||||
| Windows x64 | NSIS `.exe` | Unsigned. Windows SmartScreen may display a warning. |
|
||||
| Windows x64 portable | `.zip` | Extract to a writable folder and launch `firelink.exe`. |
|
||||
| Linux x64 | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage. |
|
||||
|
||||
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately.
|
||||
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>
|
||||
|
||||
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
|
||||
|
||||
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside the executable under `data/`.
|
||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
||||
- Credentials, browser cookies, and URL query or fragment data are not saved in portable queue records.
|
||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
||||
- The folder contains the extension pairing credential. Treat it as sensitive and do not share it.
|
||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
||||
- Keep the folder writable. Avoid `Program Files` and read-only media.
|
||||
- Settings, queues, logs, and WebView data are stored beside the executable in `data/`.
|
||||
- Close Firelink before moving or copying the folder.
|
||||
- Treat the folder as sensitive because it contains the extension pairing credential.
|
||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||
|
||||
</details>
|
||||
|
||||
## Browser Extension
|
||||
## Browser integration
|
||||
|
||||
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink. Captured downloads always open Firelink's Add window so you can review them before starting or queuing them.
|
||||
[Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) connects browser downloads, links, media pages, magnets, and Torrent metadata to Firelink. Use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest) with the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
||||
|
||||
It provides automatic capture for regular downloads, explicit **Fetch media** actions, link and selected-text context menus, Firefox and Chromium support, signed local requests, and a safe browser fallback when Firelink is unavailable.
|
||||
Captured links open Firelink's Add window for review before they are started or queued.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
|
||||
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
<p>
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install from Firefox Add-ons" /></a>
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Manual install for Chromium browsers" /></a>
|
||||
</p>
|
||||
|
||||
Install the extension, open Firelink, and pair it from **Settings -> Integrations**. Use the latest [Firelink Companion 2.0.6 release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.2.0. Chromium users can load `firelink-chromium.zip` by following the [manual installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Target | Status |
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported with a native build, bundled-engine checks, launch smoke test, and ad-hoc-signed DMG workflow. |
|
||||
| **Windows x64** | Supported with a native build, engine checks, installer smoke test, NSIS installer, and portable ZIP. |
|
||||
| **Linux x64** | Supported with native builds, bundled-engine checks, package/AppImage smoke tests, `.deb`, `.rpm`, and AppImage packages. |
|
||||
See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -112,34 +119,50 @@ Install the extension, open Firelink, and pair it from **Settings -> Integration
|
||||
- Rust and Cargo
|
||||
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
Clone the repository with its browser-extension submodule:
|
||||
### Quick start
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
|
||||
cd Firelink
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Launch the app with `npm run tauri dev`. Run the core checks with:
|
||||
Run the main checks with:
|
||||
|
||||
```sh
|
||||
node --test scripts/*.node-test.js
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri
|
||||
cargo test --all-targets
|
||||
cd src-tauri && cargo test --all-targets
|
||||
```
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
|
||||
|
||||
## Help and Project Status
|
||||
## Contributing and support
|
||||
|
||||
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
|
||||
- Report bugs and request features in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
|
||||
- Open focused pull requests with tests for behavior changes.
|
||||
- Report translation corrections in an issue or pull request.
|
||||
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
|
||||
|
||||
## Technology and License
|
||||
## Credits
|
||||
|
||||
Firelink is built with [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/), [SQLite](https://www.sqlite.org/), [aria2](https://aria2.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [FFmpeg](https://ffmpeg.org/), and [Deno](https://deno.com/).
|
||||
Core technologies:
|
||||
|
||||
Firelink is available under the [MIT License](LICENSE).
|
||||
- [Tauri 2](https://v2.tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/)
|
||||
- [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/)
|
||||
- [SQLite](https://www.sqlite.org/)
|
||||
|
||||
Bundled engines:
|
||||
|
||||
- [aria2](https://aria2.github.io/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [FFmpeg](https://ffmpeg.org/)
|
||||
- [Deno](https://deno.com/)
|
||||
|
||||
Font licenses, engine versions, source references, and third-party notices are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
## License
|
||||
|
||||
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
|
||||
|
||||
+57
-6
@@ -22,10 +22,21 @@ Firelink never falls back to system-installed media tools.
|
||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||
- `scripts/provision-engines.js` downloads and verifies target archives.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload in an
|
||||
invocation-owned temporary workspace.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
Aria2 allocation telemetry is a required bundle capability. Windows and Linux
|
||||
provisioning now builds the checksum-pinned upstream source archive with
|
||||
`scripts/aria2/firelink.patch`; this patch also retains Firelink's native DNS,
|
||||
network target policy, and Torrent routing changes. CI installs the compiler
|
||||
and static-library prerequisites. Windows uses the MSYS2 installation returned
|
||||
by the setup action (`FIRELINK_MSYS2_ROOT`, default `C:/msys64` for local builds).
|
||||
The patch checksum is recorded in both source and payload provenance. Never
|
||||
replace these builds with stock Aria2 archives: package verification requires
|
||||
`firelinkAllocationTelemetry: true` from `aria2.getVersion`.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
@@ -41,8 +52,6 @@ Keep versions aligned:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri && cargo test --all-targets
|
||||
@@ -50,10 +59,47 @@ cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
`npm run tauri` owns engine staging for `dev`, `build`, and `bundle`. The
|
||||
wrapper creates a private workspace, verifies the payload, and removes the
|
||||
workspace after Tauri exits. To stage and verify a payload manually, provide a
|
||||
private output root explicitly:
|
||||
|
||||
```bash
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
ENGINE_OUTPUT_ROOT="$(mktemp -d -t firelink-engines)/engine-dist"
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
|
||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
|
||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||
```
|
||||
|
||||
Do not use `src-tauri/engine-dist` or another repository-shared directory as
|
||||
the manual output root. On Windows, set `FIRELINK_ENGINE_OUTPUT_ROOT` to a
|
||||
private directory under `$env:TEMP` and use the PowerShell form:
|
||||
|
||||
```powershell
|
||||
$env:FIRELINK_ENGINE_OUTPUT_ROOT = Join-Path $env:TEMP "firelink-engines-$PID\engine-dist"
|
||||
node scripts/stage-engines.js --target x86_64-pc-windows-msvc
|
||||
node scripts/verify-binaries.js --staged --target x86_64-pc-windows-msvc
|
||||
```
|
||||
|
||||
Verify the DMG and the app it contains, then launch outside the repository
|
||||
working directory. The DMG bundler removes the intermediate app directory, so
|
||||
the post-build checks must use the mounted release artifact:
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
@@ -65,6 +111,11 @@ pass. A `workflow_dispatch` on a `v*` tag also publishes when its
|
||||
release-certification inputs; clean-machine QA remains a release-owner gate
|
||||
before pushing the tag.
|
||||
|
||||
For paired releases, publish and verify the Companion release first. The
|
||||
desktop release workflow requires `Extensions/Browser` to be at a clean commit
|
||||
whose exact tag matches both the Companion `package.json` and `manifest.json`
|
||||
versions before building desktop packages.
|
||||
|
||||
## Automated release builds
|
||||
|
||||
Push a version tag to build and verify native artifacts:
|
||||
|
||||
+30
-4
@@ -2,7 +2,26 @@
|
||||
|
||||
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
|
||||
|
||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
||||
Exact versions, target hashes, sources, and build descriptions are pinned in
|
||||
`engines.lock.json` for the packaged macOS payload and `engine-sources.lock.json`
|
||||
for the provisioned Windows and Linux payloads.
|
||||
|
||||
## Bundled fonts
|
||||
|
||||
Firelink bundles variable WOFF2 web fonts through Fontsource. Each font is
|
||||
licensed under the SIL Open Font License, Version 1.1. The corresponding
|
||||
license and author information are available from the linked project sources.
|
||||
|
||||
- Inter: <https://github.com/rsms/inter>
|
||||
- Outfit: <https://github.com/Outfitio/Outfit-Fonts>
|
||||
- Roboto: <https://github.com/googlefonts/roboto-classic>
|
||||
- Vazirmatn: <https://github.com/rastikerdar/vazirmatn>
|
||||
- Noto Sans Hebrew: <https://github.com/notofonts/hebrew>
|
||||
- Noto Sans SC: <https://github.com/notofonts/noto-cjk>
|
||||
|
||||
Fontsource distribution: <https://github.com/fontsource/font-files>
|
||||
|
||||
License text: <https://openfontlicense.org/open-font-license-official-text/>
|
||||
|
||||
## aria2
|
||||
|
||||
@@ -10,9 +29,14 @@ Exact versions, target hashes, sources, and build descriptions are pinned in `en
|
||||
- Source: <https://github.com/aria2/aria2>
|
||||
- License: GNU General Public License version 2 or later
|
||||
|
||||
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
|
||||
Corresponding source for the distributed version is available from the source
|
||||
link and release tag listed in the applicable engine lock file. Firelink release
|
||||
notes must retain that source reference.
|
||||
|
||||
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
|
||||
Windows and Linux use checksum-pinned upstream aria2 source archives built with
|
||||
Firelink's reviewed native-DNS, network-target, and allocation-telemetry patch.
|
||||
The archive, patch checksum, and build provenance are recorded in
|
||||
`engine-sources.lock.json`.
|
||||
|
||||
## FFmpeg
|
||||
|
||||
@@ -39,4 +63,6 @@ Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python
|
||||
|
||||
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
|
||||
|
||||
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
|
||||
Release engineering must review each newly added target payload before adding its
|
||||
hashes to the applicable engine lock file. Missing provenance or license data
|
||||
blocks release.
|
||||
|
||||
+44
-26
@@ -3,48 +3,66 @@
|
||||
"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.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
|
||||
"version": "2.9.6",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-29-g703dcc25b9",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-gpl-8.1.zip",
|
||||
"sha256": "ebf57e8b1a10b176b88c3cbc66e68a4aed472cf47520b0fbf003e892fb3be642"
|
||||
"version": "9.0.1-26-g5c8e7e2433",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-win64-gpl-9.0.zip",
|
||||
"sha256": "dd232ccf8661f837a1faa5f534a1a0bdbdb25c42afe79391e8345154df78f791"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
|
||||
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||
"buildFromSource": true,
|
||||
"patch": "scripts/aria2/firelink.patch",
|
||||
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
|
||||
"allocationTelemetry": true,
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
"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.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
|
||||
"version": "2.9.6",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-29-g703dcc25b9",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "c6c54589dd3443fb86b441351218d1f0e0cba8221134c3d2e7e3eda80e984747"
|
||||
"version": "9.0.1-26-g5c8e7e2433",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-linux64-gpl-9.0.tar.xz",
|
||||
"sha256": "e60c4187c792cc35d2558adbae5582c470713f5afed7212999200340f1394f8d"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
|
||||
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
|
||||
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||
"buildFromSource": true,
|
||||
"patch": "scripts/aria2/firelink.patch",
|
||||
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
|
||||
"allocationTelemetry": true,
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-14
@@ -4,35 +4,44 @@
|
||||
"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",
|
||||
"source": "https://github.com/aria2/aria2",
|
||||
"build": "arm64 executable with adjacent aria2-libs",
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"source": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs",
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
},
|
||||
"sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7",
|
||||
"allocationTelemetry": true,
|
||||
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125610-g312c830916",
|
||||
"version": "9.0.1",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
"build": "Stable GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1787073674_9.0.1/ffmpeg.zip",
|
||||
"sourceSha256": "8287a1b2229e05eb41859f073e18e6c52c60a778f2f5e6881070fe51b79407fe",
|
||||
"sha256": "393e4c395020a1cb7cbd77fbe00599ce69d1c6466fee0dbd59d13f86a81a1611"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.3",
|
||||
"version": "2.9.6",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
|
||||
"sha256": "b3ac3bd206e48c26026cadd80c1367e96c149f9c66130952382a642b09fa8a71"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
|
||||
"files": 133,
|
||||
"sha256": "24ac8c7557dbef0ef9e5685365b4620bc441a168daf314b71218276b782dc449"
|
||||
},
|
||||
"aria2-libs": {
|
||||
"files": 7,
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Firelink Download Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+618
-420
File diff suppressed because it is too large
Load Diff
+33
-21
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.2",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -24,7 +24,7 @@
|
||||
"desktop"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -33,38 +33,50 @@
|
||||
"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",
|
||||
"test": "vitest"
|
||||
"tauri": "node scripts/tauri-command.js",
|
||||
"test": "vitest",
|
||||
"test:race": "vitest run --repeats 5 src/utils/dockBadge.test.ts src/store/downloadStore.test.ts src/store/useDownloadStore.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
||||
"@fontsource-variable/outfit": "^5.3.0",
|
||||
"@fontsource-variable/roboto": "^5.3.0",
|
||||
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"i18next": "^26.3.6",
|
||||
"lucide-react": "^1.25.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||
"@tauri-apps/plugin-log": "^2.9.1",
|
||||
"@tauri-apps/plugin-notification": "^2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.5",
|
||||
"i18next": "^26.4.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.10",
|
||||
"zustand": "^5.0.14"
|
||||
"react-i18next": "^17.0.13",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.21",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,138 @@
|
||||
export const ARIA2_FIRELINK_REVISION = 'firelink-native-dns-v1';
|
||||
export const ARIA2_DNS_RESOLVER = 'native-async';
|
||||
export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1';
|
||||
export const ARIA2_NETWORK_TARGET_POLICY_DIGEST =
|
||||
'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705';
|
||||
|
||||
// The standard Aria2 option is the production default. It keeps hostname
|
||||
// resolution on the OS/TUN route and is accepted by stock and patched builds.
|
||||
// There is intentionally no daemon-wide flag here: async-dns=false is stamped
|
||||
// per transfer after the caller has selected the compatible route.
|
||||
export const ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS = Object.freeze([]);
|
||||
export const ARIA2_SYSTEM_RESOLVER_OPTIONS = Object.freeze({
|
||||
'async-dns': 'false',
|
||||
});
|
||||
|
||||
// Firelink-patched Aria2 exposes these options for the bounded alternate
|
||||
// magnet-probe attempt. They must never be sent to an unverified binary.
|
||||
export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([
|
||||
'--async-dns=true',
|
||||
`--dns-resolver=${ARIA2_DNS_RESOLVER}`,
|
||||
`--network-target-policy=${ARIA2_NETWORK_TARGET_POLICY}`,
|
||||
]);
|
||||
|
||||
export const ARIA2_ROUTE_OPTIONS = Object.freeze({
|
||||
'async-dns': 'true',
|
||||
'dns-resolver': ARIA2_DNS_RESOLVER,
|
||||
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
|
||||
});
|
||||
|
||||
// Local HTTP servers are used only by engine smoke tests. Product transfers
|
||||
// never apply the fixture exception; Firelink's own admission policy still
|
||||
// rejects literal local targets before Aria2 is contacted.
|
||||
export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({
|
||||
...ARIA2_SYSTEM_RESOLVER_OPTIONS,
|
||||
});
|
||||
|
||||
export function assertAria2Baseline(version) {
|
||||
if (typeof version?.version !== 'string' || version.version.trim() === '') {
|
||||
throw new Error(`aria2 returned an invalid baseline version response: ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2RouteCapabilities(version) {
|
||||
if (!Array.isArray(version?.enabledFeatures)
|
||||
|| !version.enabledFeatures.includes('Async DNS')) {
|
||||
throw new Error(`aria2 does not advertise asynchronous DNS: ${JSON.stringify(version)}`);
|
||||
}
|
||||
const expected = {
|
||||
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
};
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (version?.[field] !== value) {
|
||||
throw new Error(`aria2 route capability mismatch for ${field}: ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(version.firelinkDnsResolvers)
|
||||
|| !version.firelinkDnsResolvers.includes(ARIA2_DNS_RESOLVER)) {
|
||||
throw new Error(`aria2 does not advertise the Firelink native resolver: ${JSON.stringify(version)}`);
|
||||
}
|
||||
if (!Array.isArray(version.firelinkNetworkTargetPolicies)
|
||||
|| !version.firelinkNetworkTargetPolicies.includes(ARIA2_NETWORK_TARGET_POLICY)) {
|
||||
throw new Error(`aria2 does not advertise the Firelink target policy: ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAria2RouteCapabilities(version) {
|
||||
try {
|
||||
assertAria2RouteCapabilities(version);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2SystemResolverOptions(options, context = 'aria2 transfer') {
|
||||
if (options?.['async-dns'] !== 'false') {
|
||||
throw new Error(`${context} did not retain async-dns=false: ${JSON.stringify(options)}`);
|
||||
}
|
||||
if (Object.hasOwn(options || {}, 'dns-resolver')
|
||||
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER) {
|
||||
throw new Error(`${context} retained an unknown resolver mode: ${JSON.stringify(options)}`);
|
||||
}
|
||||
if (Object.hasOwn(options || {}, 'network-target-policy')
|
||||
&& options['network-target-policy'] !== 'none') {
|
||||
throw new Error(`${context} retained an active target policy: ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2RouteContract(version) {
|
||||
assertAria2RouteCapabilities(version);
|
||||
const expected = {
|
||||
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
|
||||
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
};
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (version?.[field] !== value) {
|
||||
throw new Error(`aria2 route contract mismatch for ${field}: ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
if (version.firelinkNetworkTargetPolicyEnforced !== true) {
|
||||
throw new Error(`aria2 is not enforcing the Firelink network target policy: ${JSON.stringify(version)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2RouteOptions(options, context = 'aria2 transfer') {
|
||||
for (const [field, value] of Object.entries(ARIA2_ROUTE_OPTIONS)) {
|
||||
if (options?.[field] !== value) {
|
||||
throw new Error(`${context} did not retain ${field}=${value}: ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2RouteSource(source, target) {
|
||||
const contract = source?.firelinkRouteContract;
|
||||
const expected = {
|
||||
revision: ARIA2_FIRELINK_REVISION,
|
||||
dnsResolver: ARIA2_DNS_RESOLVER,
|
||||
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
};
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (contract?.[field] !== value) {
|
||||
throw new Error(
|
||||
`aria2c source for ${target} is not a Firelink route-contract build; `
|
||||
+ `expected firelinkRouteContract.${field}=${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2AllocationCapabilities(version) {
|
||||
if (version?.firelinkAllocationTelemetry !== true) {
|
||||
throw new Error('Bundled Aria2 does not expose file allocation telemetry');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertAria2AllocationCapabilities,
|
||||
ARIA2_DNS_RESOLVER,
|
||||
ARIA2_FIRELINK_REVISION,
|
||||
ARIA2_NETWORK_TARGET_POLICY,
|
||||
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
assertAria2SystemResolverOptions,
|
||||
assertAria2RouteCapabilities,
|
||||
assertAria2RouteContract,
|
||||
assertAria2RouteSource,
|
||||
} from './aria2-route-contract.js';
|
||||
|
||||
const secureVersion = {
|
||||
enabledFeatures: ['Async DNS'],
|
||||
firelinkRevision: ARIA2_FIRELINK_REVISION,
|
||||
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
|
||||
firelinkDnsResolvers: [ARIA2_DNS_RESOLVER],
|
||||
firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY],
|
||||
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
firelinkNetworkTargetPolicyEnforced: true,
|
||||
};
|
||||
|
||||
test('Aria2 source metadata must identify the Firelink route contract', () => {
|
||||
const source = {
|
||||
firelinkRouteContract: {
|
||||
revision: ARIA2_FIRELINK_REVISION,
|
||||
dnsResolver: ARIA2_DNS_RESOLVER,
|
||||
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
|
||||
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
|
||||
},
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => assertAria2RouteSource(source, 'test-target'));
|
||||
assert.throws(
|
||||
() => assertAria2RouteSource({}, 'test-target'),
|
||||
/not a Firelink route-contract build/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertAria2RouteSource({
|
||||
firelinkRouteContract: {
|
||||
...source.firelinkRouteContract,
|
||||
networkTargetPolicyDigest: 'sha256:wrong',
|
||||
},
|
||||
}, 'test-target'),
|
||||
/networkTargetPolicyDigest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('route capabilities are distinct from the active local-fixture policy', () => {
|
||||
assert.doesNotThrow(() => assertAria2RouteCapabilities({
|
||||
...secureVersion,
|
||||
firelinkNetworkTargetPolicy: 'none',
|
||||
firelinkNetworkTargetPolicyEnforced: false,
|
||||
}));
|
||||
assert.doesNotThrow(() => assertAria2RouteContract(secureVersion));
|
||||
assert.throws(
|
||||
() => assertAria2RouteContract({
|
||||
...secureVersion,
|
||||
firelinkNetworkTargetPolicy: 'none',
|
||||
firelinkNetworkTargetPolicyEnforced: false,
|
||||
}),
|
||||
/route contract mismatch for firelinkNetworkTargetPolicy/,
|
||||
);
|
||||
});
|
||||
|
||||
test('system resolver options cannot retain the custom target policy', () => {
|
||||
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
|
||||
'async-dns': 'false',
|
||||
}));
|
||||
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
|
||||
'async-dns': 'false',
|
||||
'network-target-policy': 'none',
|
||||
}));
|
||||
assert.throws(
|
||||
() => assertAria2SystemResolverOptions({
|
||||
'async-dns': 'false',
|
||||
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
|
||||
}),
|
||||
/active target policy/,
|
||||
);
|
||||
});
|
||||
|
||||
test('allocation telemetry is mandatory and must be a JSON boolean capability', () => {
|
||||
assert.throws(() => assertAria2AllocationCapabilities({ version: '1.37.0' }));
|
||||
assert.throws(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: 'true' }));
|
||||
assert.doesNotThrow(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: true }));
|
||||
});
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Build from the checksum-pinned upstream archive plus the reviewed patch.
|
||||
source_root="$1"
|
||||
patch_file="$2"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
source_root="$(cygpath -u "$source_root")"
|
||||
patch_file="$(cygpath -u "$patch_file")"
|
||||
export PATH="/mingw64/bin:/usr/bin:$PATH"
|
||||
export ACLOCAL_PATH="/mingw64/share/aclocal:/usr/share/aclocal${ACLOCAL_PATH:+:$ACLOCAL_PATH}"
|
||||
export PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
|
||||
fi
|
||||
|
||||
copy_mingw_runtime_dependencies() {
|
||||
local binary="$1"
|
||||
local runtime_dir="$2"
|
||||
local dependency
|
||||
local source
|
||||
local destination
|
||||
|
||||
while IFS= read -r dependency; do
|
||||
[[ -z "$dependency" ]] && continue
|
||||
source="/mingw64/bin/$dependency"
|
||||
if [[ ! -f "$source" ]]; then
|
||||
continue
|
||||
fi
|
||||
destination="$runtime_dir/$dependency"
|
||||
if [[ -e "$destination" ]]; then
|
||||
continue
|
||||
fi
|
||||
cp "$source" "$destination"
|
||||
copy_mingw_runtime_dependencies "$destination" "$runtime_dir"
|
||||
done < <(objdump -p "$binary" | awk '/DLL Name:/{print $3}')
|
||||
}
|
||||
|
||||
cd "$source_root"
|
||||
patch --batch -p1 < "$patch_file"
|
||||
autoreconf -fi
|
||||
mkdir firelink-build
|
||||
cd firelink-build
|
||||
# Linux and Windows payloads are self-contained; do not inherit host dylibs.
|
||||
export LDFLAGS="-static ${LDFLAGS:-}"
|
||||
export PKG_CONFIG="pkg-config --static"
|
||||
../configure --enable-static --disable-shared --disable-nls \
|
||||
--without-gnutls --with-openssl --without-libxml2 --with-libexpat \
|
||||
--without-libgmp --without-libnettle --without-libgcrypt \
|
||||
--with-libssh2 --with-libcares
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
JOBS="$(nproc 2>/dev/null || echo 4)"
|
||||
elif [[ -n "${NUMBER_OF_PROCESSORS:-}" ]]; then
|
||||
JOBS="$NUMBER_OF_PROCESSORS"
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)"
|
||||
else
|
||||
JOBS=4
|
||||
fi
|
||||
JOBS="${JOBS//$'\r'/}"
|
||||
JOBS="${JOBS// /}"
|
||||
if ! [[ "$JOBS" =~ ^[1-9][0-9]*$ ]]; then
|
||||
JOBS=4
|
||||
fi
|
||||
make -j"$JOBS"
|
||||
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
command -v objdump >/dev/null 2>&1 || {
|
||||
echo "MinGW objdump is required to collect Aria2 runtime dependencies." >&2
|
||||
exit 1
|
||||
}
|
||||
runtime_dir="$source_root/aria2-libs"
|
||||
mkdir -p "$runtime_dir"
|
||||
copy_mingw_runtime_dependencies "$source_root/firelink-build/src/aria2c.exe" "$runtime_dir"
|
||||
if [[ -d /mingw64/lib/ossl-modules ]]; then
|
||||
find /mingw64/lib/ossl-modules -maxdepth 1 -type f -iname '*.dll' -exec cp {} "$runtime_dir/" \;
|
||||
fi
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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));
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -53,11 +52,4 @@ process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`
|
||||
run(process.execPath, ['scripts/stage-engines.js']);
|
||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
||||
|
||||
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
|
||||
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
fs.rmSync(engineDist, { recursive: true, force: true });
|
||||
fs.mkdirSync(engineDist, { recursive: true });
|
||||
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
|
||||
}
|
||||
|
||||
runNpmScript('build');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveOutputRoot, resolveTargetTriple } from './engine-workspace.js';
|
||||
|
||||
if (
|
||||
process.env.FIRELINK_SKIP_ENGINE_RESOURCE === '1'
|
||||
|| process.env.FIRELINK_ENGINE_BUNDLE_PREPARED === '1'
|
||||
) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Staging belongs to beforeBuildCommand or the standalone-bundle wrapper.
|
||||
// This hook is a read-only fence against a missing payload immediately before
|
||||
// Tauri consumes the resource tree.
|
||||
const target = resolveTargetTriple();
|
||||
const outputRoot = resolveOutputRoot();
|
||||
const suffix = target.includes('windows') ? '.exe' : '';
|
||||
const destination = path.join(outputRoot, target);
|
||||
const expectedNames = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']
|
||||
.map(engine => `${engine}-${target}${suffix}`);
|
||||
|
||||
for (const name of expectedNames) {
|
||||
const candidate = path.join(destination, name);
|
||||
if (!fs.existsSync(candidate) || !fs.lstatSync(candidate).isFile()) {
|
||||
throw new Error(`Prepared engine payload is incomplete: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Prepared engine payload is present for ${target} at ${destination}`);
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
let activeChild;
|
||||
let receivedSignal;
|
||||
|
||||
export const APPIMAGE_CONFIG = JSON.stringify({
|
||||
bundle: {
|
||||
resources: {
|
||||
// Tauri merges --config values using JSON Merge Patch. An omitted key
|
||||
// would leave the base engine resource enabled; null explicitly removes
|
||||
// it for this first packaging pass. The verified payload is added back
|
||||
// by repack-linux-appimage-engines.js after linuxdeploy finishes.
|
||||
'engine-dist/': null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
return {
|
||||
SIGHUP: 129,
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
}[signal] ?? 1;
|
||||
}
|
||||
|
||||
function npmInvocation(args) {
|
||||
if (process.env.npm_execpath) {
|
||||
return [process.execPath, [process.env.npm_execpath, ...args]];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]];
|
||||
}
|
||||
|
||||
return ['npm', args];
|
||||
}
|
||||
|
||||
export function appImageBundleArguments(target) {
|
||||
return [
|
||||
'run',
|
||||
'tauri',
|
||||
'--',
|
||||
'bundle',
|
||||
'-vv',
|
||||
'--target',
|
||||
target,
|
||||
'--bundles',
|
||||
'appimage',
|
||||
'--config',
|
||||
APPIMAGE_CONFIG,
|
||||
];
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
windowsHide: true,
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
|
||||
activeChild = child;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
child.once('error', error => {
|
||||
if (activeChild === child) activeChild = undefined;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.once('close', (code, signal) => {
|
||||
if (activeChild === child) activeChild = undefined;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ code, signal });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runChecked(command, args, label = command, options = {}) {
|
||||
let result;
|
||||
try {
|
||||
result = await run(command, args, options);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
const error = new Error(`${label} was terminated by ${result.signal}.`);
|
||||
error.exitCode = signalExitCode(result.signal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
const error = new Error(`${label} exited with status ${result.code}.`);
|
||||
error.exitCode = result.code ?? 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertSafeTarget(target) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) {
|
||||
throw new Error(`Invalid target triple: ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE;
|
||||
if (!target) {
|
||||
throw new Error('Pass --target <triple>.');
|
||||
}
|
||||
assertSafeTarget(target);
|
||||
|
||||
// The native-package build has already staged and verified the engines.
|
||||
// Verify the immutable provisioned payload once more before creating the
|
||||
// AppImage so a failed preparation cannot produce an artifact that later
|
||||
// appears valid only because its payload is absent.
|
||||
const provisionedRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
await runChecked(
|
||||
process.execPath,
|
||||
['scripts/verify-binaries.js', '--root', provisionedRoot, '--target', target],
|
||||
'provisioned engine verification'
|
||||
);
|
||||
|
||||
if (receivedSignal) {
|
||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||
error.exitCode = signalExitCode(receivedSignal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
|
||||
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling', {
|
||||
env: { FIRELINK_SKIP_ENGINE_RESOURCE: '1' },
|
||||
});
|
||||
|
||||
if (receivedSignal) {
|
||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||
error.exitCode = signalExitCode(receivedSignal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Built the Linux AppImage from the existing ${target} release binary.`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
const handleSignal = signal => {
|
||||
receivedSignal ??= signal;
|
||||
|
||||
// npm and Tauri can have Rust/packaging descendants. On POSIX, the child
|
||||
// is a detached process-group leader, so signal the whole group instead of
|
||||
// leaving descendants running after the wrapper exits.
|
||||
if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) {
|
||||
try {
|
||||
if (process.platform === 'win32' || !activeChild.pid) {
|
||||
activeChild.kill(signal);
|
||||
} else {
|
||||
process.kill(-activeChild.pid, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') {
|
||||
console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.exitCode = signalExitCode(signal);
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, () => handleSignal(signal));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(error => {
|
||||
console.error(`[FAIL] ${error.message}`);
|
||||
process.exitCode = error.exitCode ?? 1;
|
||||
})
|
||||
.finally(() => {
|
||||
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||
process.removeAllListeners(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js';
|
||||
|
||||
test('AppImage config explicitly removes the staged engine payload', () => {
|
||||
assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), {
|
||||
bundle: {
|
||||
resources: {
|
||||
'engine-dist/': null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('AppImage bundling uses the existing binary instead of tauri build', () => {
|
||||
const args = appImageBundleArguments('x86_64-unknown-linux-gnu');
|
||||
assert.equal(args[3], 'bundle');
|
||||
assert.equal(args.includes('build'), false);
|
||||
assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]);
|
||||
});
|
||||
+426
-79
@@ -1,12 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
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 cargoOutputLimit = 64 * 1024 * 1024;
|
||||
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 +42,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,76 +94,235 @@ 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() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const releaseSection = html.split('Download Release Build')[1] || '';
|
||||
const match =
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
|
||||
return match?.[1];
|
||||
const card = releaseSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||
const version =
|
||||
card.match(/<b>Release:\s*<\/b>\s*([0-9.]+)/)?.[1] ||
|
||||
card.match(/Release:\s*([0-9.]+)/)?.[1];
|
||||
const relativeUrl = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
||||
if (!version || !relativeUrl) return undefined;
|
||||
const url = new URL(relativeUrl, 'https://ffmpeg.martin-riedl.de').href;
|
||||
const checksum = await fetchText(`${url}.sha256`);
|
||||
const sha256 = checksum.match(/\b([0-9a-f]{64})\b/i)?.[1]?.toLowerCase();
|
||||
return sha256 ? { version, url, sha256 } : undefined;
|
||||
}
|
||||
|
||||
async function latestMartinRiedlMacArm64Snapshot() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
|
||||
const card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||
const match =
|
||||
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
||||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
|
||||
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
||||
return match?.[1]
|
||||
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
|
||||
: undefined;
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function latestBtbnFfmpegN81Build() {
|
||||
async function latestBtbnFfmpegStableBuild(stableVersion) {
|
||||
if (!/^\d+\.\d+(?:\.\d+)?$/.test(stableVersion)) {
|
||||
throw new Error(`unsupported FFmpeg stable version: ${stableVersion}`);
|
||||
}
|
||||
const stableSeries = stableVersion.split('.').slice(0, 2).join('.');
|
||||
const versionPattern = escapeRegExp(stableVersion);
|
||||
const seriesPattern = escapeRegExp(stableSeries);
|
||||
const assetPattern = new RegExp(
|
||||
`^ffmpeg-n(${versionPattern}(?:-\\d+-g[0-9a-f]+)?)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$`
|
||||
);
|
||||
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
|
||||
if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array');
|
||||
for (const release of releases) {
|
||||
if (release.tag_name === 'latest') continue;
|
||||
const assets = (release.assets || [])
|
||||
.map(asset => {
|
||||
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
|
||||
const match = asset.name.match(assetPattern);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cargoPackages(metadata) {
|
||||
if (!metadata || !Array.isArray(metadata.packages)) {
|
||||
throw new Error('Cargo metadata contained no package list');
|
||||
}
|
||||
return metadata.packages.map(pkg => ({
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
source: pkg.source || 'path',
|
||||
}));
|
||||
}
|
||||
|
||||
function diffCargoMetadata(currentMetadata, updatedMetadata) {
|
||||
const current = cargoPackages(currentMetadata);
|
||||
const updated = cargoPackages(updatedMetadata);
|
||||
const groups = new Map();
|
||||
for (const [side, packages] of [['current', current], ['updated', updated]]) {
|
||||
for (const pkg of packages) {
|
||||
const key = `${pkg.name}\0${pkg.source}`;
|
||||
const group = groups.get(key) || { name: pkg.name, source: pkg.source, current: [], updated: [] };
|
||||
group[side].push(pkg.version);
|
||||
groups.set(key, group);
|
||||
}
|
||||
}
|
||||
|
||||
const changes = [];
|
||||
for (const group of groups.values()) {
|
||||
const oldVersions = [...group.current];
|
||||
const newVersions = [...group.updated];
|
||||
for (let index = oldVersions.length - 1; index >= 0; index -= 1) {
|
||||
const unchangedIndex = newVersions.indexOf(oldVersions[index]);
|
||||
if (unchangedIndex >= 0) {
|
||||
oldVersions.splice(index, 1);
|
||||
newVersions.splice(unchangedIndex, 1);
|
||||
}
|
||||
}
|
||||
oldVersions.sort(compareVersions);
|
||||
newVersions.sort(compareVersions);
|
||||
for (let index = 0; index < Math.min(oldVersions.length, newVersions.length); index += 1) {
|
||||
changes.push({
|
||||
name: group.name,
|
||||
version: oldVersions[index],
|
||||
latest: newVersions[index],
|
||||
source: group.source,
|
||||
});
|
||||
}
|
||||
for (const version of oldVersions.slice(newVersions.length)) {
|
||||
changes.push({
|
||||
name: group.name,
|
||||
version,
|
||||
latest: null,
|
||||
source: group.source,
|
||||
});
|
||||
}
|
||||
for (const latest of newVersions.slice(oldVersions.length)) {
|
||||
changes.push({
|
||||
name: group.name,
|
||||
version: null,
|
||||
latest,
|
||||
source: group.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function cargoMetadata(manifestPath) {
|
||||
return JSON.parse(execFileSync('cargo', [
|
||||
'metadata', '--format-version', '1', '--locked', '--manifest-path', manifestPath,
|
||||
], { encoding: 'utf8', maxBuffer: cargoOutputLimit, stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||
}
|
||||
|
||||
function cargoCompatibleUpdates() {
|
||||
const sourceDir = path.join(repoRoot, 'src-tauri');
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-cargo-update-'));
|
||||
try {
|
||||
fs.copyFileSync(path.join(sourceDir, 'Cargo.toml'), path.join(temporaryRoot, 'Cargo.toml'));
|
||||
fs.copyFileSync(path.join(sourceDir, 'Cargo.lock'), path.join(temporaryRoot, 'Cargo.lock'));
|
||||
fs.mkdirSync(path.join(temporaryRoot, 'src'));
|
||||
fs.writeFileSync(path.join(temporaryRoot, 'src', 'lib.rs'), '');
|
||||
const manifestPath = path.join(temporaryRoot, 'Cargo.toml');
|
||||
const current = cargoMetadata(path.join(sourceDir, 'Cargo.toml'));
|
||||
execFileSync('cargo', ['update', '--manifest-path', manifestPath], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: cargoOutputLimit,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return diffCargoMetadata(current, cargoMetadata(manifestPath));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function printCargoReport(updates) {
|
||||
if (!updates.length) {
|
||||
console.log('Rust Cargo: current');
|
||||
return 0;
|
||||
}
|
||||
console.log(`Rust Cargo: ${updates.length} compatible locked package update(s)`);
|
||||
for (const update of updates) {
|
||||
console.log(` ${update.name}: ${update.version ?? '(absent)'} -> ${update.latest ?? '(removed)'}`);
|
||||
}
|
||||
return updates.length;
|
||||
}
|
||||
|
||||
function printNpmReport(label, outdated) {
|
||||
const entries = Object.entries(outdated);
|
||||
if (!entries.length) {
|
||||
@@ -137,7 +340,14 @@ function sourceEngineVersions(sourceLock) {
|
||||
const rows = [];
|
||||
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(engines)) {
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url });
|
||||
rows.push({
|
||||
target,
|
||||
engine,
|
||||
version: meta.version,
|
||||
url: meta.url,
|
||||
sha256: meta.sha256,
|
||||
sourceSha256: meta.sourceSha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -147,26 +357,66 @@ 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,
|
||||
sourceSha256: meta.sourceSha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 checkedHash = row.sourceSha256 || row.sha256;
|
||||
const currentHash = typeof checkedHash === 'string' ? checkedHash.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(` source sha256: ${checkedHash || 'missing'} -> ${latestHash}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
@@ -179,73 +429,170 @@ async function main() {
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
outdatedCount += printCargoReport(cargoCompatibleUpdates());
|
||||
|
||||
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 ffmpegStablePromise = latestFfmpegStable();
|
||||
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 ffmpegStablePromise;
|
||||
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
],
|
||||
[
|
||||
'Martin Riedl macOS release',
|
||||
async () => {
|
||||
const build = await latestMartinRiedlMacArm64Release();
|
||||
const stableVersion = await ffmpegStablePromise;
|
||||
if (
|
||||
!build?.version ||
|
||||
!build.url ||
|
||||
!build.sha256 ||
|
||||
compareVersions(build.version, stableVersion) !== 0
|
||||
) {
|
||||
throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
],
|
||||
[
|
||||
'BtbN FFmpeg Windows/Linux build',
|
||||
async () => {
|
||||
const build = await latestBtbnFfmpegStableBuild(await ffmpegStablePromise);
|
||||
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 martinRiedlMacArm64Release = providerValue(4);
|
||||
const btbnFfmpegStableBuild = providerValue(5);
|
||||
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 (btbnFfmpegStableBuild?.version && btbnFfmpegStableBuild.urls?.windows && btbnFfmpegStableBuild.urls?.linux) {
|
||||
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.version;
|
||||
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.urls.windows;
|
||||
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.urls.linux;
|
||||
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.hashes?.windows;
|
||||
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.hashes?.linux;
|
||||
}
|
||||
if (martinRiedlMacArm64Release?.version && martinRiedlMacArm64Release.url) {
|
||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.version;
|
||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.url;
|
||||
latestHashesByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.sha256;
|
||||
}
|
||||
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
||||
|
||||
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 stable Windows/Linux: ${displayVersion(btbnFfmpegStableBuild?.version)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 stable: ${displayVersion(martinRiedlMacArm64Release?.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,
|
||||
diffCargoMetadata,
|
||||
fetchJson,
|
||||
fetchText,
|
||||
fetchWithContext,
|
||||
latestBtbnFfmpegStableBuild,
|
||||
latestMartinRiedlMacArm64Release,
|
||||
npmExecutable,
|
||||
providerAssetHashes,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
checkRows,
|
||||
diffCargoMetadata,
|
||||
fetchJson,
|
||||
fetchText,
|
||||
latestBtbnFfmpegStableBuild,
|
||||
latestMartinRiedlMacArm64Release,
|
||||
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 compares packaged source provenance without confusing the payload digest', () => {
|
||||
const outdated = checkRows(
|
||||
[{
|
||||
target: 'aarch64-apple-darwin',
|
||||
engine: 'ffmpeg',
|
||||
version: '9.0.1',
|
||||
url: 'https://example.test/ffmpeg.zip',
|
||||
sourceSha256: 'a'.repeat(64),
|
||||
sha256: 'b'.repeat(64),
|
||||
}],
|
||||
{ ffmpeg: '9.0.1' },
|
||||
{ 'aarch64-apple-darwin:ffmpeg': '9.0.1' },
|
||||
{ 'aarch64-apple-darwin:ffmpeg': 'https://example.test/ffmpeg.zip' },
|
||||
new Set(['ffmpeg']),
|
||||
{ 'aarch64-apple-darwin:ffmpeg': 'a'.repeat(64) },
|
||||
);
|
||||
|
||||
assert.equal(outdated, 0);
|
||||
});
|
||||
|
||||
test('checkRows detects an aria2 asset digest change when the provider supplies it', () => {
|
||||
const url = 'https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip';
|
||||
const digest = 'b'.repeat(64);
|
||||
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');
|
||||
});
|
||||
|
||||
test('selects a complete BtbN build for the current stable series', async () => {
|
||||
const digest = value => `sha256:${value.repeat(64)}`;
|
||||
const release = {
|
||||
tag_name: 'autobuild-test',
|
||||
assets: [
|
||||
{
|
||||
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
|
||||
browser_download_url: 'https://example.test/windows.zip',
|
||||
digest: digest('a'),
|
||||
},
|
||||
{
|
||||
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-linux64-gpl-9.0.tar.xz',
|
||||
browser_download_url: 'https://example.test/linux.tar.xz',
|
||||
digest: digest('b'),
|
||||
},
|
||||
{
|
||||
name: 'ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip',
|
||||
browser_download_url: 'https://example.test/old.zip',
|
||||
digest: digest('c'),
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await withMockFetch(
|
||||
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||
() => latestBtbnFfmpegStableBuild('9.0.1'),
|
||||
);
|
||||
|
||||
assert.equal(result.version, '9.0.1-11-ge47273f4d9');
|
||||
assert.equal(result.urls.windows, 'https://example.test/windows.zip');
|
||||
assert.equal(result.hashes.linux, 'b'.repeat(64));
|
||||
});
|
||||
|
||||
test('selects a complete BtbN build for a two-part stable version and tag-exact assets', async () => {
|
||||
const digest = value => `sha256:${value.repeat(64)}`;
|
||||
const release = {
|
||||
tag_name: 'autobuild-test',
|
||||
assets: [
|
||||
{
|
||||
name: 'ffmpeg-n9.0-win64-gpl-9.0.zip',
|
||||
browser_download_url: 'https://example.test/windows-exact.zip',
|
||||
digest: digest('e'),
|
||||
},
|
||||
{
|
||||
name: 'ffmpeg-n9.0-linux64-gpl-9.0.tar.xz',
|
||||
browser_download_url: 'https://example.test/linux-exact.tar.xz',
|
||||
digest: digest('f'),
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await withMockFetch(
|
||||
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||
() => latestBtbnFfmpegStableBuild('9.0'),
|
||||
);
|
||||
|
||||
assert.equal(result.version, '9.0');
|
||||
assert.equal(result.urls.windows, 'https://example.test/windows-exact.zip');
|
||||
assert.equal(result.hashes.linux, 'f'.repeat(64));
|
||||
});
|
||||
|
||||
test('rejects an incomplete BtbN stable target tuple', async () => {
|
||||
const release = {
|
||||
tag_name: 'autobuild-test',
|
||||
assets: [{
|
||||
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
|
||||
browser_download_url: 'https://example.test/windows.zip',
|
||||
digest: `sha256:${'a'.repeat(64)}`,
|
||||
}],
|
||||
};
|
||||
const result = await withMockFetch(
|
||||
async () => new Response(JSON.stringify([release]), { status: 200 }),
|
||||
() => latestBtbnFfmpegStableBuild('9.0.1'),
|
||||
);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test('requires a complete Martin Riedl stable artifact and digest', async () => {
|
||||
const html = `
|
||||
<h2>Download Release Build</h2>
|
||||
<div><h3>macOS (Apple Silicon/arm64)</h3>
|
||||
<p><b>Release: </b>9.0.1</p>
|
||||
<a href="/download/macos/arm64/build/ffmpeg.zip">FFmpeg (ZIP)</a></div>`;
|
||||
const result = await withMockFetch(
|
||||
async url => new Response(
|
||||
String(url).endsWith('.sha256') ? `${'d'.repeat(64)} ffmpeg.zip\n` : html,
|
||||
{ status: 200 },
|
||||
),
|
||||
() => latestMartinRiedlMacArm64Release(),
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
version: '9.0.1',
|
||||
url: 'https://ffmpeg.martin-riedl.de/download/macos/arm64/build/ffmpeg.zip',
|
||||
sha256: 'd'.repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
test('reports compatible Cargo resolution drift from structured metadata', () => {
|
||||
const metadata = versions => ({
|
||||
packages: Object.entries(versions).map(([name, version]) => ({
|
||||
name,
|
||||
version,
|
||||
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(
|
||||
diffCargoMetadata(metadata({ indexmap: '2.14.1', serde: '1.0.229' }), metadata({
|
||||
indexmap: '2.14.2',
|
||||
serde: '1.0.229',
|
||||
})),
|
||||
[{
|
||||
name: 'indexmap',
|
||||
version: '2.14.1',
|
||||
latest: '2.14.2',
|
||||
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||
}],
|
||||
);
|
||||
});
|
||||
|
||||
test('reports packages added to or removed from the resolved Cargo graph', () => {
|
||||
const metadata = packages => ({
|
||||
packages: packages.map(([name, version]) => ({
|
||||
name,
|
||||
version,
|
||||
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||
})),
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
diffCargoMetadata(
|
||||
metadata([['removed-crate', '1.0.0'], ['stable-crate', '1.0.0']]),
|
||||
metadata([['added-crate', '2.0.0'], ['stable-crate', '1.0.0']]),
|
||||
),
|
||||
[
|
||||
{
|
||||
name: 'added-crate',
|
||||
version: null,
|
||||
latest: '2.0.0',
|
||||
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||
},
|
||||
{
|
||||
name: 'removed-crate',
|
||||
version: '1.0.0',
|
||||
latest: null,
|
||||
source: 'registry+https://github.com/rust-lang/crates.io-index',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
|
||||
|
||||
export function getAria2BuildScriptSha256(repoRoot) {
|
||||
const buildScriptPath = path.join(repoRoot, 'scripts/aria2/build.sh');
|
||||
return crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(buildScriptPath, 'utf8').replaceAll('\r\n', '\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function validateAria2Cache({
|
||||
aria2CacheRoot,
|
||||
target,
|
||||
aria2Source,
|
||||
buildScriptSha256,
|
||||
toolchainFingerprint = null,
|
||||
executableSuffix = '',
|
||||
}) {
|
||||
const manifestPath = path.join(aria2CacheRoot, 'aria2-build-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return { valid: false, reason: 'manifest-not-found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (
|
||||
manifest.schemaVersion !== 1
|
||||
|| manifest.target !== target
|
||||
|| manifest.sourceSha256 !== aria2Source.sha256
|
||||
|| manifest.patchSha256 !== aria2Source.patchSha256
|
||||
|| manifest.buildScriptSha256 !== buildScriptSha256
|
||||
) {
|
||||
return { valid: false, reason: 'manifest-metadata-mismatch' };
|
||||
}
|
||||
|
||||
if (
|
||||
toolchainFingerprint !== null
|
||||
&& manifest.toolchainFingerprint !== toolchainFingerprint
|
||||
) {
|
||||
return { valid: false, reason: 'toolchain-fingerprint-mismatch' };
|
||||
}
|
||||
|
||||
const exeName = `aria2c-${target}${executableSuffix}`;
|
||||
const cachedExe = path.join(aria2CacheRoot, exeName);
|
||||
if (!fs.existsSync(cachedExe) || sha256(cachedExe) !== manifest.files?.[exeName]) {
|
||||
return { valid: false, reason: 'executable-mismatch' };
|
||||
}
|
||||
|
||||
if (manifest.files) {
|
||||
for (const [rel, expectedSha] of Object.entries(manifest.files)) {
|
||||
if (rel === exeName) continue;
|
||||
const libFile = path.join(aria2CacheRoot, rel);
|
||||
if (!fs.existsSync(libFile) || sha256(libFile) !== expectedSha) {
|
||||
return { valid: false, reason: 'library-mismatch' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actualFiles = collectRegularFiles(aria2CacheRoot, {
|
||||
ignoredNames: ['aria2-build-manifest.json'],
|
||||
}).map(f => path.relative(aria2CacheRoot, f).split(path.sep).join('/'));
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
return { valid: false, reason: 'file-list-mismatch' };
|
||||
}
|
||||
|
||||
return { valid: true, manifest };
|
||||
} catch {
|
||||
return { valid: false, reason: 'manifest-corrupted' };
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreAria2Cache({
|
||||
aria2CacheRoot,
|
||||
payloadDestination,
|
||||
target,
|
||||
executableSuffix = '',
|
||||
isWindows = false,
|
||||
}) {
|
||||
const exeName = `aria2c-${target}${executableSuffix}`;
|
||||
const cachedExe = path.join(aria2CacheRoot, exeName);
|
||||
const targetExe = path.join(payloadDestination, exeName);
|
||||
fs.copyFileSync(cachedExe, targetExe);
|
||||
if (!isWindows) fs.chmodSync(targetExe, 0o755);
|
||||
|
||||
const cachedLibs = path.join(aria2CacheRoot, 'aria2-libs');
|
||||
if (fs.existsSync(cachedLibs)) {
|
||||
fs.cpSync(cachedLibs, path.join(payloadDestination, 'aria2-libs'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAria2Cache({
|
||||
aria2CacheRoot,
|
||||
payloadDestination,
|
||||
target,
|
||||
aria2Source,
|
||||
buildScriptSha256,
|
||||
toolchainFingerprint = null,
|
||||
executableSuffix = '',
|
||||
aria2Runtime = null,
|
||||
isWindows = false,
|
||||
}) {
|
||||
const cacheParent = path.dirname(aria2CacheRoot);
|
||||
fs.mkdirSync(cacheParent, { recursive: true });
|
||||
|
||||
const stagingDir = fs.mkdtempSync(
|
||||
path.join(cacheParent, `.${path.basename(aria2CacheRoot)}-staging-${process.pid}-`)
|
||||
);
|
||||
|
||||
try {
|
||||
const targetExeName = `aria2c-${target}${executableSuffix}`;
|
||||
const cachedExeDest = path.join(stagingDir, targetExeName);
|
||||
fs.copyFileSync(path.join(payloadDestination, targetExeName), cachedExeDest);
|
||||
if (!isWindows) fs.chmodSync(cachedExeDest, 0o755);
|
||||
|
||||
const manifestFiles = {
|
||||
[targetExeName]: sha256(cachedExeDest),
|
||||
};
|
||||
|
||||
if (aria2Runtime && fs.existsSync(aria2Runtime)) {
|
||||
const cachedLibsDest = path.join(stagingDir, 'aria2-libs');
|
||||
fs.cpSync(aria2Runtime, cachedLibsDest, { recursive: true, preserveTimestamps: true });
|
||||
const libFiles = collectRegularFiles(cachedLibsDest);
|
||||
for (const lib of libFiles) {
|
||||
const rel = path.relative(stagingDir, lib).split(path.sep).join('/');
|
||||
manifestFiles[rel] = sha256(lib);
|
||||
}
|
||||
}
|
||||
|
||||
const cacheManifest = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
sourceSha256: aria2Source.sha256,
|
||||
patchSha256: aria2Source.patchSha256,
|
||||
buildScriptSha256,
|
||||
toolchainFingerprint: toolchainFingerprint || null,
|
||||
files: manifestFiles,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(stagingDir, 'aria2-build-manifest.json'),
|
||||
`${JSON.stringify(cacheManifest, null, 2)}\n`
|
||||
);
|
||||
|
||||
await promoteDirectory(stagingDir, aria2CacheRoot);
|
||||
} catch (error) {
|
||||
try {
|
||||
await removePathWithRetry(stagingDir);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAria2BuildScriptSha256,
|
||||
restoreAria2Cache,
|
||||
saveAria2Cache,
|
||||
validateAria2Cache,
|
||||
} from './engine-aria2-cache.js';
|
||||
import { sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const TARGET = 'x86_64-pc-windows-msvc';
|
||||
const ARIA2_SOURCE = {
|
||||
version: '1.37.0-firelink-native-dns-v1',
|
||||
url: 'https://example.invalid/aria2.tar.xz',
|
||||
sha256: 'a'.repeat(64),
|
||||
buildFromSource: true,
|
||||
patch: 'scripts/aria2/firelink.patch',
|
||||
patchSha256: 'b'.repeat(64),
|
||||
allocationTelemetry: true,
|
||||
};
|
||||
|
||||
function createTestWorkspace() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-cache-test-'));
|
||||
const payloadDir = path.join(root, 'payload');
|
||||
const cacheRoot = path.join(root, 'cache', TARGET);
|
||||
fs.mkdirSync(payloadDir, { recursive: true });
|
||||
|
||||
const exeName = `aria2c-${TARGET}.exe`;
|
||||
const exePath = path.join(payloadDir, exeName);
|
||||
fs.writeFileSync(exePath, 'binary-content-for-testing');
|
||||
|
||||
const libsDir = path.join(payloadDir, 'aria2-libs');
|
||||
fs.mkdirSync(libsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(libsDir, 'test.dll'), 'dll-content');
|
||||
|
||||
return { root, payloadDir, cacheRoot, exeName, exePath, libsDir };
|
||||
}
|
||||
|
||||
test('validateAria2Cache fails closed when cache manifest is missing or invalid', () => {
|
||||
const { root, cacheRoot } = createTestWorkspace();
|
||||
try {
|
||||
const missing = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: 'c'.repeat(64),
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(missing.valid, false);
|
||||
assert.equal(missing.reason, 'manifest-not-found');
|
||||
|
||||
fs.mkdirSync(cacheRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheRoot, 'aria2-build-manifest.json'), 'not json');
|
||||
const corrupt = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: 'c'.repeat(64),
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(corrupt.valid, false);
|
||||
assert.equal(corrupt.reason, 'manifest-corrupted');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('saveAria2Cache, validateAria2Cache, and restoreAria2Cache work end-to-end', async () => {
|
||||
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
|
||||
try {
|
||||
const buildScriptSha = 'c'.repeat(64);
|
||||
const toolchainFingerprint = 'toolchain-v1';
|
||||
|
||||
await saveAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
payloadDestination: payloadDir,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
toolchainFingerprint,
|
||||
executableSuffix: '.exe',
|
||||
aria2Runtime: libsDir,
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
const valid = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
toolchainFingerprint,
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(valid.valid, true);
|
||||
|
||||
const wrongFingerprint = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
toolchainFingerprint: 'toolchain-v2',
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(wrongFingerprint.valid, false);
|
||||
assert.equal(wrongFingerprint.reason, 'toolchain-fingerprint-mismatch');
|
||||
|
||||
const restoreDir = path.join(root, 'restored');
|
||||
fs.mkdirSync(restoreDir, { recursive: true });
|
||||
restoreAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
payloadDestination: restoreDir,
|
||||
target: TARGET,
|
||||
executableSuffix: '.exe',
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sha256(path.join(restoreDir, `aria2c-${TARGET}.exe`)),
|
||||
sha256(path.join(payloadDir, `aria2c-${TARGET}.exe`))
|
||||
);
|
||||
assert.equal(
|
||||
sha256(path.join(restoreDir, 'aria2-libs', 'test.dll')),
|
||||
sha256(path.join(payloadDir, 'aria2-libs', 'test.dll'))
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validateAria2Cache rejects tampered files or rogue untracked files', async () => {
|
||||
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
|
||||
try {
|
||||
const buildScriptSha = 'c'.repeat(64);
|
||||
await saveAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
payloadDestination: payloadDir,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
executableSuffix: '.exe',
|
||||
aria2Runtime: libsDir,
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
// Tamper with cached exe
|
||||
const exePath = path.join(cacheRoot, `aria2c-${TARGET}.exe`);
|
||||
fs.appendFileSync(exePath, 'tampered');
|
||||
const tamperedExe = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(tamperedExe.valid, false);
|
||||
assert.equal(tamperedExe.reason, 'executable-mismatch');
|
||||
|
||||
// Restore exe, tamper with library
|
||||
fs.writeFileSync(exePath, 'binary-content-for-testing');
|
||||
const libPath = path.join(cacheRoot, 'aria2-libs', 'test.dll');
|
||||
fs.appendFileSync(libPath, 'tampered');
|
||||
const tamperedLib = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(tamperedLib.valid, false);
|
||||
assert.equal(tamperedLib.reason, 'library-mismatch');
|
||||
|
||||
// Restore library, add rogue file
|
||||
fs.writeFileSync(libPath, 'dll-content');
|
||||
fs.writeFileSync(path.join(cacheRoot, 'rogue.txt'), 'rogue');
|
||||
const rogueFile = validateAria2Cache({
|
||||
aria2CacheRoot: cacheRoot,
|
||||
target: TARGET,
|
||||
aria2Source: ARIA2_SOURCE,
|
||||
buildScriptSha256: buildScriptSha,
|
||||
executableSuffix: '.exe',
|
||||
});
|
||||
assert.equal(rogueFile.valid, false);
|
||||
assert.equal(rogueFile.reason, 'file-list-mismatch');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getAria2BuildScriptSha256 reads and hashes build.sh with normalized line endings', () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
const hash = getAria2BuildScriptSha256(repoRoot);
|
||||
assert.equal(typeof hash, 'string');
|
||||
assert.equal(hash.length, 64);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
const ciWorkflow = fs.readFileSync(
|
||||
path.join(repositoryRoot, '.github', 'workflows', 'ci.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const cacheActionSha = '1bd1e32a3bdc45362d1e726936510720a7c30a57';
|
||||
|
||||
function assertSafeEngineCacheWorkflow(workflow) {
|
||||
assert.match(workflow, new RegExp(`actions/cache/restore@${cacheActionSha}`));
|
||||
assert.match(workflow, /key: firelink-engine-payload-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.fingerprint \}\}/);
|
||||
assert.match(workflow, /engine-sources\.lock\.json/);
|
||||
assert.match(workflow, /scripts\/aria2\/\*\*/);
|
||||
assert.match(workflow, /scripts\/engine-\*\.js/);
|
||||
assert.match(workflow, /scripts\/verify-binaries\.js/);
|
||||
assert.doesNotMatch(workflow, /restore-keys:/);
|
||||
|
||||
const restore = workflow.indexOf('actions/cache/restore@');
|
||||
const validation = workflow.indexOf('id: engine-cache-validation');
|
||||
const provision = workflow.indexOf('node scripts/provision-engines.js');
|
||||
assert.ok(restore >= 0 && restore < validation && validation < provision);
|
||||
assert.match(workflow, /continue-on-error: true/);
|
||||
assert.match(workflow, /FIRELINK_TARGET_TRIPLE: \$\{\{ matrix\.target \}\}/);
|
||||
}
|
||||
|
||||
test('CI and release restore only exact, validated engine payload caches', () => {
|
||||
assertSafeEngineCacheWorkflow(ciWorkflow);
|
||||
assertSafeEngineCacheWorkflow(releaseWorkflow);
|
||||
});
|
||||
|
||||
test('only trusted main pushes save the shared engine cache', () => {
|
||||
assert.match(ciWorkflow, new RegExp(`actions/cache/save@${cacheActionSha}`));
|
||||
const save = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified engine payload cache'));
|
||||
assert.match(save, /github\.event_name == 'push'/);
|
||||
assert.match(save, /github\.ref == 'refs\/heads\/main'/);
|
||||
assert.doesNotMatch(releaseWorkflow, /actions\/cache\/save@/);
|
||||
});
|
||||
|
||||
test('CI and release use granular Aria2 build caching and safe timeouts', () => {
|
||||
assert.match(ciWorkflow, /timeout-minutes: (?:4[5-9]|[5-9][0-9])/);
|
||||
assert.match(ciWorkflow, /uses: Swatinem\/rust-cache@v2/);
|
||||
assert.match(ciWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
|
||||
assert.match(releaseWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
|
||||
const saveAria2 = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified Aria2 build cache'));
|
||||
assert.match(saveAria2, /github\.event_name == 'push'/);
|
||||
assert.match(saveAria2, /github\.ref == 'refs\/heads\/main'/);
|
||||
});
|
||||
@@ -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,256 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects and removes an archive with a mismatched checksum', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const corrupt = Buffer.from('corrupt engine archive');
|
||||
|
||||
try {
|
||||
await withMockFetch(
|
||||
async () => new Response(corrupt, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(corrupt.length) },
|
||||
}),
|
||||
async () => {
|
||||
await assert.rejects(
|
||||
downloadEngineArchive({
|
||||
name: 'ffmpeg',
|
||||
url: 'https://example.test/ffmpeg.zip',
|
||||
archive,
|
||||
expectedSha256: digest(Buffer.from('trusted engine archive')),
|
||||
attempts: 1,
|
||||
}),
|
||||
/Archive checksum mismatch for ffmpeg/,
|
||||
);
|
||||
},
|
||||
);
|
||||
assert.equal(fs.existsSync(archive), false);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map(key => [key, canonicalize(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildPayloadProvenance(targetSources) {
|
||||
if (!targetSources || typeof targetSources !== 'object') {
|
||||
throw new Error('Engine source lock is missing the target provenance.');
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(targetSources).map(([name, source]) => [
|
||||
name,
|
||||
{
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256,
|
||||
...(source.buildFromSource === true
|
||||
? {
|
||||
patchSha256: source.patchSha256,
|
||||
allocationTelemetry: source.allocationTelemetry === true,
|
||||
}
|
||||
: {}),
|
||||
...(name === 'aria2c' && source.firelinkRouteContract
|
||||
? { firelinkRouteContract: source.firelinkRouteContract }
|
||||
: {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function assertPayloadManifestProvenance(manifest, targetSources, target) {
|
||||
if (manifest?.schemaVersion !== 1) {
|
||||
throw new Error(`Unsupported engine payload manifest schema for ${target}.`);
|
||||
}
|
||||
if (manifest.target !== target) {
|
||||
throw new Error(`Engine payload manifest target mismatch for ${target}.`);
|
||||
}
|
||||
|
||||
const expected = buildPayloadProvenance(targetSources);
|
||||
if (JSON.stringify(canonicalize(manifest.generatedFrom))
|
||||
!== JSON.stringify(canonicalize(expected))) {
|
||||
throw new Error(`Engine payload manifest provenance mismatch for ${target}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManifestFile(root, relative) {
|
||||
if (typeof relative !== 'string' || relative.length === 0) {
|
||||
throw new Error('Engine payload manifest contains an invalid file path.');
|
||||
}
|
||||
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const candidate = path.resolve(resolvedRoot, relative);
|
||||
const relativeToRoot = path.relative(resolvedRoot, candidate);
|
||||
if (
|
||||
relativeToRoot === '..'
|
||||
|| relativeToRoot.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relativeToRoot)
|
||||
) {
|
||||
throw new Error(`Engine payload manifest escapes its root: ${relative}.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function readAndValidatePayloadManifest(root, targetSources, target) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Engine payload manifest is missing for ${target}.`);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Engine payload manifest is invalid for ${target}: ${error.message}`);
|
||||
}
|
||||
|
||||
assertPayloadManifestProvenance(manifest, targetSources, target);
|
||||
if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
|
||||
throw new Error(`Engine payload manifest files are invalid for ${target}.`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(manifest.files).sort();
|
||||
const resolvedFiles = new Map(
|
||||
expectedFiles.map(relative => [relative, resolveManifestFile(root, relative)]),
|
||||
);
|
||||
const actualFiles = collectRegularFiles(root, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
throw new Error(`Engine payload files do not match the manifest for ${target}.`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const expected = manifest.files[relative];
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) {
|
||||
throw new Error(`Engine payload manifest checksum is invalid: ${relative}.`);
|
||||
}
|
||||
const file = resolvedFiles.get(relative);
|
||||
if (!fs.statSync(file).isFile() || sha256(file) !== expected) {
|
||||
throw new Error(`Engine payload manifest checksum mismatch: ${relative}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertPayloadManifestProvenance,
|
||||
buildPayloadProvenance,
|
||||
readAndValidatePayloadManifest,
|
||||
} from './engine-payload-manifest.js';
|
||||
import { sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const TARGET = 'x86_64-unknown-linux-gnu';
|
||||
const SOURCES = {
|
||||
'yt-dlp': {
|
||||
version: '2026.08.19',
|
||||
url: 'https://example.invalid/yt-dlp.zip',
|
||||
sha256: 'a'.repeat(64),
|
||||
},
|
||||
deno: {
|
||||
version: '2.9.6',
|
||||
url: 'https://example.invalid/deno.zip',
|
||||
sha256: 'b'.repeat(64),
|
||||
},
|
||||
ffmpeg: {
|
||||
version: '9.0.1',
|
||||
url: 'https://example.invalid/ffmpeg.tar.xz',
|
||||
sha256: 'c'.repeat(64),
|
||||
},
|
||||
aria2c: {
|
||||
version: '1.37.0-firelink-native-dns-v1',
|
||||
url: 'https://example.invalid/aria2.tar.xz',
|
||||
sha256: 'd'.repeat(64),
|
||||
buildFromSource: true,
|
||||
patchSha256: 'e'.repeat(64),
|
||||
allocationTelemetry: true,
|
||||
firelinkRouteContract: {
|
||||
revision: 'firelink-native-dns-v1',
|
||||
dnsResolver: 'native-async',
|
||||
networkTargetPolicy: 'firelink-v1',
|
||||
networkTargetPolicyDigest: 'sha256:test',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function createPayload() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-manifest-'));
|
||||
const file = path.join(root, 'aria2c');
|
||||
fs.writeFileSync(file, 'verified engine');
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
target: TARGET,
|
||||
generatedFrom: buildPayloadProvenance(SOURCES),
|
||||
files: { aria2c: sha256(file) },
|
||||
};
|
||||
fs.writeFileSync(path.join(root, 'payload-manifest.json'), `${JSON.stringify(manifest)}\n`);
|
||||
return { root, manifest };
|
||||
}
|
||||
|
||||
test('payload manifest validation binds files and source provenance', () => {
|
||||
const { root, manifest } = createPayload();
|
||||
try {
|
||||
assert.deepEqual(readAndValidatePayloadManifest(root, SOURCES, TARGET), manifest);
|
||||
assert.doesNotThrow(() => assertPayloadManifestProvenance(manifest, SOURCES, TARGET));
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('payload manifest validation rejects changed provenance and path traversal', () => {
|
||||
const { root, manifest } = createPayload();
|
||||
try {
|
||||
const changed = { ...manifest, generatedFrom: { ...manifest.generatedFrom } };
|
||||
changed.generatedFrom.aria2c = {
|
||||
...changed.generatedFrom.aria2c,
|
||||
patchSha256: 'f'.repeat(64),
|
||||
};
|
||||
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(changed));
|
||||
assert.throws(
|
||||
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
|
||||
/provenance mismatch/,
|
||||
);
|
||||
|
||||
const traversal = {
|
||||
...manifest,
|
||||
files: { '../outside': '0'.repeat(64) },
|
||||
};
|
||||
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(traversal));
|
||||
assert.throws(
|
||||
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
|
||||
/escapes its root|files do not match/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getAria2BuildScriptSha256 } from './engine-aria2-cache.js';
|
||||
|
||||
const WINDOWS_PACKAGES = [
|
||||
'autoconf',
|
||||
'automake',
|
||||
'libtool',
|
||||
'gettext-devel',
|
||||
'pkgconf',
|
||||
'make',
|
||||
'patch',
|
||||
'binutils',
|
||||
'mingw-w64-x86_64-gcc',
|
||||
'mingw-w64-x86_64-binutils',
|
||||
'mingw-w64-x86_64-pkgconf',
|
||||
'mingw-w64-x86_64-openssl',
|
||||
'mingw-w64-x86_64-libssh2',
|
||||
'mingw-w64-x86_64-c-ares',
|
||||
'mingw-w64-x86_64-expat',
|
||||
'mingw-w64-x86_64-sqlite3',
|
||||
'mingw-w64-x86_64-zlib',
|
||||
];
|
||||
|
||||
const LINUX_PACKAGES = [
|
||||
'gcc',
|
||||
'g++',
|
||||
'make',
|
||||
'patch',
|
||||
'binutils',
|
||||
'autoconf',
|
||||
'automake',
|
||||
'libtool',
|
||||
'gettext',
|
||||
'autopoint',
|
||||
'pkg-config',
|
||||
'libssl-dev',
|
||||
'libssh2-1-dev',
|
||||
'libgcrypt20-dev',
|
||||
'libc-ares-dev',
|
||||
'libexpat1-dev',
|
||||
'libsqlite3-dev',
|
||||
'zlib1g-dev',
|
||||
];
|
||||
|
||||
function run(command, args) {
|
||||
try {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).replaceAll('\r\n', '\n').trim();
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.toString().trim() || error.message;
|
||||
throw new Error(`Could not fingerprint the engine toolchain with ${command}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOutput(name, value) {
|
||||
const line = `${name}=${value}\n`;
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, line);
|
||||
} else {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = process.env.FIRELINK_TARGET_TRIPLE;
|
||||
if (!target) throw new Error('FIRELINK_TARGET_TRIPLE is required.');
|
||||
|
||||
const records = [`target=${target}`];
|
||||
if (process.platform === 'win32') {
|
||||
const msysRoot = process.env.FIRELINK_MSYS2_ROOT;
|
||||
if (!msysRoot) throw new Error('FIRELINK_MSYS2_ROOT is required on Windows.');
|
||||
const bash = path.join(msysRoot, 'usr', 'bin', 'bash.exe');
|
||||
const packages = WINDOWS_PACKAGES.join(' ');
|
||||
records.push(`msys2-packages=${run(bash, ['-lc', `pacman -Q ${packages}`])}`);
|
||||
} else if (process.platform === 'linux') {
|
||||
records.push(`debian-packages=${run('dpkg-query', [
|
||||
'-W',
|
||||
'-f=${binary:Package}=${Version}\\n',
|
||||
...LINUX_PACKAGES,
|
||||
])}`);
|
||||
for (const [command, args] of [
|
||||
['gcc', ['--version']],
|
||||
['make', ['--version']],
|
||||
['autoconf', ['--version']],
|
||||
['automake', ['--version']],
|
||||
['pkg-config', ['--version']],
|
||||
]) {
|
||||
records.push(`${command}=${run(command, args).split('\n', 1)[0]}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported engine toolchain host: ${process.platform}`);
|
||||
}
|
||||
|
||||
const fingerprint = crypto.createHash('sha256').update(records.join('\n')).digest('hex');
|
||||
writeOutput('fingerprint', fingerprint);
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourceLockPath = path.join(repoRoot, 'engine-sources.lock.json');
|
||||
if (fs.existsSync(sourceLockPath)) {
|
||||
const sourceLock = JSON.parse(fs.readFileSync(sourceLockPath, 'utf8'));
|
||||
const aria2Source = sourceLock.targets?.[target]?.aria2c;
|
||||
if (aria2Source) {
|
||||
const canonicalize = val => {
|
||||
if (Array.isArray(val)) return val.map(canonicalize);
|
||||
if (val && typeof val === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(val).sort().map(k => [k, canonicalize(val[k])])
|
||||
);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
const patchPath = path.join(repoRoot, aria2Source.patch || 'scripts/aria2/firelink.patch');
|
||||
const buildShPath = path.join(repoRoot, 'scripts/aria2/build.sh');
|
||||
const patchSha = fs.existsSync(patchPath)
|
||||
? crypto.createHash('sha256').update(fs.readFileSync(patchPath, 'utf8').replaceAll('\r\n', '\n')).digest('hex')
|
||||
: (aria2Source.patchSha256 || '');
|
||||
const buildShSha = fs.existsSync(buildShPath)
|
||||
? getAria2BuildScriptSha256(repoRoot)
|
||||
: '';
|
||||
|
||||
const aria2Records = [
|
||||
...records,
|
||||
`aria2-source=${JSON.stringify(canonicalize(aria2Source))}`,
|
||||
`aria2-patch-sha256=${patchSha}`,
|
||||
`aria2-build-sh-sha256=${buildShSha}`,
|
||||
];
|
||||
const aria2Fingerprint = crypto.createHash('sha256').update(aria2Records.join('\n')).digest('hex');
|
||||
writeOutput('aria2-fingerprint', aria2Fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { removePathWithRetry } from './engine-payload-promotion.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const PLATFORM_MAP = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
const SAFE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
function argumentValue(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0) return args[index + 1];
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find(argument => argument.startsWith(prefix));
|
||||
return inline?.slice(prefix.length);
|
||||
}
|
||||
|
||||
export function assertSafeTarget(target) {
|
||||
if (typeof target !== 'string' || !SAFE_TARGET_PATTERN.test(target)) {
|
||||
throw new Error(`Invalid target triple: ${target ?? '<missing>'}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function resolveTargetTriple(
|
||||
args = process.argv.slice(2),
|
||||
env = process.env,
|
||||
platform = os.platform(),
|
||||
arch = os.arch(),
|
||||
) {
|
||||
const hostTarget = ARCH_MAP[arch] && PLATFORM_MAP[platform]
|
||||
? `${ARCH_MAP[arch]}-${PLATFORM_MAP[platform]}`
|
||||
: undefined;
|
||||
const target = argumentValue(args, '--target')
|
||||
|| env.TAURI_ENV_TARGET_TRIPLE
|
||||
|| env.FIRELINK_TARGET_TRIPLE
|
||||
|| hostTarget;
|
||||
return assertSafeTarget(target);
|
||||
}
|
||||
|
||||
export function resolveOutputRoot(args = process.argv.slice(2), env = process.env) {
|
||||
const outputRoot = argumentValue(args, '--output-root') || env.FIRELINK_ENGINE_OUTPUT_ROOT;
|
||||
if (!outputRoot) {
|
||||
throw new Error(
|
||||
'No engine output workspace was provided. Run through npm run tauri or set FIRELINK_ENGINE_OUTPUT_ROOT.',
|
||||
);
|
||||
}
|
||||
return path.resolve(outputRoot);
|
||||
}
|
||||
|
||||
function canonicalPathWithMissingComponents(value) {
|
||||
let cursor = path.resolve(value);
|
||||
const missing = [];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const canonical = fs.realpathSync.native(cursor);
|
||||
return path.join(canonical, ...missing.reverse());
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) throw error;
|
||||
missing.push(path.basename(cursor));
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function comparablePath(value) {
|
||||
const normalized = path.normalize(value);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function isWithinPath(root, candidate) {
|
||||
const relative = path.relative(comparablePath(root), comparablePath(candidate));
|
||||
return relative === ''
|
||||
|| (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function assertSafeOutputRoot(outputRoot, forbiddenRoots = []) {
|
||||
const canonicalOutputRoot = canonicalPathWithMissingComponents(outputRoot);
|
||||
for (const forbiddenRoot of forbiddenRoots) {
|
||||
const canonicalForbiddenRoot = canonicalPathWithMissingComponents(forbiddenRoot);
|
||||
if (isWithinPath(canonicalForbiddenRoot, canonicalOutputRoot)) {
|
||||
throw new Error(
|
||||
`Refusing to use a repository-shared engine workspace: ${outputRoot}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return canonicalOutputRoot;
|
||||
}
|
||||
|
||||
export function stripVerbatimPrefix(filePath) {
|
||||
return typeof filePath === 'string' && filePath.startsWith('\\\\?\\')
|
||||
? filePath.slice(4)
|
||||
: filePath;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTempBase(referencePath = repoRoot) {
|
||||
if (process.env.FIRELINK_ENGINE_WORKSPACE_BASE) {
|
||||
return path.resolve(process.env.FIRELINK_ENGINE_WORKSPACE_BASE);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const referenceDrive = path.parse(path.resolve(referencePath)).root.toLowerCase();
|
||||
|
||||
if (process.env.RUNNER_TEMP) {
|
||||
const runnerTemp = path.resolve(process.env.RUNNER_TEMP);
|
||||
if (path.parse(runnerTemp).root.toLowerCase() === referenceDrive) {
|
||||
return runnerTemp;
|
||||
}
|
||||
}
|
||||
|
||||
const osTemp = path.resolve(os.tmpdir());
|
||||
if (path.parse(osTemp).root.toLowerCase() === referenceDrive) {
|
||||
return osTemp;
|
||||
}
|
||||
|
||||
const adjacentTemp = path.join(path.resolve(referencePath, '..'), '.firelink-engine-workspaces');
|
||||
fs.mkdirSync(adjacentTemp, { recursive: true, mode: 0o700 });
|
||||
return adjacentTemp;
|
||||
}
|
||||
|
||||
if (process.env.RUNNER_TEMP) {
|
||||
return path.resolve(process.env.RUNNER_TEMP);
|
||||
}
|
||||
|
||||
return os.tmpdir();
|
||||
}
|
||||
|
||||
export function createEngineWorkspace(target) {
|
||||
assertSafeTarget(target);
|
||||
const tempBase = resolveWorkspaceTempBase();
|
||||
const rawWorkspace = fs.realpathSync.native(
|
||||
fs.mkdtempSync(path.join(tempBase, `firelink-engine-${target}-${process.pid}-`)),
|
||||
);
|
||||
const workspace = stripVerbatimPrefix(rawWorkspace);
|
||||
const outputRoot = path.join(workspace, 'engine-dist');
|
||||
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
|
||||
return { outputRoot, runtimeRoot: outputRoot, workspace };
|
||||
}
|
||||
|
||||
export function engineResourceConfig(outputRoot) {
|
||||
const source = `${stripVerbatimPrefix(path.resolve(outputRoot))}${path.sep}`;
|
||||
return JSON.stringify({
|
||||
bundle: {
|
||||
resources: {
|
||||
[source]: 'engine-dist/',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeEngineWorkspace(workspace) {
|
||||
const resolved = stripVerbatimPrefix(path.resolve(workspace));
|
||||
const basename = path.basename(resolved);
|
||||
if (!basename.startsWith('firelink-engine-')) {
|
||||
throw new Error(`Refusing to remove an unexpected engine workspace: ${resolved}`);
|
||||
}
|
||||
await removePathWithRetry(resolved);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertSafeTarget,
|
||||
assertSafeOutputRoot,
|
||||
createEngineWorkspace,
|
||||
engineResourceConfig,
|
||||
removeEngineWorkspace,
|
||||
resolveOutputRoot,
|
||||
resolveTargetTriple,
|
||||
resolveWorkspaceTempBase,
|
||||
stripVerbatimPrefix,
|
||||
} from './engine-workspace.js';
|
||||
|
||||
test('target resolution accepts explicit and inline target arguments', () => {
|
||||
assert.equal(
|
||||
resolveTargetTriple(['--target', 'x86_64-unknown-linux-gnu'], {}, 'darwin', 'arm64'),
|
||||
'x86_64-unknown-linux-gnu',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTargetTriple(['--target=x86_64-pc-windows-msvc'], {}, 'darwin', 'arm64'),
|
||||
'x86_64-pc-windows-msvc',
|
||||
);
|
||||
});
|
||||
|
||||
test('target validation rejects path traversal before filesystem use', () => {
|
||||
assert.throws(() => assertSafeTarget('../outside'), /Invalid target triple/);
|
||||
assert.throws(
|
||||
() => resolveTargetTriple(['--target', 'x86_64/../../outside'], {}, 'darwin', 'arm64'),
|
||||
/Invalid target triple/,
|
||||
);
|
||||
});
|
||||
|
||||
test('engine workspaces are unique and produce an absolute Tauri resource mapping', async () => {
|
||||
const first = createEngineWorkspace('aarch64-apple-darwin');
|
||||
const second = createEngineWorkspace('aarch64-apple-darwin');
|
||||
try {
|
||||
assert.notEqual(first.workspace, second.workspace);
|
||||
assert.equal(fs.statSync(first.outputRoot).isDirectory(), true);
|
||||
const config = JSON.parse(engineResourceConfig(first.outputRoot));
|
||||
assert.equal(
|
||||
config.bundle.resources[`${path.resolve(first.outputRoot)}${path.sep}`],
|
||||
'engine-dist/',
|
||||
);
|
||||
} finally {
|
||||
await removeEngineWorkspace(first.workspace);
|
||||
await removeEngineWorkspace(second.workspace);
|
||||
}
|
||||
});
|
||||
|
||||
test('staging requires an explicit private output workspace', () => {
|
||||
assert.throws(() => resolveOutputRoot([], {}), /No engine output workspace/);
|
||||
assert.equal(
|
||||
resolveOutputRoot(['--output-root', '/tmp/firelink-engine-run'], {}).endsWith(
|
||||
path.join('firelink-engine-run'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shared repository output roots and descendants are rejected', () => {
|
||||
const repoRoot = path.resolve('/repo');
|
||||
assert.throws(
|
||||
() => assertSafeOutputRoot('/repo/src-tauri/engine-dist/target', [
|
||||
repoRoot,
|
||||
path.join(repoRoot, 'src-tauri'),
|
||||
path.join(repoRoot, 'src-tauri', 'engine-dist'),
|
||||
]),
|
||||
/repository-shared engine workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
test('output roots are checked after resolving symlinked parents', () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-workspace-test-'));
|
||||
const sharedRoot = path.join(temporaryRoot, 'shared');
|
||||
const linkedRoot = path.join(temporaryRoot, 'linked');
|
||||
try {
|
||||
fs.mkdirSync(path.join(sharedRoot, 'src-tauri', 'engine-dist'), { recursive: true });
|
||||
fs.symlinkSync(sharedRoot, linkedRoot, 'dir');
|
||||
assert.throws(
|
||||
() => assertSafeOutputRoot(path.join(linkedRoot, 'src-tauri', 'engine-dist', 'target'), [
|
||||
sharedRoot,
|
||||
path.join(sharedRoot, 'src-tauri'),
|
||||
path.join(sharedRoot, 'src-tauri', 'engine-dist'),
|
||||
]),
|
||||
/repository-shared engine workspace/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stripVerbatimPrefix removes Win32 verbatim namespaces', () => {
|
||||
assert.equal(stripVerbatimPrefix('\\\\?\\D:\\a\\_temp'), 'D:\\a\\_temp');
|
||||
assert.equal(stripVerbatimPrefix('D:\\a\\_temp'), 'D:\\a\\_temp');
|
||||
assert.equal(stripVerbatimPrefix('/tmp/firelink'), '/tmp/firelink');
|
||||
});
|
||||
|
||||
test('resolveWorkspaceTempBase honors FIRELINK_ENGINE_WORKSPACE_BASE override', () => {
|
||||
const custom = path.resolve('/custom/engine/base');
|
||||
const prev = process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
|
||||
process.env.FIRELINK_ENGINE_WORKSPACE_BASE = custom;
|
||||
try {
|
||||
assert.equal(resolveWorkspaceTempBase(), custom);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
|
||||
else process.env.FIRELINK_ENGINE_WORKSPACE_BASE = prev;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function run(script, args) {
|
||||
const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', script), ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[FAIL] Could not run ${script}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
run('stage-engines.js', []);
|
||||
run('verify-binaries.js', ['--staged']);
|
||||
+169
-113
@@ -1,15 +1,29 @@
|
||||
#!/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 { buildPayloadProvenance } from './engine-payload-manifest.js';
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
import { assertAria2RouteSource } from './aria2-route-contract.js';
|
||||
import {
|
||||
getAria2BuildScriptSha256,
|
||||
restoreAria2Cache,
|
||||
saveAria2Cache,
|
||||
validateAria2Cache,
|
||||
} from './engine-aria2-cache.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
@@ -33,112 +47,71 @@ if (!targetSources) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
if (targetSources.aria2c?.firelinkRouteContract) {
|
||||
try {
|
||||
assertAria2RouteSource(targetSources.aria2c, target);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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 destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
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}`));
|
||||
}
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
signalHandlers.set(signalName, handler);
|
||||
process.on(signalName, handler);
|
||||
}
|
||||
|
||||
let temporary;
|
||||
let payloadDestination;
|
||||
|
||||
function throwIfProvisioningAborted() {
|
||||
if (provisioningAbortController.signal.aborted) {
|
||||
throw provisioningAbortController.signal.reason;
|
||||
}
|
||||
}
|
||||
|
||||
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,51 +133,51 @@ 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 = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
generatedFrom: Object.fromEntries(
|
||||
Object.entries(targetSources).map(([name, source]) => [
|
||||
name,
|
||||
{
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256
|
||||
}
|
||||
])
|
||||
),
|
||||
generatedFrom: buildPayloadProvenance(targetSources),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(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
|
||||
});
|
||||
@@ -215,11 +188,94 @@ try {
|
||||
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
const aria2Source = targetSources.aria2c;
|
||||
if (aria2Source.buildFromSource !== true || aria2Source.allocationTelemetry !== true) {
|
||||
throw new Error('Aria2 provisioning requires the allocation telemetry source build.');
|
||||
}
|
||||
const patchFile = path.join(repoRoot, aria2Source.patch);
|
||||
if (sha256(patchFile) !== aria2Source.patchSha256) throw new Error('Aria2 source patch checksum mismatch');
|
||||
|
||||
const aria2CacheRoot = process.env.FIRELINK_ARIA2_CACHE_DIR
|
||||
|| path.join(destinationParent, '.aria2-cache', target);
|
||||
const buildScriptSha256 = getAria2BuildScriptSha256(repoRoot);
|
||||
const toolchainFingerprint = process.env.FIRELINK_TOOLCHAIN_FINGERPRINT || null;
|
||||
|
||||
const cacheValidation = validateAria2Cache({
|
||||
aria2CacheRoot,
|
||||
target,
|
||||
aria2Source,
|
||||
buildScriptSha256,
|
||||
toolchainFingerprint,
|
||||
executableSuffix,
|
||||
});
|
||||
|
||||
if (cacheValidation.valid) {
|
||||
restoreAria2Cache({
|
||||
aria2CacheRoot,
|
||||
payloadDestination,
|
||||
target,
|
||||
executableSuffix,
|
||||
isWindows,
|
||||
});
|
||||
console.log(`Reused cached Aria2 build from ${aria2CacheRoot}`);
|
||||
} else {
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
const sourceRoots = fs.readdirSync(aria2, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(aria2, entry.name, 'configure.ac')))
|
||||
.map(entry => path.join(aria2, entry.name));
|
||||
if (sourceRoots.length !== 1) throw new Error('Aria2 archive must contain exactly one source root');
|
||||
const [sourceRoot] = sourceRoots;
|
||||
const bash = isWindows ? path.join(process.env.FIRELINK_MSYS2_ROOT || 'C:/msys64', 'usr/bin/bash.exe') : 'bash';
|
||||
await execFileAsync(bash, [path.join(repoRoot, 'scripts/aria2/build.sh').replaceAll('\\', '/'), sourceRoot, patchFile], {
|
||||
signal: provisioningAbortController.signal,
|
||||
env: { ...process.env, ...(isWindows ? { MSYSTEM: 'MINGW64' } : {}) },
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
copyExecutable(path.join(sourceRoot, 'firelink-build', 'src', `aria2c${executableSuffix}`), 'aria2c');
|
||||
const aria2Runtime = path.join(sourceRoot, 'aria2-libs');
|
||||
if (fs.existsSync(aria2Runtime)) {
|
||||
fs.cpSync(aria2Runtime, path.join(payloadDestination, 'aria2-libs'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await saveAria2Cache({
|
||||
aria2CacheRoot,
|
||||
payloadDestination,
|
||||
target,
|
||||
aria2Source,
|
||||
buildScriptSha256,
|
||||
toolchainFingerprint,
|
||||
executableSuffix,
|
||||
aria2Runtime,
|
||||
isWindows,
|
||||
});
|
||||
console.log(`Saved built Aria2 cache to ${aria2CacheRoot}`);
|
||||
} catch (cacheError) {
|
||||
console.warn(`Could not save Aria2 build cache: ${cacheError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
writePayloadManifest();
|
||||
throwIfProvisioningAborted();
|
||||
await promoteDirectory(payloadDestination, destination);
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
if (temporary) {
|
||||
try {
|
||||
await removePathWithRetry(temporary);
|
||||
} catch (cleanupError) {
|
||||
if (provisioningAbortController.signal.aborted) {
|
||||
console.warn(`Could not remove temporary directory during abort: ${cleanupError.message}`);
|
||||
} else {
|
||||
throw cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [signalName, handler] of signalHandlers) {
|
||||
process.removeListener(signalName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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/);
|
||||
});
|
||||
|
||||
test('release workflow normalizes all 6 distribution target artifacts', () => {
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
|
||||
});
|
||||
|
||||
test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
|
||||
assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
|
||||
assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
|
||||
assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
|
||||
assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/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';
|
||||
import {
|
||||
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
ARIA2_SYSTEM_RESOLVER_OPTIONS,
|
||||
ARIA2_ROUTE_OPTIONS,
|
||||
assertAria2Baseline,
|
||||
assertAria2RouteOptions,
|
||||
assertAria2SystemResolverOptions,
|
||||
hasAria2RouteCapabilities,
|
||||
} from './aria2-route-contract.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
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]
|
||||
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
|
||||
? path.join(
|
||||
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
|
||||
targetTriple,
|
||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
)
|
||||
: path.join(
|
||||
repoRoot,
|
||||
'src-tauri',
|
||||
'binaries',
|
||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
}
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function forceRemoveIfPresent(port, secret, gid) {
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.forceRemove', [gid]);
|
||||
} catch (error) {
|
||||
if (!/not found|no such download|active download not found/i.test(error.message)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
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 pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--rpc-secret=${secret}`,
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
assertAria2Baseline(version);
|
||||
const routeCapabilitiesAvailable = hasAria2RouteCapabilities(version);
|
||||
if (routeCapabilitiesAvailable) {
|
||||
// The fixture server is intentionally loopback. Disable only the custom
|
||||
// target policy for this smoke daemon after capabilities are attested.
|
||||
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; optional Firelink route capabilities available`);
|
||||
} else {
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; using stock system-resolver capabilities`);
|
||||
}
|
||||
const systemFixtureOptions = routeCapabilitiesAvailable
|
||||
? { ...ARIA2_SYSTEM_RESOLVER_OPTIONS, 'network-target-policy': 'none' }
|
||||
: { ...ARIA2_SYSTEM_RESOLVER_OPTIONS };
|
||||
|
||||
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
||||
...systemFixtureOptions,
|
||||
out: 'resolver-normal.bin',
|
||||
}]);
|
||||
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
||||
assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri');
|
||||
|
||||
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, [], {
|
||||
...systemFixtureOptions,
|
||||
dir: tempRoot,
|
||||
}]);
|
||||
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
||||
assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent');
|
||||
|
||||
const proxyRoute = 'http://127.0.0.1:9';
|
||||
const normalizedProxyRoute = new URL(proxyRoute).toString();
|
||||
const proxiedUriResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/file'], {
|
||||
...systemFixtureOptions,
|
||||
'all-proxy': proxyRoute,
|
||||
pause: 'true',
|
||||
out: 'resolver-proxied.bin',
|
||||
}]);
|
||||
const proxiedUriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedUriResult]);
|
||||
if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) {
|
||||
throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`);
|
||||
}
|
||||
assertAria2SystemResolverOptions(proxiedUriOptions, 'proxied aria2.addUri');
|
||||
|
||||
const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||
...systemFixtureOptions,
|
||||
'all-proxy': proxyRoute,
|
||||
pause: 'true',
|
||||
dir: tempRoot,
|
||||
}]);
|
||||
const proxiedTorrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedTorrentResult]);
|
||||
if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) {
|
||||
throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`);
|
||||
}
|
||||
assertAria2SystemResolverOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent');
|
||||
|
||||
if (routeCapabilitiesAvailable) {
|
||||
const alternateResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/alternate'], {
|
||||
...ARIA2_ROUTE_OPTIONS,
|
||||
pause: 'true',
|
||||
out: 'resolver-alternate.bin',
|
||||
}]);
|
||||
const alternateOptions = await rpc(rpcPort, secret, 'aria2.getOption', [alternateResult]);
|
||||
assertAria2RouteOptions(alternateOptions, 'alternate aria2.addUri');
|
||||
await forceRemoveIfPresent(rpcPort, secret, alternateResult);
|
||||
}
|
||||
await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult);
|
||||
await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult);
|
||||
|
||||
console.log('[PASS] Aria2 preserved system route options for direct/proxied normal/Torrent transfers');
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} 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,521 @@
|
||||
#!/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';
|
||||
import {
|
||||
ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
assertAria2Baseline,
|
||||
hasAria2RouteCapabilities,
|
||||
} from './aria2-route-contract.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
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]
|
||||
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
|
||||
? path.join(
|
||||
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
|
||||
targetTriple,
|
||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
)
|
||||
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
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 pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--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',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
`--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);
|
||||
assertAria2Baseline(version);
|
||||
if (hasAria2RouteCapabilities(version)) {
|
||||
// The fixture server is intentionally loopback. Disable only the custom
|
||||
// target policy after the optional capabilities have been attested.
|
||||
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
|
||||
}
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke (system resolver)`);
|
||||
|
||||
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||
}]);
|
||||
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`,
|
||||
], { ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
|
||||
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
|
||||
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
|
||||
|
||||
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
|
||||
throw new Error('valid checksum transfer did not complete');
|
||||
}
|
||||
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||
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()], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||
}]);
|
||||
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
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`], {
|
||||
...ARIA2_LOCAL_FIXTURE_OPTIONS,
|
||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||
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');
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -17,13 +18,23 @@ 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;
|
||||
// Portable-package checks intentionally inspect their disposable bundle's
|
||||
// data directory. Every other smoke run gets its own disposable profile.
|
||||
const smokeStorageRoot = assertPortableData ? null : fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-smoke-')));
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
FIRELINK_SMOKE_TEST: '1',
|
||||
FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '',
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
||||
GDK_BACKEND: 'x11',
|
||||
},
|
||||
@@ -83,6 +94,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 +337,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 +396,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;
|
||||
@@ -350,5 +406,7 @@ try {
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
} else if (smokeStorageRoot) {
|
||||
fs.rmSync(smokeStorageRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+66
-65
@@ -1,33 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
|
||||
import { sha256, treeDigest } from './engine-payload-integrity.js';
|
||||
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
|
||||
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
|
||||
import {
|
||||
assertSafeOutputRoot,
|
||||
resolveOutputRoot,
|
||||
resolveTargetTriple,
|
||||
} from './engine-workspace.js';
|
||||
import { assertAria2RouteSource } from './aria2-route-contract.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
|
||||
const sourceLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8'));
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
|
||||
const target = argValue('--target')
|
||||
|| process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| hostTarget;
|
||||
const target = resolveTargetTriple();
|
||||
const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [
|
||||
repoRoot,
|
||||
path.join(repoRoot, 'src-tauri'),
|
||||
path.join(repoRoot, 'src-tauri', 'engine-dist'),
|
||||
]);
|
||||
const isWindowsTarget = target.includes('windows');
|
||||
const suffix = isWindowsTarget ? '.exe' : '';
|
||||
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
@@ -52,6 +48,15 @@ if (!source) {
|
||||
}
|
||||
|
||||
if (targetLock) {
|
||||
if (targetLock.engines?.aria2c?.firelinkRouteContract) {
|
||||
try {
|
||||
assertAria2RouteSource(targetLock.engines.aria2c, target);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const engine of engines) {
|
||||
const name = `${engine}-${target}${suffix}`;
|
||||
const expected = targetLock.engines?.[engine]?.sha256;
|
||||
@@ -75,58 +80,54 @@ if (targetLock) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const manifestPath = path.join(source, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`No committed lock or payload manifest exists for ${target}.`);
|
||||
const sourceTargetLock = sourceLock.targets?.[target];
|
||||
if (!sourceTargetLock) {
|
||||
console.error(`No source lock exists for the provisioned engine target ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.target !== target) {
|
||||
console.error(`Payload manifest target mismatch: ${manifest.target}`);
|
||||
try {
|
||||
const manifest = readAndValidatePayloadManifest(source, sourceTargetLock, target);
|
||||
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
|
||||
assertAria2RouteSource(manifest.generatedFrom.aria2c, target);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [relative, expected] of Object.entries(manifest.files || {})) {
|
||||
const file = path.join(source, relative);
|
||||
if (!fs.existsSync(file) || sha256(file) !== expected) {
|
||||
console.error(`Payload manifest mismatch: ${relative}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
|
||||
const destination = path.join(outputRoot, target);
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(outputRoot, `.staging-${target}-${process.pid}-`));
|
||||
const temporaryDestination = path.join(temporaryRoot, target);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(temporaryDestination, { recursive: true, mode: 0o700 });
|
||||
|
||||
for (const name of expectedNames) {
|
||||
fs.copyFileSync(path.join(source, name), path.join(temporaryDestination, name));
|
||||
if (!isWindowsTarget) {
|
||||
fs.chmodSync(path.join(temporaryDestination, name), 0o755);
|
||||
}
|
||||
}
|
||||
const actualFiles = collectRegularFiles(source, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(source, file).split(path.sep).join('/'));
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
console.error(`Payload contains files not covered by manifest for ${target}.`);
|
||||
process.exit(1);
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
fs.cpSync(sourceDir, path.join(temporaryDestination, runtimeDir), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destination = path.join(outputRoot, target);
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
for (const name of expectedNames) {
|
||||
fs.copyFileSync(path.join(source, name), path.join(destination, name));
|
||||
if (!isWindowsTarget) {
|
||||
fs.chmodSync(path.join(destination, name), 0o755);
|
||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||
if (fs.existsSync(payloadManifest)) {
|
||||
fs.copyFileSync(payloadManifest, path.join(temporaryDestination, 'payload-manifest.json'));
|
||||
}
|
||||
await promoteDirectory(temporaryDestination, destination);
|
||||
} finally {
|
||||
await removePathWithRetry(temporaryRoot);
|
||||
}
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||
if (fs.existsSync(payloadManifest)) {
|
||||
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
|
||||
}
|
||||
|
||||
console.log(`Staged Firelink engines for ${target} from ${source}`);
|
||||
console.log(`Staged Firelink engines for ${target} from ${source} into ${destination}`);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
createEngineWorkspace,
|
||||
engineResourceConfig,
|
||||
removeEngineWorkspace,
|
||||
resolveTargetTriple,
|
||||
} from './engine-workspace.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
|
||||
const ENGINE_TREE_COMMANDS = new Set(['dev', 'build', 'bundle']);
|
||||
|
||||
export function commandUsesEngineTree(args) {
|
||||
return args.some(argument => ENGINE_TREE_COMMANDS.has(argument));
|
||||
}
|
||||
|
||||
export function commandIsStandaloneBundle(args) {
|
||||
return args.includes('bundle');
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
return {
|
||||
SIGHUP: 129,
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
}[signal] ?? 1;
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const usesEngineWorkspace = commandUsesEngineTree(args);
|
||||
let engineWorkspace;
|
||||
let child;
|
||||
let receivedSignal;
|
||||
let escalationTimer;
|
||||
let interruptedProcessPid;
|
||||
|
||||
function windowsTaskkillPath() {
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
|
||||
return systemRoot ? path.join(systemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe';
|
||||
}
|
||||
|
||||
function forceTerminateProcessTree(pid) {
|
||||
if (!pid) return Promise.resolve();
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
console.error(`[WARN] Could not force-terminate the Tauri process group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
let killer;
|
||||
try {
|
||||
killer = spawn(
|
||||
windowsTaskkillPath(),
|
||||
['/PID', String(pid), '/T', '/F'],
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
killer.once('error', finish);
|
||||
killer.once('close', finish);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSignal(signal) {
|
||||
receivedSignal ??= signal;
|
||||
if (child && child.exitCode === null && child.signalCode === null) {
|
||||
const pid = child.pid;
|
||||
interruptedProcessPid ??= pid;
|
||||
if (process.platform === 'win32') {
|
||||
// Node's Windows child.kill() does not reliably terminate descendants.
|
||||
// taskkill's process-tree mode is the OS-supported equivalent of the
|
||||
// POSIX process-group kill used below.
|
||||
void forceTerminateProcessTree(pid);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!pid) {
|
||||
child.kill(signal);
|
||||
} else {
|
||||
process.kill(-pid, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
console.error(`[WARN] Could not terminate the Tauri process group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (!escalationTimer) {
|
||||
escalationTimer = setTimeout(() => {
|
||||
void forceTerminateProcessTree(pid);
|
||||
}, 2_000);
|
||||
escalationTimer.unref();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = signalExitCode(signal);
|
||||
}
|
||||
|
||||
function runChild(command, commandArgs, env) {
|
||||
const spawned = spawn(command, commandArgs, {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
child = spawned;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = callback => value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (child === spawned) child = undefined;
|
||||
callback(value);
|
||||
};
|
||||
spawned.once('error', settle(reject));
|
||||
spawned.once('close', (code, signal) => settle(resolve)({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const handlers = new Map(['SIGHUP', 'SIGINT', 'SIGTERM'].map(signal => [
|
||||
signal,
|
||||
() => handleSignal(signal),
|
||||
]));
|
||||
for (const [signal, handler] of handlers) process.once(signal, handler);
|
||||
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
let commandArgs = args;
|
||||
if (usesEngineWorkspace) {
|
||||
const target = resolveTargetTriple(args, env);
|
||||
engineWorkspace = createEngineWorkspace(target);
|
||||
env.FIRELINK_ENGINE_WORKSPACE = engineWorkspace.workspace;
|
||||
env.FIRELINK_ENGINE_OUTPUT_ROOT = engineWorkspace.outputRoot;
|
||||
env.FIRELINK_ENGINE_RUNTIME_ROOT = engineWorkspace.runtimeRoot;
|
||||
env.FIRELINK_TARGET_TRIPLE = target;
|
||||
|
||||
if (
|
||||
(args.includes('build') || args.includes('bundle'))
|
||||
&& env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1'
|
||||
) {
|
||||
commandArgs = [...args, '--config', engineResourceConfig(engineWorkspace.outputRoot)];
|
||||
}
|
||||
}
|
||||
|
||||
if (receivedSignal) return;
|
||||
|
||||
if (usesEngineWorkspace && commandIsStandaloneBundle(args) && env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1') {
|
||||
let preparation;
|
||||
try {
|
||||
preparation = await runChild(
|
||||
process.execPath,
|
||||
[path.join(repoRoot, 'scripts', 'prepare-tauri-engines.js')],
|
||||
env,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Engine preparation failed: ${error.message}`, { cause: error });
|
||||
}
|
||||
if (receivedSignal) return;
|
||||
if (preparation.signal) {
|
||||
process.exitCode = signalExitCode(preparation.signal);
|
||||
return;
|
||||
}
|
||||
if (preparation.code !== 0) {
|
||||
process.exitCode = preparation.code ?? 1;
|
||||
return;
|
||||
}
|
||||
env.FIRELINK_ENGINE_BUNDLE_PREPARED = '1';
|
||||
}
|
||||
|
||||
if (receivedSignal) return;
|
||||
|
||||
const result = await runChild(process.execPath, [tauriCli, ...commandArgs], env);
|
||||
|
||||
if (receivedSignal) {
|
||||
process.exitCode = signalExitCode(receivedSignal);
|
||||
} else if (result.signal) {
|
||||
process.exitCode = signalExitCode(result.signal);
|
||||
} else {
|
||||
process.exitCode = result.code ?? 1;
|
||||
}
|
||||
} finally {
|
||||
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
||||
if (escalationTimer) clearTimeout(escalationTimer);
|
||||
if (interruptedProcessPid) await forceTerminateProcessTree(interruptedProcessPid);
|
||||
if (engineWorkspace) {
|
||||
try {
|
||||
await removeEngineWorkspace(engineWorkspace.workspace);
|
||||
} catch (error) {
|
||||
console.error(`[WARN] Could not remove the temporary engine workspace: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
run().catch(error => {
|
||||
console.error(`[FAIL] Tauri command failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { commandIsStandaloneBundle, commandUsesEngineTree } from './tauri-command.js';
|
||||
|
||||
test('Tauri engine-consuming commands use an engine workspace', () => {
|
||||
assert.equal(commandUsesEngineTree(['dev']), true);
|
||||
assert.equal(commandUsesEngineTree(['build', '--target', 'x86_64-unknown-linux-gnu']), true);
|
||||
assert.equal(commandUsesEngineTree(['bundle', '--bundles', 'appimage']), true);
|
||||
assert.equal(commandUsesEngineTree(['info']), false);
|
||||
assert.equal(commandUsesEngineTree(['--help']), false);
|
||||
});
|
||||
|
||||
test('standalone bundle commands prepare engines before Tauri starts', () => {
|
||||
assert.equal(commandIsStandaloneBundle(['bundle', '--bundles', 'app']), true);
|
||||
assert.equal(commandIsStandaloneBundle(['build', '--bundles', 'app']), false);
|
||||
});
|
||||
|
||||
test('the bundle hook accepts a completed wrapper preflight', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(import.meta.dirname, 'before-tauri-bundle.js')],
|
||||
{
|
||||
cwd: path.join(import.meta.dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
FIRELINK_ENGINE_BUNDLE_PREPARED: '1',
|
||||
FIRELINK_SKIP_ENGINE_RESOURCE: '',
|
||||
FIRELINK_ENGINE_OUTPUT_ROOT: '',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
});
|
||||
@@ -5,6 +5,17 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
resolveOutputRoot,
|
||||
resolveTargetTriple,
|
||||
} from './engine-workspace.js';
|
||||
import {
|
||||
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
assertAria2Baseline,
|
||||
assertAria2AllocationCapabilities,
|
||||
assertAria2RouteSource,
|
||||
} from './aria2-route-contract.js';
|
||||
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -29,9 +40,7 @@ if (!currentArch || !currentPlatform) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetTriple = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| `${currentArch}-${currentPlatform}`;
|
||||
const targetTriple = resolveTargetTriple();
|
||||
const hostTriple = `${currentArch}-${currentPlatform}`;
|
||||
const canExecuteTarget = targetTriple === hostTriple;
|
||||
const isWindows = targetTriple.includes('windows');
|
||||
@@ -41,6 +50,10 @@ const ext = isWindows ? '.exe' : '';
|
||||
const suffix = `-${targetTriple}${ext}`;
|
||||
|
||||
const scriptsDir = __dirname;
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
@@ -81,7 +94,7 @@ function findEngineRoot(root) {
|
||||
|
||||
const configuredRoot = argValue('--root')
|
||||
|| (process.argv.includes('--staged')
|
||||
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
||||
? path.join(resolveOutputRoot(), targetTriple)
|
||||
: searchRoot
|
||||
? findEngineRoot(searchRoot)
|
||||
: null);
|
||||
@@ -89,6 +102,8 @@ const binariesDir = configuredRoot
|
||||
? path.resolve(configuredRoot)
|
||||
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
const stagedVerification = process.argv.includes('--staged');
|
||||
const sourceTargetLock = sourceLock.targets?.[targetTriple];
|
||||
|
||||
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
||||
const FORBIDDEN_STDERR = [
|
||||
@@ -109,6 +124,23 @@ function ok(msg) {
|
||||
console.log(`[OK] ${msg}`);
|
||||
}
|
||||
|
||||
if (sourceTargetLock) {
|
||||
try {
|
||||
const manifest = readAndValidatePayloadManifest(binariesDir, sourceTargetLock, targetTriple);
|
||||
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
|
||||
assertAria2RouteSource(manifest.generatedFrom.aria2c, targetTriple);
|
||||
}
|
||||
ok('Payload manifest provenance and checksums');
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error('\nAborting: engine payload integrity checks failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rejectSymlinks(root, label) {
|
||||
if (!fs.existsSync(root)) {
|
||||
return;
|
||||
@@ -152,9 +184,13 @@ function engineEnv(engine) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
return {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: modulesDir,
|
||||
...(process.platform === 'win32'
|
||||
? { [pathKey]: `${modulesDir}${path.delimiter}${process.env[pathKey] || ''}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,6 +502,7 @@ if (canExecuteTarget) {
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
@@ -560,6 +597,8 @@ if (canExecuteTarget) {
|
||||
try {
|
||||
const resp = JSON.parse(result.data);
|
||||
if (resp?.result?.version) {
|
||||
assertAria2Baseline(resp.result);
|
||||
assertAria2AllocationCapabilities(resp.result);
|
||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||
} else {
|
||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultRepositoryRoot = path.resolve(scriptDirectory, '..');
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
export function exactVersionTag(extensionRoot, expectedTag) {
|
||||
try {
|
||||
const tags = execFileSync(
|
||||
'git',
|
||||
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
},
|
||||
}
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
.find(tag => tag === expectedTag);
|
||||
return tags || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyCompanionRelease({
|
||||
repositoryRoot = defaultRepositoryRoot,
|
||||
resolveExactTag = exactVersionTag,
|
||||
requireExactTag = true,
|
||||
} = {}) {
|
||||
const extensionRoot = path.join(repositoryRoot, 'Extensions', 'Browser');
|
||||
const packagePath = path.join(extensionRoot, 'package.json');
|
||||
const manifestPath = path.join(extensionRoot, 'manifest.json');
|
||||
|
||||
if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) {
|
||||
throw new Error('Companion package.json and manifest.json must both exist.');
|
||||
}
|
||||
|
||||
const packageVersion = readJson(packagePath).version;
|
||||
const manifestVersion = readJson(manifestPath).version;
|
||||
if (!packageVersion || packageVersion !== manifestVersion) {
|
||||
throw new Error(
|
||||
`Companion versions do not agree: package.json=${packageVersion || 'missing'}, ` +
|
||||
`manifest.json=${manifestVersion || 'missing'}.`
|
||||
);
|
||||
}
|
||||
if (typeof packageVersion !== 'string' || !SEMVER.test(packageVersion)) {
|
||||
throw new Error(`Companion version ${String(packageVersion)} is not a valid semantic version.`);
|
||||
}
|
||||
|
||||
const expectedTag = `v${packageVersion}`;
|
||||
if (!requireExactTag) {
|
||||
return { tag: null, version: packageVersion };
|
||||
}
|
||||
const tag = resolveExactTag(extensionRoot, expectedTag);
|
||||
if (!tag) {
|
||||
throw new Error(
|
||||
`Companion HEAD is not exactly tagged ${expectedTag}; publish the Companion release first.`
|
||||
);
|
||||
}
|
||||
if (tag !== expectedTag) {
|
||||
throw new Error(`Companion HEAD tag ${tag} does not match ${expectedTag}.`);
|
||||
}
|
||||
|
||||
return { tag, version: packageVersion };
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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 { execFileSync } from 'node:child_process';
|
||||
import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
|
||||
|
||||
function createFixture(packageVersion, manifestVersion) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
|
||||
const extensionRoot = path.join(root, 'Extensions', 'Browser');
|
||||
fs.mkdirSync(extensionRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extensionRoot, 'package.json'),
|
||||
`${JSON.stringify({ version: packageVersion }, null, 2)}\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(extensionRoot, 'manifest.json'),
|
||||
`${JSON.stringify({ version: manifestVersion }, null, 2)}\n`
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('accepts matching Companion metadata and exact release tag', () => {
|
||||
const root = createFixture('2.0.7', '2.0.7');
|
||||
try {
|
||||
let resolvedExpectedTag;
|
||||
assert.deepEqual(
|
||||
verifyCompanionRelease({
|
||||
repositoryRoot: root,
|
||||
resolveExactTag: (_extensionRoot, expectedTag) => {
|
||||
resolvedExpectedTag = expectedTag;
|
||||
return 'v2.0.7';
|
||||
},
|
||||
}),
|
||||
{ tag: 'v2.0.7', version: '2.0.7' }
|
||||
);
|
||||
assert.equal(resolvedExpectedTag, 'v2.0.7');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects mismatched Companion package and manifest versions', () => {
|
||||
const root = createFixture('2.0.7', '2.0.6');
|
||||
try {
|
||||
assert.throws(
|
||||
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.7' }),
|
||||
/versions do not agree/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a malformed Companion semantic version before tag lookup', () => {
|
||||
const root = createFixture('--contains=HEAD', '--contains=HEAD');
|
||||
let tagLookupCalled = false;
|
||||
try {
|
||||
assert.throws(
|
||||
() => verifyCompanionRelease({
|
||||
repositoryRoot: root,
|
||||
resolveExactTag: () => {
|
||||
tagLookupCalled = true;
|
||||
return 'v--contains=HEAD';
|
||||
},
|
||||
}),
|
||||
/not a valid semantic version/
|
||||
);
|
||||
assert.equal(tagLookupCalled, false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an untagged Companion commit', () => {
|
||||
const root = createFixture('2.0.7', '2.0.7');
|
||||
try {
|
||||
assert.throws(
|
||||
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => null }),
|
||||
/not exactly tagged v2.0.7/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
assert.throws(
|
||||
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.6' }),
|
||||
/does not match v2.0.7/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
|
||||
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
||||
try {
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
};
|
||||
execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
|
||||
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
|
||||
execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
|
||||
|
||||
const globalConfig = path.join(root, 'global.gitconfig');
|
||||
fs.writeFileSync(globalConfig, '[alias]\n\ttag = !printf "v2.0.8\\n"\n');
|
||||
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
||||
assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
|
||||
assert.equal(exactVersionTag(root, 'v2.0.8'), null);
|
||||
} finally {
|
||||
if (previousGlobalConfig === undefined) {
|
||||
delete process.env.GIT_CONFIG_GLOBAL;
|
||||
} else {
|
||||
process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const windowStyles = fs.readFileSync('src/index.css', 'utf8');
|
||||
const propertiesWindowSource = fs.readFileSync('src-tauri/src/properties_window.rs', 'utf8');
|
||||
const mainWindowSource = fs.readFileSync('src-tauri/src/lib.rs', 'utf8');
|
||||
const windowsConfiguration = JSON.parse(
|
||||
fs.readFileSync('src-tauri/tauri.windows.conf.json', 'utf8')
|
||||
);
|
||||
|
||||
const cssBlock = selector => {
|
||||
const opening = `${selector} {`;
|
||||
const start = windowStyles.indexOf(opening);
|
||||
assert.notEqual(start, -1, `${selector} should exist`);
|
||||
const end = windowStyles.indexOf('}', start + opening.length);
|
||||
assert.notEqual(end, -1, `${selector} should have a closing brace`);
|
||||
return windowStyles.slice(start, end + 1);
|
||||
};
|
||||
|
||||
test('Windows native shadows remain disabled for the main and Properties windows', () => {
|
||||
assert.deepEqual(
|
||||
{
|
||||
transparent: windowsConfiguration.app.windows[0].transparent,
|
||||
decorations: windowsConfiguration.app.windows[0].decorations,
|
||||
shadow: windowsConfiguration.app.windows[0].shadow,
|
||||
},
|
||||
{ transparent: true, decorations: false, shadow: false }
|
||||
);
|
||||
assert.match(
|
||||
mainWindowSource,
|
||||
/let main_window_config = app\s*\.config\(\)\s*\.app\s*\.windows[\s\S]*?\.find\(\|window\| window\.label == "main"\)/
|
||||
);
|
||||
assert.match(
|
||||
mainWindowSource,
|
||||
/WebviewWindowBuilder::from_config\(\s*app\.handle\(\),\s*&main_window_config,\s*\)/
|
||||
);
|
||||
assert.match(
|
||||
propertiesWindowSource,
|
||||
/#\[cfg\(target_os = "windows"\)\]\s*let builder = builder\.transparent\(true\)\.shadow\(false\);/
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows selects stronger renderer-owned contours for every theme', () => {
|
||||
const expectedTokens = [
|
||||
[':root', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
|
||||
['.theme-light', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
|
||||
['.theme-dark', '0 0% 100% / 0.35', '0 0% 100% / 0.14'],
|
||||
['.theme-dracula', '228 14% 84% / 0.45', '228 14% 84% / 0.18'],
|
||||
['.theme-nord', '218 27% 88% / 0.45', '218 27% 88% / 0.18'],
|
||||
];
|
||||
|
||||
for (const [selector, active, inactive] of expectedTokens) {
|
||||
const block = cssBlock(selector);
|
||||
assert.match(block, new RegExp(`--window-frame-windows-active: ${active.replace('.', '\\.')}\\s*;`));
|
||||
assert.match(block, new RegExp(`--window-frame-windows-inactive: ${inactive.replace('.', '\\.')}\\s*;`));
|
||||
}
|
||||
|
||||
const windowsBlock = cssBlock('html[data-platform="windows"]');
|
||||
assert.match(
|
||||
windowsBlock,
|
||||
/--window-frame-active:\s*var\(--window-frame-windows-active\);/
|
||||
);
|
||||
assert.match(
|
||||
windowsBlock,
|
||||
/--window-frame-inactive:\s*var\(--window-frame-windows-inactive\);/
|
||||
);
|
||||
});
|
||||
|
||||
test('maximized Windows shells remain square and borderless', () => {
|
||||
assert.match(
|
||||
windowStyles,
|
||||
/html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-color:\s*transparent;/
|
||||
);
|
||||
assert.match(
|
||||
windowStyles,
|
||||
/html\[data-platform="linux"\] :is\(\.app-shell, \.properties-window-shell\),\s*html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-radius:\s*0;/
|
||||
);
|
||||
});
|
||||
Generated
+584
-556
File diff suppressed because it is too large
Load Diff
+21
-18
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.2.0"
|
||||
version = "1.4.2"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -24,48 +24,51 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-shell = "2.3.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls = { version = "0.23.44", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
sysinfo = "0.39.6"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
sha1 = "0.11"
|
||||
base64 = { version = "0.23.1", default-features = false, features = ["std"] }
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.19"
|
||||
thiserror = "2.0.20"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
keepawake = "0.6.1"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.33", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
log = "0.4.34"
|
||||
tauri-plugin-log = "2.9.1"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
|
||||
apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
unicode-normalization = "0.1.25"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
zbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-rust"] }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Rust dependency advisory policy
|
||||
|
||||
`cargo audit` is a required CI gate. Vulnerability advisories must be resolved;
|
||||
the gate must not be bypassed with a broad ignore list.
|
||||
|
||||
As of 2026-09-05, Cargo reports no vulnerability advisories. It does report
|
||||
the following informational warnings, which remain visible in CI output:
|
||||
|
||||
- `RUSTSEC-2024-0411` through `RUSTSEC-2024-0420` (GTK3 bindings) and
|
||||
`RUSTSEC-2024-0370` (`proc-macro-error`) are Linux-only dependencies reached
|
||||
through Tauri/Wry's GTK3 and tray integration.
|
||||
- `RUSTSEC-2024-0429` (`glib` 0.18.5 iterator unsoundness) is in that same
|
||||
Linux Tauri/Wry GTK3 graph. Firelink does not directly use
|
||||
`glib::VariantStrIter`, but this remains an upstream risk rather than a
|
||||
Firelink-level remediation.
|
||||
- `RUSTSEC-2025-0075`, `RUSTSEC-2025-0080`, `RUSTSEC-2025-0081`,
|
||||
`RUSTSEC-2025-0098`, and `RUSTSEC-2025-0100` are unmaintained UNIC crates
|
||||
reached through `tauri-utils -> urlpattern`.
|
||||
|
||||
Review these paths with every Tauri/Wry update and no later than 2026-12-05.
|
||||
Remove this acknowledgement when the upstream graph no longer contains the
|
||||
affected packages. Do not add these advisory IDs to Cargo's ignore list: a
|
||||
future severity change must remain visible.
|
||||
Binary file not shown.
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user