mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 01:35:43 +00:00
Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de97fa1e65 | |||
| 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 | |||
| d6b5d6a944 | |||
| efeacf737a | |||
| 6ae790f2c5 | |||
| 07efb3667d | |||
| 512a77f6bb | |||
| 030db52016 | |||
| d1452ce0c1 | |||
| 8e706b598c | |||
| 297f2c08fb | |||
| 16377aa0d6 | |||
| 20a136cd1f | |||
| e1f83b4f19 | |||
| a98cce0980 | |||
| 81381a5a93 | |||
| 886388d5f2 | |||
| 69ce2b15ba | |||
| 621652ae13 | |||
| e0cb124720 | |||
| c133556d38 | |||
| f423d9bee3 | |||
| 2b14b8f58c | |||
| 6e85c0842f | |||
| f47eb7507f | |||
| c427aa2f7a | |||
| 23878c2ef8 | |||
| 79cd9f0ed1 | |||
| 60f13f756c | |||
| 2668f0b722 | |||
| 93ddf427b4 | |||
| e4d7d5ecf0 | |||
| 160e6af945 | |||
| c914eeb7b3 | |||
| 62365f514e | |||
| 9fdd8ac1b8 | |||
| 61eb034db1 | |||
| c949cbb9ee | |||
| 566396e629 | |||
| db39cd2153 | |||
| f4b830414d | |||
| 0447d1cab7 | |||
| 513143d745 | |||
| a8dc4fb447 | |||
| 50c3da2f5d | |||
| 6ef911919d | |||
| 5144ecd39e | |||
| a56b859151 | |||
| 469faed7b9 | |||
| 79ce0c18a1 | |||
| d195a132b3 | |||
| feb5d8e87d | |||
| a136fa832c | |||
| f3d0e0be13 | |||
| edeef0ac54 | |||
| 9f333618fc | |||
| ed54a048ab | |||
| d2479f52be | |||
| 6e6ae51395 | |||
| 1d197432b2 | |||
| 9917f29743 | |||
| df3bc359c4 | |||
| 57f132f53e | |||
| 52b00e5cb4 | |||
| 8e02a61c3f | |||
| 82f2914077 | |||
| 9224117d77 | |||
| 3c84b5c1a4 | |||
| c34c489aef | |||
| b81e8391e1 | |||
| 6ff0047d6c | |||
| 4a3fece22b | |||
| 1da0fa7223 | |||
| d6af4ee2b5 | |||
| 2479ead4ed | |||
| 80a29356e0 | |||
| 45bbca0515 | |||
| e35b1af731 | |||
| 19953a210e | |||
| 76850f2433 | |||
| 0eee47b97f | |||
| e8487ee71b | |||
| e07182fbf2 | |||
| 2d9eed99d5 | |||
| 80cf835060 | |||
| c78a72a8d7 | |||
| df85a77987 | |||
| be2a98fbcd | |||
| fcfdffa6e0 | |||
| 9805c9288a | |||
| dad5b7bc5e | |||
| f441c687f0 | |||
| a0f44b79ad | |||
| 56b4c9f511 | |||
| c1fa87b953 | |||
| c7ec8cd666 | |||
| 9133e3b05b | |||
| 5bbee12602 | |||
| 7894c05bba | |||
| 33375df2ff | |||
| 1922db8ea0 | |||
| ba70662165 | |||
| 629a34d1e8 | |||
| 248f3869ad | |||
| 4f4c655de6 | |||
| 3fbd0742be | |||
| c5025fd5a0 | |||
| b1c84a0fb9 | |||
| fbb89cde8e | |||
| cd8ab5c12b | |||
| ed7c47cb49 | |||
| 8c035167c8 | |||
| 6d9ef68bb0 | |||
| 51258b25bc | |||
| 1baadf6ea6 | |||
| 60dad5703a | |||
| b8ef712981 | |||
| 3b9faa789c | |||
| 161b0028f9 | |||
| f84726a403 | |||
| e52e885193 | |||
| 5fc3c9f965 | |||
| 800b94f9c0 | |||
| 6b5384f261 | |||
| 607d193f8e | |||
| f711c6b7a4 | |||
| e30eff4d2e | |||
| 85ad9d18e8 | |||
| 036ff02dac | |||
| 99f73eaae2 | |||
| 5ea46024e9 | |||
| a2a3b08d4e | |||
| 5ef0b3b5f6 | |||
| 515ba611ce | |||
| 579174c5b5 | |||
| c13f993150 | |||
| 017d9cd879 | |||
| dabd252ab7 | |||
| 3da73c623f | |||
| 757e313f71 | |||
| 3aaf32f9ee | |||
| 58bbf95761 |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="20" role="img" aria-label="Windows">
|
||||
<title>Windows</title>
|
||||
<rect width="86" height="20" fill="#0078D6"/>
|
||||
<path fill="#fff" d="M7 5.3 12.8 4.5v5H7V5.3Zm6.6-1 7.4-1.1v6.3h-7.4V4.3ZM7 10.5h5.8v5L7 14.7v-4.2Zm6.6 0H21v6.3l-7.4-1.1v-5.2Z"/>
|
||||
<text x="28" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">Windows</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -27,6 +27,7 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: Desktop checks (${{ matrix.target }})
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -52,8 +53,28 @@ jobs:
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
# The hosted Ubuntu image can expose an unreachable Azure mirror in
|
||||
# its apt sources while the public Ubuntu archive is reachable.
|
||||
# Normalize every apt source, including the image's mirror list,
|
||||
# before refreshing indexes so a transient runner-local mirror does
|
||||
# not consume the whole job timeout.
|
||||
sudo find /etc/apt -type f \
|
||||
-exec sed -i \
|
||||
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||
{} +
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
update
|
||||
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=60 \
|
||||
install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
@@ -65,12 +86,34 @@ jobs:
|
||||
if: runner.os != 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets --target ${{ matrix.target }}
|
||||
- name: Test Rust backend
|
||||
- name: Test Windows Torrent RPC integration
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
cargo test --tests --target ${{ matrix.target }}
|
||||
cargo test --lib --no-run --target ${{ matrix.target }}
|
||||
run: cargo test --test torrent_rpc --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Compile Windows queue-manager integration
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test queue_manager --no-run --target ${{ matrix.target }}
|
||||
- name: Compile Windows Rust library tests
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --lib --no-run --target ${{ matrix.target }}
|
||||
- name: Run Windows headless production contracts
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test production_contract --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows atomic Torrent storage
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test atomic_file --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows Torrent cache safety
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test torrent_cache --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Verify Windows Torrent web-seed normalization
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
@@ -78,3 +121,9 @@ jobs:
|
||||
run: |
|
||||
node scripts/stage-engines.js --target ${{ matrix.target }}
|
||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||
- name: Run Torrent process smoke
|
||||
run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths
|
||||
- name: Run Aria2 resolver smoke
|
||||
run: node scripts/smoke-aria2-resolver.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
- name: Run Aria2 normal-transfer smoke
|
||||
run: node scripts/smoke-aria2-transfers.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
|
||||
+184
-16
@@ -6,13 +6,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: 'Publish the GitHub release after all platform QA is certified'
|
||||
description: 'Publish the GitHub release after all platform build and verification jobs pass'
|
||||
type: boolean
|
||||
default: false
|
||||
certified_cross_platform:
|
||||
description: 'Confirm Windows, Linux, and macOS clean-machine QA passed'
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -44,18 +40,50 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- 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 \
|
||||
@@ -65,16 +93,38 @@ jobs:
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5
|
||||
libtinfo5 \
|
||||
rpm \
|
||||
cpio \
|
||||
libarchive-tools \
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
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 }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
|
||||
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 from existing binary
|
||||
if: runner.os == 'Linux'
|
||||
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
@@ -90,8 +140,25 @@ jobs:
|
||||
- name: Verify macOS packaged engines and launch
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
||||
set -euo pipefail
|
||||
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --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'
|
||||
@@ -101,8 +168,61 @@ jobs:
|
||||
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
||||
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
||||
7z x $installer.FullName "-o$extractRoot" -y
|
||||
& 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"
|
||||
Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null
|
||||
$payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq "firelink.exe" } |
|
||||
Sort-Object FullName |
|
||||
Select-Object -First 1
|
||||
if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." }
|
||||
Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force
|
||||
Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline
|
||||
@"
|
||||
Firelink portable
|
||||
|
||||
Extract this folder to a writable location and launch firelink.exe.
|
||||
Close Firelink before copying or moving this folder.
|
||||
Settings, queues, logs, and WebView data are stored under data\.
|
||||
Only one Firelink instance can run at a time; close the installed app before launching this copy.
|
||||
Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records.
|
||||
Saved site passwords remain in the Windows credential store and are not portable.
|
||||
The portable 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 to another drive.
|
||||
The portable archive does not register the firelink:// protocol; use the installer for browser launch integration.
|
||||
"@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt')
|
||||
New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null
|
||||
$portableExe = Join-Path $portableRoot 'firelink.exe'
|
||||
node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }}
|
||||
node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data
|
||||
Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
$portableDataDir = Join-Path $portableRoot 'data'
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $portableDataDir)) { break }
|
||||
Start-Sleep -Milliseconds (200 * $attempt)
|
||||
}
|
||||
if (Test-Path $portableDataDir) {
|
||||
throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data."
|
||||
}
|
||||
$portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip"
|
||||
7z a -tzip $portableZip "$portableRoot\*" -y
|
||||
|
||||
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
|
||||
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
|
||||
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
|
||||
@@ -122,6 +242,9 @@ jobs:
|
||||
chmod +x "$APPIMAGE"
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
|
||||
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
|
||||
ARIA2="$(find "$RUNNER_TEMP/squashfs-root" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||
test -n "$ARIA2"
|
||||
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
|
||||
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -129,14 +252,36 @@ jobs:
|
||||
path: ${{ matrix.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
name: Firelink-Windows-x64-portable-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-portable/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
inputs.publish_release &&
|
||||
inputs.certified_cross_platform
|
||||
(
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
)
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -151,10 +296,33 @@ jobs:
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
test -s release_notes.md
|
||||
- name: Normalize release asset names
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
rename_asset() {
|
||||
local pattern="$1"
|
||||
local destination="$2"
|
||||
local source
|
||||
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
|
||||
if (( ${#matches[@]} != 1 )); then
|
||||
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
|
||||
exit 1
|
||||
fi
|
||||
source="${matches[0]}"
|
||||
mv "$source" "release-assets/$destination"
|
||||
}
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
|
||||
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-assets/**
|
||||
|
||||
+11
@@ -11,14 +11,24 @@ 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/
|
||||
RC Read-only/
|
||||
RC Read_only/
|
||||
|
||||
# Frontend output and logs
|
||||
.agents/
|
||||
release_notes.md
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
@@ -55,6 +65,7 @@ build/
|
||||
# Local secrets and signing material
|
||||
.env
|
||||
.env.*
|
||||
implementation_plan.md
|
||||
*.key
|
||||
*.pem
|
||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
+222
@@ -5,6 +5,228 @@ All notable changes to Firelink will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.0] - 2026-08-27
|
||||
|
||||
This release adds built-in Torrent downloads and a dedicated Properties window, while making regular downloads, browser handoffs, and cross-platform packages more dependable.
|
||||
|
||||
### New features
|
||||
|
||||
- **Torrent downloads**
|
||||
- Add `.torrent` files and magnet links from the Add window, file associations, `magnet:` links, and Firelink Companion.
|
||||
- Resolve remote metadata before enqueueing and safely reuse validated Torrent metadata.
|
||||
- Select files, prioritize pieces, preallocate or allocate as needed, verify existing data, remove unselected files safely, and add per-file web seeds.
|
||||
- Manage trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
|
||||
- View file progress, piece availability, connected and listed peers, seeders, upload totals and speed, and the info hash.
|
||||
- Set upload limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data to a new location.
|
||||
- Use a dedicated Torrents category with pause, resume, retry, redownload, and safe cleanup.
|
||||
- **Download and Torrent Properties windows**
|
||||
- Open a selected download in its own window with overview, transfer, and advanced controls.
|
||||
- Use Torrent tabs for file selection, trackers, peers, options, and live diagnostics.
|
||||
- Edit supported settings while a transfer is active, including speed, connections, Torrent upload and peer limits, seeding, verification, allocation, and encryption.
|
||||
- Inspect allocation, exact progress, resume failures, destinations, and current diagnostics; copy long URLs or paths and export magnet links where available.
|
||||
- Keep the window size during the app session while the window follows the current theme and locale.
|
||||
- **Adaptive mirror selection**
|
||||
- Optionally use recent transfer performance to choose among multiple mirrors. Mirror statistics stay private on this device.
|
||||
- **Transfer and layout visibility**
|
||||
- Show the file-allocation phase while a normal download prepares its destination.
|
||||
- Remember the main-window size and position and the Folders collapse preference between launches.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve normal-download recovery across restarts, stale transfers, redirects, mirrors, connection-pool slowdowns, retries, and resume operations without saved credentials.
|
||||
- Improve media recovery and resume messaging, preserve exact progress at the end of a transfer, and restore adaptive YouTube formats. This responds to the interrupted-YouTube-download report in [#36](https://github.com/nimbold/Firelink/issues/36).
|
||||
- Make browser and deep-link inputs arrive in order, keep magnet clipboard handoffs usable, and make Add-window destination and metadata validation clearer.
|
||||
- Improve the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior at narrow window sizes.
|
||||
- Add clear guidance for the macOS first-launch security warning and safe approval steps, responding to [#34](https://github.com/nimbold/Firelink/issues/34).
|
||||
- Refresh bundled engines and dependencies, resume interrupted engine downloads safely, and strengthen package, release, and cross-platform verification.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Retry affected transfers through the system resolver when a VPN or network tunnel leaves aria2 unable to resolve a host, addressing [#35](https://github.com/nimbold/Firelink/issues/35).
|
||||
- Prevent late or duplicate lifecycle events from reviving, removing, or misreporting a download after a newer action has already won.
|
||||
- Keep replacement, removal, and pre-admission cleanup from leaving stale queue entries, partial files, or misleading progress behind.
|
||||
- Keep completed, paused, failed, and retrying downloads authoritative while allocation and progress updates arrive asynchronously.
|
||||
- Make scheduled actions, speed limits, logs, persisted settings, and browser credentials safer when several changes happen close together.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Use [Firelink Companion `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0), or the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest), with Firelink `1.4.0`.
|
||||
|
||||
## [1.3.1] - 2026-07-30
|
||||
|
||||
This hotfix improves filename handling, localized display details, and release reliability across platforms.
|
||||
|
||||
### 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, 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, 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, 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, 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prevent stale lifecycle work, duplicate terminal events, and overlapping controls from deleting, reviving, or leaving the wrong download in a misleading state.
|
||||
- Keep missed completion events and connection-limit updates from leaving a finished or active download visually stuck.
|
||||
- Bound backend connection counts so user-selected limits are respected instead of allowing overlapping settings to exceed them.
|
||||
- Keep extension handoffs in the Add window and make selected-link batches retain their page context for folder suggestions.
|
||||
|
||||
## [1.1.1] - 2026-07-17
|
||||
|
||||
This patch release focuses on transfer reliability, browser captures, and easier download control.
|
||||
|
||||
### New
|
||||
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
|
||||
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
|
||||
|
||||
### Improved
|
||||
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
|
||||
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
|
||||
- Improve playlist state, media size estimates, and large-list performance.
|
||||
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
|
||||
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
|
||||
|
||||
### Fixed
|
||||
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
|
||||
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
|
||||
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
|
||||
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
|
||||
|
||||
## [1.1.0] - 2026-07-15
|
||||
|
||||
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
|
||||
|
||||
### New
|
||||
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
|
||||
|
||||
### Improved
|
||||
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
|
||||
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
|
||||
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
|
||||
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
|
||||
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
|
||||
|
||||
### Fixed
|
||||
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
|
||||
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
|
||||
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
|
||||
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
|
||||
|
||||
## [1.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
|
||||
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
|
||||
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
|
||||
|
||||
### Improved
|
||||
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
|
||||
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
|
||||
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
|
||||
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
|
||||
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
|
||||
|
||||
### Fixed
|
||||
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
|
||||
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
|
||||
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
|
||||
|
||||
## [1.0.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
|
||||
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
|
||||
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
### New
|
||||
- Add explicit **Fetch media** actions for Firelink Companion so video and audio pages can be sent to Firelink from the extension popup or page context menu.
|
||||
- Add a Chromium extension install path in Firelink's Integrations settings and release documentation, addressing the Chromium support request in [#4](https://github.com/nimbold/Firelink/issues/4).
|
||||
|
||||
### Improved
|
||||
- Improve YouTube and social-media download handling with better proxy support, cleaner diagnostics, safer log redaction, and more reliable metadata fetching based on reports in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Let category subfolders be disabled so files can save directly into a base download folder, addressing [#6](https://github.com/nimbold/Firelink/issues/6).
|
||||
- Refresh bundled media engines and release tooling for the current cross-platform desktop builds.
|
||||
- Strengthen release packaging checks for macOS, Windows, and Linux, including macOS ad-hoc signing verification and Windows installer icon handling.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube downloads that failed when Chrome's cookie database could not be copied by retrying public media without browser cookies, addressing [#7](https://github.com/nimbold/Firelink/issues/7).
|
||||
- Fix the hidden-sidebar trap on Settings and other pages so the sidebar can always be shown again, addressing [#9](https://github.com/nimbold/Firelink/issues/9).
|
||||
- Fix Windows proxy detection so system proxy schemes are preserved for metadata and media requests, addressing [#5](https://github.com/nimbold/Firelink/issues/5).
|
||||
- Fix captured and auto-captured browser links so they always route through Firelink's Add window before reaching the download list.
|
||||
- Fix media downloads after a metadata fallback so pages without selectable preview formats can still let yt-dlp choose the best downloadable file instead of crashing.
|
||||
- Fix media cleanup and retry paths so failed, canceled, or retried media downloads leave fewer stale temporary files behind.
|
||||
|
||||
## [1.0.1] - 2026-07-04
|
||||
|
||||
### Fixed
|
||||
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
|
||||
|
||||
## [1.0.0] - 2026-07-04
|
||||
|
||||
### Highlights
|
||||
|
||||
Submodule
+1
Submodule Extensions/Browser added at 3fe0a52a59
Submodule Extensions/Firefox deleted from 42b28fa4f6
@@ -1,95 +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)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](https://tauri.app/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://react.dev/)
|
||||
[](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) · [Release Notes](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?
|
||||
|
||||
## Why Firelink
|
||||
Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
|
||||
|
||||
Firelink is built for people who want a real desktop download manager again: fast segmented transfers, browser capture, media extraction, scheduling, recovery, and clear control over where files land. Version 1.0.0 completes the move from the earlier macOS-only Swift app to a modern Rust/Tauri application with a React and TypeScript interface.
|
||||
It uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
|
||||
|
||||
The app keeps the heavy work native. Downloads are coordinated by a Rust backend, accelerated with aria2, enriched with yt-dlp and FFmpeg for media workflows, and persisted locally with SQLite so queues survive restarts and app updates.
|
||||
## Status
|
||||
|
||||
Firelink `1.4.0` is the latest desktop release.
|
||||
|
||||
Use [the latest Firelink Companion release, `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0) with Firelink. The Companion is maintained in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension).
|
||||
|
||||
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
|
||||
|
||||
- **Fast segmented downloads** powered by aria2 with configurable connections, retries, and speed limits.
|
||||
- **Media extraction** with yt-dlp, FFmpeg, and Deno for video/audio links and richer format selection.
|
||||
- **A real Add window** for manual, extension-captured, and media downloads, including metadata, duplicate handling, and save-location choices before downloads start.
|
||||
- **Persistent queue management** with safe concurrency limits, pause/resume, retry, redownload, sorting, multi-select, and bulk controls.
|
||||
- **Download scheduling** with start/stop windows, speed-limiter tools, and optional post-queue actions.
|
||||
- **Smart organization** through categories, default folders, per-download overrides, and open/reveal/trash actions.
|
||||
- **Private browser handoff** through authenticated local pairing with replay protection and desktop-server proof checks.
|
||||
- **Native desktop integration** including tray controls, notifications, completion sounds, sleep prevention, and OS keychain support where available.
|
||||
- **Diagnostics built in** with engine health checks, structured logs, and packaged-engine 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 the latest desktop build 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` | Unsigned and not notarized. Open through Finder or approve once in **System Settings -> Privacy & Security**. |
|
||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
||||
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
|
||||
| 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. |
|
||||
|
||||
Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for normal app usage.
|
||||
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separate engine installation is required.
|
||||
|
||||
## Browser Extension
|
||||
<details>
|
||||
<summary><strong>macOS security warning and first launch</strong></summary>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20Firelink%20Companion-Firefox-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion on Firefox" /></a>
|
||||
</div>
|
||||
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.
|
||||
|
||||
Firelink Companion connects your browser to the desktop app so links and browser downloads can open in Firelink instead of disappearing into the browser's default download shelf.
|
||||
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:
|
||||
|
||||
What it adds:
|
||||
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:
|
||||
|
||||
- **Automatic capture** for normal browser downloads, while still routing every captured link through Firelink's Add window.
|
||||
- **Context-menu actions** for "Download with Firelink" and selected links.
|
||||
- **Signed local requests** using the pairing token from **Settings -> Integrations**.
|
||||
- **Server identity checks** so the extension only trusts the real local Firelink app.
|
||||
- **Offline-safe behavior** that resumes browser downloads when Firelink is closed or rejects a handoff.
|
||||
- **Protocol-aware compatibility** so older desktop builds are rejected before automatic capture can cancel a browser download.
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Firelink.app
|
||||
```
|
||||
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. The Firefox add-on is maintained in the [Firelink-Extension](https://github.com/nimbold/Firelink-Extension) repository and is also vendored here as the `Extensions/Firefox` submodule.
|
||||
If Firelink is stored somewhere else, replace the path with the actual path to `Firelink.app`. The approval is normally needed only once.
|
||||
|
||||
## Platforms
|
||||
</details>
|
||||
|
||||
| Target | Status |
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported. Automated native build, engine validation, packaged launch smoke test, and unsigned DMG packaging. |
|
||||
| **Windows x64** | Supported. Native GitHub Actions build, engine validation, silent installer smoke test, and NSIS packaging. |
|
||||
| **Linux x64** | Supported. Native GitHub Actions build, engine validation, AppImage repackaging, and xvfb launch smoke test. |
|
||||
<details>
|
||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||
|
||||
- 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 integration
|
||||
|
||||
[Firelink Companion `2.2.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.0) connects browser downloads, links, media pages, magnets, and Torrent metadata to Firelink. Use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest) with the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
||||
|
||||
Captured links open Firelink's Add window for review before they are started or queued.
|
||||
|
||||
<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>
|
||||
|
||||
See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -100,73 +119,50 @@ Install the extension, open Firelink, then pair it from **Settings -> Integratio
|
||||
- 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
|
||||
```
|
||||
|
||||
Install dependencies and launch the desktop app:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Run the core checks:
|
||||
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
|
||||
```
|
||||
|
||||
Create a production bundle:
|
||||
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
|
||||
|
||||
```sh
|
||||
npm run tauri build
|
||||
```
|
||||
## Contributing and support
|
||||
|
||||
macOS development uses locked payloads in `src-tauri/binaries`. Windows and Linux payloads are provisioned from checksum-pinned archives:
|
||||
|
||||
```sh
|
||||
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
|
||||
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Firefox/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
- 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.
|
||||
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
|
||||
|
||||
## Technology & Credits
|
||||
## Credits
|
||||
|
||||
Firelink is made possible by these open-source projects:
|
||||
Core technologies:
|
||||
|
||||
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
|
||||
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
|
||||
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
|
||||
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
|
||||
- **[SQLite](https://www.sqlite.org/)** for persistent local data
|
||||
- **[aria2](https://aria2.github.io/)** for segmented downloading
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
|
||||
- [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).
|
||||
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
|
||||
|
||||
+34
-9
@@ -4,11 +4,14 @@ Targets:
|
||||
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Windows x64 portable ZIP
|
||||
- Linux x64 AppImage
|
||||
- Linux x64 Debian package
|
||||
- Linux x64 RPM package
|
||||
|
||||
## Distribution policy
|
||||
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
Firelink does not use an Apple Developer account. macOS releases are ad-hoc signed but not notarized or Gatekeeper-approved. Users may still need to explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must not describe these builds as Developer ID signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
|
||||
|
||||
@@ -22,6 +25,8 @@ Firelink never falls back to system-installed media tools.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `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 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.
|
||||
|
||||
## Version update
|
||||
@@ -45,18 +50,39 @@ cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
Verify the DMG and the app it contains, then launch outside the repository
|
||||
working directory. The DMG bundler removes the intermediate app directory, so
|
||||
the post-build checks must use the mounted release artifact:
|
||||
|
||||
```bash
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
DMG="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --dmg "$DMG"
|
||||
MOUNT_POINT="$(mktemp -d -t firelink-dmg)"
|
||||
cleanup() {
|
||||
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
|
||||
rmdir "$MOUNT_POINT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null
|
||||
APP_COUNT="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' | wc -l | tr -d ' ')"
|
||||
test "$APP_COUNT" -eq 1
|
||||
APP="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' -print -quit)"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
GitHub release publication is intentionally manual. Tag pushes build and upload
|
||||
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
|
||||
tag when both release-certification inputs are checked after Windows, Linux,
|
||||
and macOS clean-machine QA.
|
||||
GitHub release publication follows `.github/workflows/release.yml`. A `v*` tag
|
||||
push builds, verifies, and publishes the GitHub release after the platform jobs
|
||||
pass. A `workflow_dispatch` on a `v*` tag also publishes when its
|
||||
`publish_release` input is enabled. The current workflow has no separate
|
||||
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
|
||||
|
||||
@@ -69,7 +95,6 @@ git push origin v<version>
|
||||
|
||||
GitHub Actions builds all targets on native runners, verifies engines inside
|
||||
final package contents, performs packaged launch smoke where supported, and
|
||||
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
|
||||
run on the same tag after clean-machine QA is complete.
|
||||
publishes the GitHub Release after the build matrix passes.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
||||
@@ -4,6 +4,23 @@ Firelink distributes separate executable tools. Firelink's MIT license does not
|
||||
|
||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
||||
|
||||
## 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
|
||||
|
||||
- Project: <https://aria2.github.io/>
|
||||
|
||||
+18
-18
@@ -3,19 +3,19 @@
|
||||
"targets": {
|
||||
"x86_64-pc-windows-msvc": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_win.zip",
|
||||
"sha256": "9bb27530494092870b5330deacfc65a40d3e980c7e2c67e5f09b902c37a6903d"
|
||||
"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.1",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-win64-gpl-8.1.zip",
|
||||
"sha256": "ddbe0deec00b9cfd3ee74298a46a22ac4061913ed47d92297cd168cc5a5cc625"
|
||||
"version": "8.1.2-46-g139afe709a",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-win64-gpl-8.1.zip",
|
||||
"sha256": "f966bc2e843bcd680dedd6d1a2c0c895bab859a402c6dd107cbe72a796dfebcf"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -25,19 +25,19 @@
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_linux.zip",
|
||||
"sha256": "217bbc9c3ed19ea75a7f151a3e48dbfeac7f459a7dce2deeeecc2d6e2871bd5b"
|
||||
"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.1",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
|
||||
"version": "2.9.5",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "0c6772b77fdbf127cc1498eca39a40e20b88817f36b66d553cebcfcca32b6d78"
|
||||
"version": "8.1.2-46-g139afe709a",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "0814f4491c2673ea505be8fb65a76c2bfabaa5aad8f33d49b1c5b87a2262e8c5"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
|
||||
+9
-8
@@ -4,10 +4,10 @@
|
||||
"aarch64-apple-darwin": {
|
||||
"engines": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"version": "2026.08.19",
|
||||
"source": "https://github.com/yt-dlp/yt-dlp",
|
||||
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
|
||||
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
|
||||
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -16,22 +16,23 @@
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125385-ge2e889d9da",
|
||||
"version": "N-125892-g406c5a37aa",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"sha256": "3027bd75166a596c31a271ce9ae14b5441492b9557011993c0bf873b5a7c6a09"
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1785661721_N-125892-g406c5a37aa/ffmpeg.zip",
|
||||
"sha256": "734e6b72a0c2d0d5e089b5a0094be74fa058c15158f6b0689207a01fedafd8f5"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.5",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "df5e2cca5253ec99b9a630fb059bf01cdbbf586fed1f344017875c5462b2c483"
|
||||
"sha256": "b5bd08edab254d42d7b05aa5b6cb4c9b8d4dede4975aff76951ce2cce18866fa"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
|
||||
"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
+1154
-372
File diff suppressed because it is too large
Load Diff
+33
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -31,36 +31,51 @@
|
||||
"dev": "vite",
|
||||
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@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.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
"i18next": "^26.4.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"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.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
const tauriConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(repositoryRoot, 'src-tauri', 'tauri.conf.json'), 'utf8')
|
||||
);
|
||||
|
||||
const torrentAssociation = tauriConfig.bundle.fileAssociations.find(association =>
|
||||
association.ext?.some(extension => extension.toLowerCase() === 'torrent')
|
||||
);
|
||||
|
||||
test('declares the native macOS BitTorrent content type', () => {
|
||||
assert.ok(torrentAssociation, 'the bundle must declare a .torrent association');
|
||||
assert.equal(torrentAssociation.mimeType, 'application/x-bittorrent');
|
||||
assert.deepEqual(torrentAssociation.exportedType, {
|
||||
identifier: 'org.bittorrent.torrent',
|
||||
conformsTo: ['public.data', 'public.item']
|
||||
});
|
||||
assert.equal(
|
||||
torrentAssociation.contentTypes,
|
||||
undefined,
|
||||
'the UTI declaration supplies the extension and MIME tags for clean installs'
|
||||
);
|
||||
});
|
||||
|
||||
test('declares magnet as a desktop deep-link scheme', () => {
|
||||
assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']);
|
||||
});
|
||||
|
||||
const packagedAppPath = process.env.FIRELINK_MACOS_APP;
|
||||
|
||||
if (packagedAppPath) {
|
||||
test('packaged macOS app exports the Torrent UTI and magnet URL scheme', () => {
|
||||
assert.equal(process.platform, 'darwin', 'packaged macOS association checks require macOS');
|
||||
const infoPlistPath = path.join(packagedAppPath, 'Contents', 'Info.plist');
|
||||
assert.ok(fs.existsSync(infoPlistPath), `missing packaged Info.plist: ${infoPlistPath}`);
|
||||
|
||||
const plist = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], {
|
||||
encoding: 'utf8'
|
||||
}));
|
||||
const urlTypes = Array.isArray(plist.CFBundleURLTypes) ? plist.CFBundleURLTypes : [];
|
||||
const schemes = urlTypes.flatMap(entry => (
|
||||
entry && typeof entry === 'object' && Array.isArray(entry.CFBundleURLSchemes)
|
||||
? entry.CFBundleURLSchemes.filter(scheme => typeof scheme === 'string')
|
||||
: []
|
||||
));
|
||||
assert.ok(schemes.includes('firelink'), 'packaged app must retain the Firelink deep-link scheme');
|
||||
assert.ok(schemes.includes('magnet'), 'packaged app must export the magnet URL scheme');
|
||||
|
||||
const documentTypes = Array.isArray(plist.CFBundleDocumentTypes) ? plist.CFBundleDocumentTypes : [];
|
||||
const torrentDocument = documentTypes.find(entry =>
|
||||
entry
|
||||
&& typeof entry === 'object'
|
||||
&& Array.isArray(entry.CFBundleTypeExtensions)
|
||||
&& entry.CFBundleTypeExtensions.some(extension =>
|
||||
typeof extension === 'string' && extension.toLowerCase() === 'torrent'
|
||||
)
|
||||
);
|
||||
assert.ok(torrentDocument, 'packaged app must claim the .torrent extension');
|
||||
assert.ok(
|
||||
Array.isArray(torrentDocument.LSItemContentTypes)
|
||||
&& torrentDocument.LSItemContentTypes.includes('org.bittorrent.torrent'),
|
||||
'packaged app must claim the standard BitTorrent UTI'
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const sourceArgument = process.argv.indexOf('--source');
|
||||
const sourceLocation = sourceArgument >= 0
|
||||
? process.argv[sourceArgument + 1]
|
||||
: 'https://raw.githubusercontent.com/aria2/aria2/release-1.37.0/doc/manual-src/en/aria2c.rst';
|
||||
|
||||
if (!sourceLocation) throw new Error('--source requires a path or URL');
|
||||
|
||||
const source = /^https:\/\//.test(sourceLocation)
|
||||
? await fetch(sourceLocation, { signal: AbortSignal.timeout(15000) }).then(response => {
|
||||
if (!response.ok) throw new Error(`Aria2 manual fetch failed with HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
: fs.readFileSync(sourceLocation, 'utf8');
|
||||
|
||||
function section(start, end) {
|
||||
const startIndex = source.indexOf(`${start}\n`);
|
||||
const endIndex = source.indexOf(`${end}\n`, startIndex + start.length);
|
||||
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find manual section ${start} -> ${end}`);
|
||||
return source.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function options(text) {
|
||||
const names = [...text.matchAll(/^\.\. option:: .*?(--[a-z0-9-]+)/gm)]
|
||||
.map(match => match[1].slice(2));
|
||||
return [...new Set(names)].sort();
|
||||
}
|
||||
|
||||
const normal = options(section('HTTP/FTP/SFTP Options', 'BitTorrent/Metalink Options'));
|
||||
const torrent = options(
|
||||
`${section('BitTorrent/Metalink Options', 'BitTorrent Specific Options')}\n${section('BitTorrent Specific Options', 'Metalink Specific Options')}`,
|
||||
);
|
||||
|
||||
if (torrent.length !== 46) {
|
||||
throw new Error(`Expected 46 unique Aria2 1.37.0 Torrent options, found ${torrent.length}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
source: sourceLocation,
|
||||
aria2Version: '1.37.0',
|
||||
normal: { count: normal.length, options: normal },
|
||||
torrent: { count: torrent.length, options: torrent },
|
||||
}, null, 2));
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -41,14 +41,15 @@ function runNpmScript(script) {
|
||||
run('npm', ['run', script]);
|
||||
}
|
||||
|
||||
// A packaged Tauri artifact must have its own consent identity. A source or
|
||||
// commit fingerprint cannot distinguish two fresh signed/package builds made
|
||||
// from the same checkout, which would let an updated binary silently reuse the
|
||||
// previous binary's credential-store approval. Keep any configured identity
|
||||
// as useful provenance, but always add the artifact nonce.
|
||||
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
|
||||
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,200 @@
|
||||
#!/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) {
|
||||
let result;
|
||||
try {
|
||||
result = await run(command, args);
|
||||
} 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 once more before creating the AppImage so a failed preparation
|
||||
// cannot produce an artifact that later appears valid only because its
|
||||
// payload is absent.
|
||||
await runChecked(
|
||||
process.execPath,
|
||||
['scripts/verify-binaries.js', '--staged', '--target', target],
|
||||
'staged 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');
|
||||
|
||||
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]);
|
||||
});
|
||||
+323
-42
@@ -7,6 +7,14 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const userAgent = 'firelink-update-check';
|
||||
const fetchRetryDelaysMs = [250, 1_000];
|
||||
const fetchTimeoutMs = 30_000;
|
||||
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function httpResponseError(response, url) {
|
||||
const status = [response.status, response.statusText].filter(Boolean).join(' ');
|
||||
return new Error(`${status}: ${url}`);
|
||||
}
|
||||
|
||||
function parseJsonFile(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
|
||||
@@ -32,36 +40,119 @@ 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) {
|
||||
const details = error.stderr?.toString().trim();
|
||||
throw new Error(details || `npm outdated failed in ${cwd}`);
|
||||
}
|
||||
const output = error.stdout?.toString() || '{}';
|
||||
return JSON.parse(output || '{}');
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -73,6 +164,52 @@ async function latestMartinRiedlMacArm64Release() {
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function latestBtbnFfmpegN81Build() {
|
||||
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
|
||||
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)$/);
|
||||
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?.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 printNpmReport(label, outdated) {
|
||||
const entries = Object.entries(outdated);
|
||||
if (!entries.length) {
|
||||
@@ -90,7 +227,7 @@ function sourceEngineVersions(sourceLock) {
|
||||
const rows = [];
|
||||
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(engines)) {
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -100,22 +237,58 @@ function packagedEngineVersions(engineLock) {
|
||||
const rows = [];
|
||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
||||
rows.push({ target, engine, version: meta.version });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url, sha256: meta.sha256 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
|
||||
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 status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
|
||||
if (status === 'outdated') outdated += 1;
|
||||
const latestUrl = latestUrlsByTargetEngine[targetKey];
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
const latestHash = latestHashesByTargetEngine[targetKey] || latestHashesByUrl[row.url];
|
||||
const currentHash = typeof row.sha256 === 'string' ? row.sha256.toLowerCase() : '';
|
||||
const hashOutdated = Boolean(latestHash && currentHash !== latestHash);
|
||||
const status = versionOutdated
|
||||
? 'outdated'
|
||||
: sourceOutdated
|
||||
? 'source-outdated'
|
||||
: hashOutdated
|
||||
? 'hash-outdated'
|
||||
: 'current';
|
||||
if (status !== 'current') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
||||
if (hashOutdated) console.log(` sha256: ${row.sha256 || 'missing'} -> ${latestHash}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
@@ -125,54 +298,162 @@ async function main() {
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Firefox extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [ytDlp, deno, aria2, ffmpeg, martinRiedlMacArm64Ffmpeg] = await Promise.all([
|
||||
githubLatest('yt-dlp/yt-dlp'),
|
||||
githubLatest('denoland/deno'),
|
||||
githubLatest('aria2/aria2'),
|
||||
latestFfmpegStable(),
|
||||
latestMartinRiedlMacArm64Release(),
|
||||
]);
|
||||
const providerChecks = [
|
||||
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
|
||||
['Deno latest release', () => githubLatest('denoland/deno')],
|
||||
['aria2 latest release', () => githubLatest('aria2/aria2')],
|
||||
[
|
||||
'FFmpeg stable release',
|
||||
async () => {
|
||||
const version = await latestFfmpegStable();
|
||||
if (!version) throw new Error('FFmpeg release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
],
|
||||
[
|
||||
'Martin Riedl macOS release',
|
||||
async () => {
|
||||
const version = await latestMartinRiedlMacArm64Release();
|
||||
if (!version) throw new Error('Martin Riedl macOS release provider response has no usable version');
|
||||
return version;
|
||||
},
|
||||
],
|
||||
[
|
||||
'Martin Riedl macOS snapshot',
|
||||
async () => {
|
||||
const build = await latestMartinRiedlMacArm64Snapshot();
|
||||
if (!build?.version || !build.url) {
|
||||
throw new Error('Martin Riedl FFmpeg provider response has no complete macOS arm64 snapshot');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
],
|
||||
[
|
||||
'BtbN FFmpeg Windows/Linux build',
|
||||
async () => {
|
||||
const build = await latestBtbnFfmpegN81Build();
|
||||
if (
|
||||
!build?.version ||
|
||||
!build.urls?.windows ||
|
||||
!build.urls?.linux ||
|
||||
!build.hashes?.windows ||
|
||||
!build.hashes?.linux
|
||||
) {
|
||||
throw new Error('BtbN FFmpeg provider response has no complete Windows/Linux build with SHA-256 digests');
|
||||
}
|
||||
return build;
|
||||
},
|
||||
],
|
||||
];
|
||||
const providerResults = await Promise.allSettled(providerChecks.map(([, check]) => check()));
|
||||
const providerFailures = [];
|
||||
for (const [index, [label]] of providerChecks.entries()) {
|
||||
const result = providerResults[index];
|
||||
if (result.status === 'rejected') {
|
||||
const detail = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
providerFailures.push(label);
|
||||
console.error(`provider unavailable: ${label}: ${detail}`);
|
||||
}
|
||||
}
|
||||
const providerValue = index =>
|
||||
providerResults[index].status === 'fulfilled' ? providerResults[index].value : undefined;
|
||||
const ytDlp = providerValue(0);
|
||||
const deno = providerValue(1);
|
||||
const aria2 = providerValue(2);
|
||||
const ffmpeg = providerValue(3);
|
||||
const martinRiedlMacArm64Snapshot = providerValue(5);
|
||||
const btbnFfmpegN81Build = providerValue(6);
|
||||
const latestByEngine = {
|
||||
'yt-dlp': ytDlp.tag_name,
|
||||
deno: deno.tag_name,
|
||||
aria2c: aria2.tag_name,
|
||||
'yt-dlp': ytDlp?.tag_name,
|
||||
deno: deno?.tag_name,
|
||||
aria2c: aria2?.tag_name,
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {};
|
||||
const latestUrlsByTargetEngine = {};
|
||||
const latestHashesByTargetEngine = {};
|
||||
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
|
||||
if (btbnFfmpegN81Build?.version && btbnFfmpegN81Build.urls?.windows && btbnFfmpegN81Build.urls?.linux) {
|
||||
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.version;
|
||||
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.version;
|
||||
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.urls.windows;
|
||||
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.urls.linux;
|
||||
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.hashes?.windows;
|
||||
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.hashes?.linux;
|
||||
}
|
||||
if (martinRiedlMacArm64Snapshot?.version && martinRiedlMacArm64Snapshot.url) {
|
||||
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.version;
|
||||
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.url;
|
||||
}
|
||||
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
|
||||
|
||||
console.log('\nlatest engines:');
|
||||
for (const [engine, version] of Object.entries(latestByEngine)) {
|
||||
console.log(` ${engine}: ${normalizeVersion(version)}`);
|
||||
console.log(` ${engine}: ${displayVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${displayVersion(btbnFfmpegN81Build?.version)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${displayVersion(martinRiedlMacArm64Snapshot?.version)}`);
|
||||
|
||||
const targetSpecificEngines = new Set(['ffmpeg']);
|
||||
const engineCheckFailures = [];
|
||||
const runEngineCheck = (label, rows) => {
|
||||
try {
|
||||
return checkRows(
|
||||
rows,
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine,
|
||||
targetSpecificEngines,
|
||||
latestHashesByTargetEngine,
|
||||
latestHashesByUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'LATEST_VERSION_UNAVAILABLE') throw error;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
engineCheckFailures.push(label);
|
||||
console.error(`engine provider unavailable: ${label}: ${detail}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
outdatedCount += runEngineCheck(
|
||||
'engine source lock',
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
outdatedCount += runEngineCheck(
|
||||
'packaged engine lock',
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
console.error(`\n${outdatedCount} outdated item(s) found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (providerFailures.length > 0) {
|
||||
console.error(`\n${providerFailures.length} provider check(s) unavailable; refusing to claim that all updates are current.`);
|
||||
}
|
||||
if (engineCheckFailures.length > 0) {
|
||||
console.error(`\n${engineCheckFailures.length} engine lock check(s) unavailable; refusing to claim that all engines are current.`);
|
||||
}
|
||||
if (providerFailures.length > 0 || engineCheckFailures.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll checked packages and engines are current.');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { checkRows, fetchJson, fetchText, fetchWithContext, npmExecutable, providerAssetHashes };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { checkRows, fetchJson, fetchText, npmExecutable, providerAssetHashes } from './check-updates.js';
|
||||
|
||||
async function withMockFetch(mockFetch, callback) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test('fetchJson retries transient HTTP responses before succeeding', async () => {
|
||||
let attempts = 0;
|
||||
const result = await withMockFetch(async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) return new Response('temporarily unavailable', { status: 503 });
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}, () => fetchJson('https://example.test/releases'));
|
||||
|
||||
assert.deepEqual(result, { status: 'ok' });
|
||||
assert.equal(attempts, 3);
|
||||
});
|
||||
|
||||
test('fetchText does not retry terminal HTTP responses', async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
withMockFetch(async () => {
|
||||
attempts += 1;
|
||||
return new Response('not found', { status: 404 });
|
||||
}, () => fetchText('https://example.test/missing')),
|
||||
/404: https:\/\/example\.test\/missing/
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('checkRows fails closed when a latest version is unavailable', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
checkRows(
|
||||
[{ target: 'test-target', engine: 'test-engine', version: '1.0.0', url: 'https://example.test/engine' }],
|
||||
{}
|
||||
),
|
||||
/Latest version is unavailable for test-target test-engine/
|
||||
);
|
||||
});
|
||||
|
||||
test('checkRows does not fall back to the generic release for target-specific engines', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
checkRows(
|
||||
[{ target: 'test-target', engine: 'ffmpeg', version: '8.1.2', url: 'https://example.test/engine' }],
|
||||
{ ffmpeg: '9.0.1' },
|
||||
{},
|
||||
{},
|
||||
new Set(['ffmpeg'])
|
||||
),
|
||||
/Latest provider version is unavailable for test-target ffmpeg/
|
||||
);
|
||||
});
|
||||
|
||||
test('checkRows detects a provider hash change when version and URL are current', () => {
|
||||
const outdated = checkRows(
|
||||
[{
|
||||
target: 'test-target',
|
||||
engine: 'test-engine',
|
||||
version: '1.0.0',
|
||||
url: 'https://example.test/engine',
|
||||
sha256: 'a'.repeat(64),
|
||||
}],
|
||||
{ 'test-engine': '1.0.0' },
|
||||
{},
|
||||
{},
|
||||
new Set(),
|
||||
{},
|
||||
{ 'https://example.test/engine': 'b'.repeat(64) },
|
||||
);
|
||||
|
||||
assert.equal(outdated, 1);
|
||||
});
|
||||
|
||||
test('checkRows detects an aria2 asset digest change when the provider supplies it', () => {
|
||||
const url = 'https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip';
|
||||
const digest = 'b'.repeat(64);
|
||||
const hashes = providerAssetHashes({
|
||||
aria2: { assets: [{ browser_download_url: url, digest: `sha256:${digest}` }] },
|
||||
});
|
||||
|
||||
const outdated = checkRows(
|
||||
[{
|
||||
target: 'x86_64-pc-windows-msvc',
|
||||
engine: 'aria2c',
|
||||
version: '1.37.0',
|
||||
url,
|
||||
sha256: 'a'.repeat(64),
|
||||
}],
|
||||
{ aria2c: '1.37.0' },
|
||||
{},
|
||||
{},
|
||||
new Set(),
|
||||
{},
|
||||
hashes,
|
||||
);
|
||||
|
||||
assert.equal(outdated, 1);
|
||||
});
|
||||
|
||||
test('npm executable selection uses the Windows command shim when needed', () => {
|
||||
assert.equal(npmExecutable('win32'), 'npm.cmd');
|
||||
assert.equal(npmExecutable('darwin'), 'npm');
|
||||
assert.equal(npmExecutable('linux'), 'npm');
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import fs from 'node:fs';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const DEFAULT_ATTEMPTS = 3;
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000];
|
||||
const FILE_RESET_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function parseContentRange(value) {
|
||||
const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(value || '');
|
||||
if (!match) return undefined;
|
||||
const start = Number(match[1]);
|
||||
const end = Number(match[2]);
|
||||
const total = match[3] === '*' ? undefined : Number(match[3]);
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
|
||||
return undefined;
|
||||
}
|
||||
if (total !== undefined && (!Number.isSafeInteger(total) || end >= total)) {
|
||||
return undefined;
|
||||
}
|
||||
return { start, end, total };
|
||||
}
|
||||
|
||||
function responseLength(response) {
|
||||
const value = response.headers.get('content-length');
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
const length = Number(value);
|
||||
return Number.isSafeInteger(length) ? length : undefined;
|
||||
}
|
||||
|
||||
function createDownloadTimeout(idleTimeoutMs) {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`Download idle for ${idleTimeoutMs}ms`));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
if (signal?.reason instanceof Error) return signal.reason;
|
||||
return new Error('Engine archive download aborted');
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
}
|
||||
|
||||
function combineAbortSignals(signals) {
|
||||
const activeSignals = signals.filter(Boolean);
|
||||
const controller = new AbortController();
|
||||
const listeners = [];
|
||||
const abort = signal => {
|
||||
if (!controller.signal.aborted) controller.abort(abortReason(signal));
|
||||
};
|
||||
|
||||
for (const signal of activeSignals) {
|
||||
const listener = () => abort(signal);
|
||||
listeners.push([signal, listener]);
|
||||
if (signal.aborted) abort(signal);
|
||||
else signal.addEventListener('abort', listener, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose() {
|
||||
for (const [signal, listener] of listeners) {
|
||||
signal.removeEventListener('abort', listener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function archiveSize(archive) {
|
||||
try {
|
||||
return fs.statSync(archive).size;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function checksumMismatchError(name, expected, actual) {
|
||||
const error = new Error(`Archive checksum mismatch for ${name}. Expected ${expected}, got ${actual}`);
|
||||
error.code = 'ARCHIVE_CHECKSUM_MISMATCH';
|
||||
return error;
|
||||
}
|
||||
|
||||
function sleep(milliseconds, signal) {
|
||||
if (!signal) return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const finish = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
timer = setTimeout(finish, milliseconds);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
async function resetArchive(archive) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(archive, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= FILE_RESET_RETRY_DELAYS_MS.length) throw error;
|
||||
await sleep(FILE_RESET_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and checksum an engine archive, resuming an interrupted response
|
||||
* when the provider honors HTTP range requests. A provider that ignores the
|
||||
* range is handled safely by replacing the partial file instead of appending
|
||||
* a second full archive to it.
|
||||
*/
|
||||
export async function downloadEngineArchive({
|
||||
name,
|
||||
url,
|
||||
archive,
|
||||
expectedSha256,
|
||||
attempts = DEFAULT_ATTEMPTS,
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
||||
signal,
|
||||
}) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const partialSize = archiveSize(archive);
|
||||
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
|
||||
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
|
||||
const requestSignal = combineAbortSignals([signal, downloadTimeout.signal]);
|
||||
let resetForRetry = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
|
||||
redirect: 'follow',
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
|
||||
if (response.status === 416 && partialSize > 0) {
|
||||
await response.body?.cancel();
|
||||
resetForRetry = true;
|
||||
throw new Error(`Retained partial archive range is not satisfiable for ${name}`);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const contentRange = response.status === 206
|
||||
? parseContentRange(response.headers.get('content-range'))
|
||||
: undefined;
|
||||
if (response.status === 206 && (!contentRange || contentRange.start !== partialSize)) {
|
||||
await response.body.cancel();
|
||||
throw new Error(`Invalid Content-Range while downloading ${name}`);
|
||||
}
|
||||
|
||||
const append = response.status === 206 && partialSize > 0;
|
||||
const expectedResponseLength = responseLength(response);
|
||||
if (!append && partialSize > 0) {
|
||||
// The provider ignored Range and returned the complete archive.
|
||||
await resetArchive(archive);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadTimeout.refresh();
|
||||
callback(null, chunk, encoding);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
|
||||
{ signal: requestSignal.signal },
|
||||
);
|
||||
|
||||
throwIfAborted(signal);
|
||||
const finalSize = archiveSize(archive);
|
||||
const expectedFinalSize = contentRange?.total
|
||||
?? (expectedResponseLength === undefined
|
||||
? undefined
|
||||
: (append ? partialSize + expectedResponseLength : expectedResponseLength));
|
||||
if (expectedFinalSize !== undefined && finalSize !== expectedFinalSize) {
|
||||
throw new Error(
|
||||
`Incomplete archive for ${name}: expected ${expectedFinalSize} bytes, got ${finalSize}`,
|
||||
);
|
||||
}
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual === expectedSha256) return archive;
|
||||
|
||||
resetForRetry = true;
|
||||
throw checksumMismatchError(name, expectedSha256, actual);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
lastError = error;
|
||||
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
|
||||
await resetArchive(archive);
|
||||
}
|
||||
if (attempt === attempts) {
|
||||
throw new Error(
|
||||
`Failed to download ${name} after ${attempts} attempts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await sleep(retryDelaysMs[attempt - 1] ?? 0, signal);
|
||||
} finally {
|
||||
requestSignal.dispose();
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
|
||||
async function withMockFetch(mockFetch, callback) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function makeBody(chunks, failure) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(Buffer.from(chunk));
|
||||
if (failure) controller.error(failure);
|
||||
else controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeArchivePath() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-download-'));
|
||||
return {
|
||||
directory,
|
||||
archive: path.join(directory, 'engine.zip'),
|
||||
};
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
test('resumes an interrupted archive response from the retained partial file', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive payload');
|
||||
const prefix = full.subarray(0, 8);
|
||||
const middle = full.subarray(8, 15);
|
||||
const suffix = full.subarray(15);
|
||||
fs.writeFileSync(archive, Buffer.concat([prefix, middle]));
|
||||
const ranges = [];
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
ranges.push(options.headers?.Range);
|
||||
if (calls === 1) {
|
||||
return new Response(makeBody([], new Error('connection reset')), {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Length': String(suffix.length),
|
||||
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(makeBody([suffix]), {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Length': String(suffix.length),
|
||||
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
|
||||
},
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 2,
|
||||
retryDelaysMs: [0],
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
ranges,
|
||||
[`bytes=${prefix.length + middle.length}-`, `bytes=${prefix.length + middle.length}-`],
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('replaces a partial archive when the provider ignores the Range request', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive after range fallback');
|
||||
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
|
||||
let requestedRange;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
requestedRange = options.headers?.Range;
|
||||
return new Response(full, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(full.length) },
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(requestedRange, 'bytes=19-');
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts a complete retained archive without issuing an unsatisfiable range', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive retained after a late connection reset');
|
||||
fs.writeFileSync(archive, full);
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async () => {
|
||||
calls += 1;
|
||||
throw new Error('fetch should not be called for a complete retained archive');
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('restarts from zero after an unsatisfiable retained range', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const full = Buffer.from('complete archive after a stale range response');
|
||||
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
|
||||
const ranges = [];
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
ranges.push(options.headers?.Range);
|
||||
if (calls === 1) return new Response(null, { status: 416 });
|
||||
return new Response(full, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(full.length) },
|
||||
});
|
||||
}, async () => {
|
||||
await downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(full),
|
||||
attempts: 2,
|
||||
retryDelaysMs: [0],
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(ranges, ['bytes=19-', undefined]);
|
||||
assert.deepEqual(fs.readFileSync(archive), full);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates external cancellation without retrying an in-flight archive', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const abortController = new AbortController();
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
assert.equal(options.signal.aborted, false);
|
||||
setTimeout(() => {
|
||||
abortController.abort(new Error('provisioning interrupted'));
|
||||
}, 10);
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(Buffer.from('partial archive bytes'));
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '100' },
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(Buffer.from('never completed')),
|
||||
attempts: 3,
|
||||
retryDelaysMs: [500, 500],
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
error => {
|
||||
assert.match(error.message, /provisioning interrupted/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.ok(fs.statSync(archive).size > 0);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function pathExists(value) {
|
||||
try {
|
||||
fs.lstatSync(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function previousPayloadPrefix(destination) {
|
||||
return `.${path.basename(destination)}.previous-`;
|
||||
}
|
||||
|
||||
function previousPayloads(destination) {
|
||||
const parent = path.dirname(destination);
|
||||
const prefix = previousPayloadPrefix(destination);
|
||||
return fs.readdirSync(parent, { withFileTypes: true })
|
||||
.filter(entry => entry.name.startsWith(prefix))
|
||||
.map(entry => path.join(parent, entry.name));
|
||||
}
|
||||
|
||||
function previousPayloadOwner(destination, candidate) {
|
||||
const suffix = path.basename(candidate).slice(previousPayloadPrefix(destination).length);
|
||||
const separator = suffix.indexOf('-');
|
||||
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOrphanedPreviousPayloads(destination) {
|
||||
for (const candidate of previousPayloads(destination)) {
|
||||
const owner = previousPayloadOwner(destination, candidate);
|
||||
if (owner === null || isProcessAlive(owner)) continue;
|
||||
await removePathWithRetry(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function provisioningTemporaryPrefix(target) {
|
||||
return `.firelink-engines-${target}-`;
|
||||
}
|
||||
|
||||
function provisioningTemporaryOwner(target, candidate) {
|
||||
const suffix = path.basename(candidate).slice(provisioningTemporaryPrefix(target).length);
|
||||
const separator = suffix.indexOf('-');
|
||||
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only staging directories created by a provisioner whose PID is no
|
||||
* longer alive. Legacy directories without an owner PID remain untouched so
|
||||
* a concurrent or ambiguous provisioner can never lose its staging tree.
|
||||
*/
|
||||
export async function removeOrphanedProvisioningDirectories(destinationParent, target) {
|
||||
const prefix = provisioningTemporaryPrefix(target);
|
||||
for (const entry of fs.readdirSync(destinationParent, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
||||
const candidate = path.join(destinationParent, entry.name);
|
||||
const owner = provisioningTemporaryOwner(target, candidate);
|
||||
if (owner === null || owner === process.pid || isProcessAlive(owner)) continue;
|
||||
await removePathWithRetry(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the only previous payload left by a process that died after moving
|
||||
* the destination aside but before publishing its replacement. Multiple
|
||||
* candidates are ambiguous and remain untouched for manual recovery.
|
||||
*/
|
||||
export function recoverInterruptedPromotion(destination) {
|
||||
if (pathExists(destination)) return;
|
||||
|
||||
const candidates = previousPayloads(destination);
|
||||
if (candidates.length === 0) return;
|
||||
if (candidates.length > 1) {
|
||||
throw new Error(
|
||||
`Cannot recover engine payload at ${destination}: found ${candidates.length} previous payloads`
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = candidates[0];
|
||||
if (!fs.lstatSync(candidate).isDirectory()) {
|
||||
throw new Error(`Cannot recover engine payload from non-directory backup: ${candidate}`);
|
||||
}
|
||||
fs.renameSync(candidate, destination);
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
export async function removePathWithRetry(value) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(value, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= RETRY_DELAYS_MS.length) throw error;
|
||||
await sleep(RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a fully verified payload without exposing a partially written
|
||||
* directory. The staging directory must share a filesystem with destination.
|
||||
*/
|
||||
export async function promoteDirectory(staging, destination) {
|
||||
recoverInterruptedPromotion(destination);
|
||||
const stagingStats = fs.lstatSync(staging);
|
||||
if (!stagingStats.isDirectory()) {
|
||||
throw new Error(`Engine payload staging path is not a directory: ${staging}`);
|
||||
}
|
||||
|
||||
const parent = path.dirname(destination);
|
||||
const backup = path.join(
|
||||
parent,
|
||||
`.${path.basename(destination)}.previous-${process.pid}-${process.hrtime.bigint()}`
|
||||
);
|
||||
let movedExisting = false;
|
||||
|
||||
try {
|
||||
if (pathExists(destination)) {
|
||||
fs.renameSync(destination, backup);
|
||||
movedExisting = true;
|
||||
}
|
||||
fs.renameSync(staging, destination);
|
||||
} catch (error) {
|
||||
if (movedExisting && !pathExists(destination) && pathExists(backup)) {
|
||||
try {
|
||||
fs.renameSync(backup, destination);
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[error, restoreError],
|
||||
`Failed to publish engine payload and restore the previous payload at ${destination}`
|
||||
);
|
||||
}
|
||||
} else if (movedExisting && pathExists(destination) && pathExists(backup)) {
|
||||
// Another provisioner won the promotion race; discard only our backup.
|
||||
await removePathWithRetry(backup);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (movedExisting) await removePathWithRetry(backup);
|
||||
await removeOrphanedPreviousPayloads(destination);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
function temporaryDirectory() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-payload-promotion-'));
|
||||
}
|
||||
|
||||
test('promotes a verified staging directory and replaces the previous payload', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
assert.equal(fs.existsSync(staging), false);
|
||||
assert.equal(fs.readdirSync(root).some(name => name.includes('.previous-')), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an invalid staging path without removing the previous payload', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const staging = path.join(root, 'staging-file');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.writeFileSync(staging, 'not a directory');
|
||||
|
||||
await assert.rejects(() => promoteDirectory(staging, destination), /not a directory/);
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'old');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('recovers one interrupted previous payload before publishing a replacement', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const backup = path.join(root, '.target.previous-123-456');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(backup, { recursive: true });
|
||||
fs.writeFileSync(path.join(backup, 'engine'), 'old');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
assert.equal(fs.existsSync(backup), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when interrupted recovery has multiple candidates', () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const firstBackup = path.join(root, '.target.previous-123-456');
|
||||
const secondBackup = path.join(root, '.target.previous-789-012');
|
||||
fs.mkdirSync(firstBackup, { recursive: true });
|
||||
fs.mkdirSync(secondBackup, { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => recoverInterruptedPromotion(destination),
|
||||
/found 2 previous payloads/
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
assert.equal(fs.existsSync(firstBackup), true);
|
||||
assert.equal(fs.existsSync(secondBackup), true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes a temporary payload tree after it becomes disposable', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const temporary = path.join(root, 'temporary');
|
||||
fs.mkdirSync(path.join(temporary, 'nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(temporary, 'nested', 'archive'), 'payload');
|
||||
|
||||
await removePathWithRetry(temporary);
|
||||
|
||||
assert.equal(fs.existsSync(temporary), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes orphaned backups from dead provisioners after a successful publish', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const destination = path.join(root, 'target');
|
||||
const orphanedBackup = path.join(root, '.target.previous-999999999-123456');
|
||||
const staging = path.join(root, 'staging', 'payload');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, 'engine'), 'old');
|
||||
fs.mkdirSync(orphanedBackup, { recursive: true });
|
||||
fs.writeFileSync(path.join(orphanedBackup, 'engine'), 'orphaned');
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'engine'), 'new');
|
||||
|
||||
await promoteDirectory(staging, destination);
|
||||
|
||||
assert.equal(fs.existsSync(orphanedBackup), false);
|
||||
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes only provisioning staging owned by a dead PID', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const target = 'x86_64-unknown-linux-gnu';
|
||||
const orphaned = path.join(root, `.firelink-engines-${target}-999999999-dead`);
|
||||
const live = path.join(root, `.firelink-engines-${target}-${process.pid}-live`);
|
||||
const legacy = path.join(root, `.firelink-engines-${target}-legacy`);
|
||||
fs.mkdirSync(orphaned, { recursive: true });
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
|
||||
await removeOrphanedProvisioningDirectories(root, target);
|
||||
|
||||
assert.equal(fs.existsSync(orphaned), false);
|
||||
assert.equal(fs.existsSync(live), true);
|
||||
assert.equal(fs.existsSync(legacy), true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
@@ -32,42 +40,61 @@ if (!targetSources) {
|
||||
}
|
||||
|
||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
const provisioningAbortController = new AbortController();
|
||||
const signalNames = process.platform === 'win32'
|
||||
? ['SIGINT', 'SIGTERM']
|
||||
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
const signalHandlers = new Map();
|
||||
for (const signalName of signalNames) {
|
||||
const handler = () => {
|
||||
if (!provisioningAbortController.signal.aborted) {
|
||||
provisioningAbortController.abort(new Error(`Engine provisioning interrupted by ${signalName}`));
|
||||
}
|
||||
};
|
||||
signalHandlers.set(signalName, handler);
|
||||
process.on(signalName, handler);
|
||||
}
|
||||
|
||||
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'}`
|
||||
);
|
||||
const response = await fetch(source.url, { redirect: 'follow' });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
const output = fs.createWriteStream(archive);
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!output.write(Buffer.from(value))) {
|
||||
await new Promise(resolve => output.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => output.end(resolve));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -89,13 +116,13 @@ function findFile(root, names) {
|
||||
}
|
||||
|
||||
function copyExecutable(source, engine) {
|
||||
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
|
||||
const output = path.join(payloadDestination, `${engine}-${target}${executableSuffix}`);
|
||||
fs.copyFileSync(source, output);
|
||||
if (!isWindows) fs.chmodSync(output, 0o755);
|
||||
}
|
||||
|
||||
function writePayloadManifest() {
|
||||
const files = collectRegularFiles(destination, {
|
||||
const files = collectRegularFiles(payloadDestination, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
});
|
||||
const manifest = {
|
||||
@@ -113,27 +140,36 @@ function writePayloadManifest() {
|
||||
),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(destination, file).split(path.sep).join('/'),
|
||||
path.relative(payloadDestination, file).split(path.sep).join('/'),
|
||||
sha256(file)
|
||||
])
|
||||
)
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(destination, 'payload-manifest.json'),
|
||||
path.join(payloadDestination, 'payload-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const destinationParent = path.dirname(destination);
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
recoverInterruptedPromotion(destination);
|
||||
await removeOrphanedProvisioningDirectories(destinationParent, target);
|
||||
throwIfProvisioningAborted();
|
||||
// Keep staging on the destination filesystem so the final rename is atomic.
|
||||
temporary = fs.mkdtempSync(
|
||||
path.join(destinationParent, `.firelink-engines-${target}-${process.pid}-`)
|
||||
);
|
||||
payloadDestination = path.join(temporary, 'payload');
|
||||
fs.mkdirSync(payloadDestination, { recursive: true });
|
||||
|
||||
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
||||
copyExecutable(
|
||||
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
||||
'yt-dlp'
|
||||
);
|
||||
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
|
||||
fs.cpSync(path.join(ytdlp, '_internal'), path.join(payloadDestination, '_internal'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true
|
||||
});
|
||||
@@ -148,7 +184,12 @@ try {
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
throwIfProvisioningAborted();
|
||||
await promoteDirectory(payloadDestination, destination);
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
if (temporary) await removePathWithRetry(temporary);
|
||||
for (const [signalName, handler] of signalHandlers) {
|
||||
process.removeListener(signalName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('release Linux dependency installation is mirror-normalized and bounded', () => {
|
||||
assert.match(releaseWorkflow, /azure\\\.archive\\\.ubuntu\\\.com/);
|
||||
assert.equal((releaseWorkflow.match(/Acquire::Retries=3/g) || []).length, 2);
|
||||
assert.equal((releaseWorkflow.match(/timeout --foreground --signal=TERM --kill-after=30s 10m apt-get/g) || []).length, 2);
|
||||
assert.doesNotMatch(releaseWorkflow, /^\s*sudo apt-get (update|install)/m);
|
||||
});
|
||||
|
||||
test('macOS release verification uses the app mounted from the final DMG', () => {
|
||||
assert.match(releaseWorkflow, /npm run verify:macos-signing -- --dmg "\$DMG"/);
|
||||
assert.match(releaseWorkflow, /hdiutil attach -nobrowse -readonly -mountpoint "\$MOUNT_POINT" "\$DMG"/);
|
||||
assert.match(releaseWorkflow, /find "\$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink\.app'/);
|
||||
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
|
||||
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
const platform = {
|
||||
darwin: 'apple-darwin',
|
||||
linux: 'unknown-linux-gnu',
|
||||
win32: 'pc-windows-msvc',
|
||||
}[process.platform];
|
||||
if (!arch || !platform) {
|
||||
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
||||
}
|
||||
const targetTriple = `${arch}-${platform}`;
|
||||
const argumentIndex = process.argv.indexOf('--binary');
|
||||
const binaryPath = path.resolve(
|
||||
argumentIndex >= 0
|
||||
? process.argv[argumentIndex + 1]
|
||||
: path.join(
|
||||
repoRoot,
|
||||
'src-tauri',
|
||||
'binaries',
|
||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
}
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = address && typeof address !== 'string' ? address.port : undefined;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
if (!port) throw new Error('Could not reserve a local port');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function rpc(port, secret, method, params = []) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params: [`token:${secret}`, ...params],
|
||||
}),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs = 3000) {
|
||||
if (childExited(child)) return true;
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRpc(port, secret) {
|
||||
const deadline = Date.now() + 10000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await rpc(port, secret, 'aria2.getVersion');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
function bencode(value) {
|
||||
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||
if (typeof value === 'string') return bencode(Buffer.from(value));
|
||||
if (typeof value === 'number') return Buffer.from(`i${value}e`);
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||
return Buffer.concat([
|
||||
Buffer.from('d'),
|
||||
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
|
||||
Buffer.from('e'),
|
||||
]);
|
||||
}
|
||||
throw new Error(`Unsupported bencode value: ${typeof value}`);
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
let exited = await waitForChildExit(child);
|
||||
if (!exited) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch {
|
||||
// The process may have exited between the timeout and taskkill.
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited && process.platform !== 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited) {
|
||||
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
|
||||
const secret = `firelink-resolver-${crypto.randomUUID()}`;
|
||||
const rpcPort = await availablePort();
|
||||
const contentServer = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-length': '1' });
|
||||
response.end('x');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
contentServer.once('error', reject);
|
||||
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const contentPort = contentServer.address().port;
|
||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--rpc-secret=${secret}`,
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
|
||||
|
||||
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
||||
'async-dns': 'false',
|
||||
out: 'resolver-normal.bin',
|
||||
}]);
|
||||
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
||||
if (uriOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
|
||||
}
|
||||
|
||||
const torrent = bencode({
|
||||
info: {
|
||||
length: 1,
|
||||
name: 'resolver-torrent.bin',
|
||||
pieces: Buffer.alloc(20),
|
||||
'piece length': 16384,
|
||||
},
|
||||
}).toString('base64');
|
||||
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||
'async-dns': 'false',
|
||||
dir: tempRoot,
|
||||
}]);
|
||||
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
||||
if (torrentOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
|
||||
}
|
||||
|
||||
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
} finally {
|
||||
await new Promise(resolve => contentServer.close(resolve));
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
const platform = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
|
||||
if (!arch || !platform) throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
||||
const targetTriple = `${arch}-${platform}`;
|
||||
const argumentIndex = process.argv.indexOf('--binary');
|
||||
const binaryPath = path.resolve(argumentIndex >= 0
|
||||
? process.argv[argumentIndex + 1]
|
||||
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function listen(server) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Could not allocate local fixture port');
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
const port = await listen(server);
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function rpc(port, secret, method, params = []) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params: [`token:${secret}`, ...params],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
async function waitForRpc(port, secret) {
|
||||
const deadline = Date.now() + 10000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await rpc(port, secret, 'aria2.getVersion');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
async function waitForTerminal(port, secret, gid, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, [
|
||||
'status', 'errorCode', 'errorMessage', 'completedLength', 'totalLength',
|
||||
]]);
|
||||
if (['complete', 'error', 'removed'].includes(latest.status)) return latest;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} did not become terminal: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
async function waitForProgress(port, secret, gid, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, ['status', 'completedLength']]);
|
||||
if (latest.status === 'active' && Number(latest.completedLength) > 0) return latest;
|
||||
await wait(25);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} made no observable progress: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
function serveBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
if (!match) {
|
||||
response.writeHead(200, { 'content-length': String(buffer.length), 'accept-ranges': 'bytes' });
|
||||
response.end(buffer);
|
||||
return;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const requestedEnd = match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(206, {
|
||||
'content-length': String(body.length),
|
||||
'content-range': `bytes ${start}-${end}/${buffer.length}`,
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function serveThrottledBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
const requestedEnd = match && match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(match ? 206 : 200, {
|
||||
'content-length': String(body.length),
|
||||
...(match ? { 'content-range': `bytes ${start}-${end}/${buffer.length}` } : {}),
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
let offset = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || offset >= body.length) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(offset + 32 * 1024, body.length);
|
||||
response.write(body.subarray(offset, next));
|
||||
offset = next;
|
||||
}, 20);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs = 8000) {
|
||||
if (childExited(child)) return true;
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// It may already be stopping.
|
||||
}
|
||||
let exited = await waitForChildExit(child);
|
||||
if (!exited) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch {
|
||||
// It may have exited between the timeout and taskkill.
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited && process.platform !== 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited) throw new Error(`Aria2 process ${child.pid} did not exit after cleanup`);
|
||||
}
|
||||
|
||||
async function removeTempRoot(tempRoot) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100 * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
|
||||
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
|
||||
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
|
||||
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
|
||||
let finalRequests = 0;
|
||||
let finalCredentials = [];
|
||||
let redirectSourceCredentials;
|
||||
let smokePassed = false;
|
||||
let smokeFailure;
|
||||
|
||||
const targetServer = http.createServer((request, response) => {
|
||||
finalRequests += 1;
|
||||
finalCredentials.push({
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
});
|
||||
serveBuffer(request, response, payload);
|
||||
});
|
||||
const targetPort = await listen(targetServer);
|
||||
const fixtureServer = http.createServer((request, response) => {
|
||||
switch (new URL(request.url, 'http://fixture.invalid').pathname) {
|
||||
case '/range':
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
case '/no-range':
|
||||
response.writeHead(200, { 'content-length': String(payload.length) });
|
||||
response.end(payload);
|
||||
break;
|
||||
case '/throttled':
|
||||
serveThrottledBuffer(request, response, payload);
|
||||
break;
|
||||
case '/authenticated': {
|
||||
const expectedAuthorization = `Basic ${Buffer.from('fixture-user:fixture-password').toString('base64')}`;
|
||||
if (request.headers.authorization !== expectedAuthorization
|
||||
|| request.headers.cookie !== 'fixture-cookie=present'
|
||||
|| request.headers['x-firelink-auth'] !== 'present') {
|
||||
response.writeHead(401, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
}
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
}
|
||||
case '/redirect':
|
||||
redirectSourceCredentials = {
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
};
|
||||
response.writeHead(302, { location: `http://127.0.0.1:${targetPort}/final` });
|
||||
response.end();
|
||||
break;
|
||||
case '/missing':
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
case '/malformed':
|
||||
response.writeHead(200, { 'content-length': String(payload.length * 2) });
|
||||
response.write(payload.subarray(0, 1024));
|
||||
response.destroy();
|
||||
break;
|
||||
case '/slow': {
|
||||
const slowLength = 64 * 1024;
|
||||
let written = 0;
|
||||
response.writeHead(200, { 'content-length': String(slowLength) });
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || written >= slowLength) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
response.write(Buffer.alloc(1024, 0x73));
|
||||
written += 1024;
|
||||
}, 500);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
const fixturePort = await listen(fixtureServer);
|
||||
const rpcPort = await availablePort();
|
||||
const unavailableProxyPort = await availablePort();
|
||||
const secret = `firelink-transfers-${crypto.randomUUID()}`;
|
||||
const configPath = path.join(tempRoot, 'aria2.conf');
|
||||
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
|
||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--conf-path=${configPath}`,
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
`--server-stat-if=${serverStatPath}`,
|
||||
`--server-stat-of=${serverStatPath}`,
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke`);
|
||||
|
||||
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||
}]);
|
||||
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
||||
if (rangeStatus.status !== 'complete' || !fs.readFileSync(path.join(tempRoot, 'range.bin')).equals(payload)) {
|
||||
throw new Error(`bounded-range transfer failed: ${JSON.stringify(rangeStatus)}`);
|
||||
}
|
||||
|
||||
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
|
||||
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
|
||||
throw new Error('single-connection no-range transfer did not complete');
|
||||
}
|
||||
|
||||
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
|
||||
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
||||
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, authenticatedGid)).status !== 'complete') {
|
||||
throw new Error('authenticated cookie/header transfer did not complete');
|
||||
}
|
||||
|
||||
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'resume.bin', split: '1', continue: 'true',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, resumeGid);
|
||||
await rpc(rpcPort, secret, 'aria2.pause', [resumeGid]);
|
||||
const pausedStatus = await rpc(rpcPort, secret, 'aria2.tellStatus', [resumeGid, ['status', 'completedLength']]);
|
||||
if (pausedStatus.status !== 'paused' || Number(pausedStatus.completedLength) <= 0) {
|
||||
throw new Error(`normal transfer did not pause with resumable progress: ${JSON.stringify(pausedStatus)}`);
|
||||
}
|
||||
await rpc(rpcPort, secret, 'aria2.unpause', [resumeGid]);
|
||||
if ((await waitForTerminal(rpcPort, secret, resumeGid)).status !== 'complete') {
|
||||
throw new Error('paused normal transfer did not resume to completion');
|
||||
}
|
||||
|
||||
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'cancel.bin', split: '1',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, cancelGid);
|
||||
await rpc(rpcPort, secret, 'aria2.remove', [cancelGid]);
|
||||
const cancelStatus = await waitForTerminal(rpcPort, secret, cancelGid);
|
||||
if (cancelStatus.status !== 'removed') {
|
||||
throw new Error(`normal transfer cancellation did not reach removed: ${JSON.stringify(cancelStatus)}`);
|
||||
}
|
||||
|
||||
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
|
||||
`http://127.0.0.1:${fixturePort}/missing`,
|
||||
`http://127.0.0.1:${fixturePort}/range`,
|
||||
], { out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
|
||||
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
|
||||
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
|
||||
|
||||
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
|
||||
throw new Error('valid checksum transfer did not complete');
|
||||
}
|
||||
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||
if (mismatchStatus.status !== 'error') throw new Error(`checksum mismatch was not rejected: ${JSON.stringify(mismatchStatus)}`);
|
||||
|
||||
// Model Firelink's manual preflight: credentials reach the original origin,
|
||||
// the redirect is not followed by the HTTP client, and Aria2 receives only
|
||||
// the resolved cross-origin URL without credential options.
|
||||
const redirectProbe = await fetch(`http://127.0.0.1:${fixturePort}/redirect`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-0',
|
||||
Authorization: 'Bearer fixture-secret',
|
||||
Cookie: 'fixture=secret',
|
||||
'X-Firelink-Secret': 'fixture',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (redirectProbe.status !== 302) throw new Error(`redirect preflight returned HTTP ${redirectProbe.status}`);
|
||||
if (!redirectSourceCredentials || Object.values(redirectSourceCredentials).some(value => !value)) {
|
||||
throw new Error(`redirect source did not receive its scoped credentials: ${JSON.stringify(redirectSourceCredentials)}`);
|
||||
}
|
||||
const redirectLocation = redirectProbe.headers.get('location');
|
||||
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
|
||||
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
|
||||
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
|
||||
out: 'redirect.bin',
|
||||
}]);
|
||||
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
|
||||
if (redirectStatus.status !== 'complete' || finalRequests === 0 || finalCredentials.some(headers => Object.values(headers).some(Boolean))) {
|
||||
throw new Error(`redirect credential boundary failed: ${JSON.stringify({ redirectStatus, finalRequests, finalCredentials })}`);
|
||||
}
|
||||
|
||||
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
|
||||
out: 'missing.bin', 'max-tries': '1',
|
||||
}]);
|
||||
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
|
||||
if (missingStatus.status !== 'error' || !['3', '4'].includes(missingStatus.errorCode)) {
|
||||
throw new Error(`not-found error classification changed: ${JSON.stringify(missingStatus)}`);
|
||||
}
|
||||
|
||||
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
|
||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||
}]);
|
||||
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
||||
if (lowSpeedStatus.status !== 'error' || lowSpeedStatus.errorCode !== '5') {
|
||||
throw new Error(`low-speed error classification changed: ${JSON.stringify(lowSpeedStatus)}`);
|
||||
}
|
||||
|
||||
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
|
||||
out: 'malformed.bin', 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
|
||||
throw new Error('malformed response unexpectedly completed');
|
||||
}
|
||||
|
||||
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||
throw new Error('unavailable proxy unexpectedly completed');
|
||||
}
|
||||
|
||||
smokePassed = true;
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
if (smokePassed) {
|
||||
const stat = fs.readFileSync(serverStatPath, 'utf8');
|
||||
if (!stat.includes('host=127.0.0.1')) {
|
||||
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([
|
||||
new Promise(resolve => fixtureServer.close(resolve)),
|
||||
new Promise(resolve => targetServer.close(resolve)),
|
||||
]);
|
||||
await removeTempRoot(tempRoot);
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (smokeFailure) throw smokeFailure;
|
||||
console.log('[PASS] Aria2 normal transfers, auth/cookies, resume/cancel, mirrors, integrity, redirects, low-speed/not-found classification, malformed responses, proxy failures, and server statistics');
|
||||
+269
-26
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
@@ -15,6 +16,14 @@ if (!executableArg) {
|
||||
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const MAX_STABILITY_MS = 60_000;
|
||||
const MAX_CONSECUTIVE_STABILITY_FAILURES = 3;
|
||||
const stabilityMsValue = Number.parseInt(argValue('--stability-ms') || '5000', 10);
|
||||
const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
|
||||
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
||||
: 5000;
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
@@ -30,12 +39,14 @@ const child = spawn(executable, [], {
|
||||
let stderr = '';
|
||||
let spawnError = null;
|
||||
let readyPort = null;
|
||||
let childExit = null;
|
||||
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
if (readyPort === null) {
|
||||
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
|
||||
}
|
||||
@@ -44,6 +55,7 @@ child.on('exit', (code, signal) => {
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -51,19 +63,25 @@ function sleep(ms) {
|
||||
|
||||
async function findReadyPort() {
|
||||
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
|
||||
if (spawnError) {
|
||||
if (spawnError || childExit) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
|
||||
const matches = await Promise.all(ports.map(async port => {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (response.headers.get('x-firelink-server') === '1') {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}/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 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
readyPort = matches.find(port => port !== null) ?? null;
|
||||
|
||||
if (readyPort === null) {
|
||||
await sleep(250);
|
||||
@@ -71,6 +89,55 @@ async function findReadyPort() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkReadyPort() {
|
||||
if (readyPort === null) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${readyPort}/ping`, {
|
||||
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
|
||||
});
|
||||
const matchesChild = response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
|
||||
await response.body?.cancel();
|
||||
return matchesChild;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStableReady() {
|
||||
const deadline = Date.now() + stabilityMs;
|
||||
let consecutiveFailures = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (spawnError) {
|
||||
throw new Error(`Packaged Firelink failed during stability check: ${spawnError.message}`);
|
||||
}
|
||||
if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
}
|
||||
if (await checkReadyPort()) {
|
||||
consecutiveFailures = 0;
|
||||
} else {
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_STABILITY_FAILURES) {
|
||||
throw new Error('Packaged Firelink stopped exposing its extension ping endpoint during stability check.');
|
||||
}
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
}
|
||||
if (!await checkReadyPort() && !await checkReadyPort()) {
|
||||
throw new Error('Packaged Firelink was not healthy at the end of its stability check.');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoVisibleWindows(rootPid) {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
@@ -120,43 +187,219 @@ if ($visible.Count -gt 0) {
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
function waitForChildExit(timeoutMs) {
|
||||
if (childExit) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let timer;
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(true);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(rootPid) {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
process.kill(-rootPid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
await findReadyPort();
|
||||
async function waitForProcessGroupExit(rootPid, timeoutMs) {
|
||||
if (process.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessGroupAlive(rootPid)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
return !isProcessGroupAlive(rootPid);
|
||||
}
|
||||
|
||||
function windowsBundleRoot() {
|
||||
return path.dirname(executable).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function windowsBundleProcessIds() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
Select-Object -ExpandProperty ProcessId
|
||||
`;
|
||||
try {
|
||||
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map(value => Number.parseInt(value.trim(), 10))
|
||||
.filter(Number.isInteger);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function terminateWindowsBundleProcesses() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
`;
|
||||
try {
|
||||
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function waitForWindowsBundleExit(timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const processIds = windowsBundleProcessIds();
|
||||
if (processIds?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return windowsBundleProcessIds()?.length === 0;
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const childWasRunning = !childExit;
|
||||
if (process.platform === 'win32') {
|
||||
if (childWasRunning) {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
const childExited = await waitForChildExit(10000);
|
||||
const bundleExited = await waitForWindowsBundleExit(5000);
|
||||
if (childExited && bundleExited) {
|
||||
return true;
|
||||
}
|
||||
terminateWindowsBundleProcesses();
|
||||
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
|
||||
}
|
||||
|
||||
if (childWasRunning && !childExit) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
const childExited = await waitForChildExit(5000);
|
||||
const processGroupExited = await waitForProcessGroupExit(child.pid, 5000);
|
||||
if (childExited && processGroupExited) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
if (!childExited) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
|
||||
}
|
||||
|
||||
async function assertPortableStorage() {
|
||||
const portableRoot = path.dirname(executable);
|
||||
const marker = path.join(portableRoot, 'portable.flag');
|
||||
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
|
||||
const webviewData = path.join(portableRoot, 'data', 'webview');
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
|
||||
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
|
||||
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
|
||||
if (markerReady && databaseReady && webviewReady) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await findReadyPort();
|
||||
|
||||
if (readyPort === null) {
|
||||
if (spawnError) {
|
||||
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
} else if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
if (assertPortableData) {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
await assertStableReady();
|
||||
|
||||
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;
|
||||
} finally {
|
||||
terminateChild();
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+203
-52
@@ -2,6 +2,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -44,16 +45,34 @@ const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
const matches = [];
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const hasExpectedEngineFile = directory => {
|
||||
try {
|
||||
return fs.lstatSync(path.join(directory, expected)).isFile();
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
|
||||
}
|
||||
};
|
||||
if (hasExpectedEngineFile(resolvedRoot)) {
|
||||
matches.push(resolvedRoot);
|
||||
}
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
||||
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
|
||||
walk(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.resolve(root));
|
||||
walk(resolvedRoot);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
||||
}
|
||||
@@ -317,6 +336,82 @@ function runEngine(label, engine, args, timeout = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProcessExit(proc, timeoutMs) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
proc.removeListener('exit', onExit);
|
||||
resolve(value);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
proc.once('exit', onExit);
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
finish(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateProcess(proc, label) {
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not determine the verifier RPC port.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const port = address.port;
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPortBindingFailure(message) {
|
||||
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
|
||||
}
|
||||
|
||||
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
||||
if (canExecuteTarget) {
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
||||
@@ -345,25 +440,6 @@ if (canExecuteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
@@ -371,39 +447,114 @@ if (canExecuteTarget) {
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
let result = { ok: false, error: 'RPC test did not run.' };
|
||||
let rpcStderr = '';
|
||||
const maxPortAttempts = 3;
|
||||
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
|
||||
let port;
|
||||
try {
|
||||
port = await findAvailablePort();
|
||||
} catch (error) {
|
||||
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
|
||||
break;
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
let spawnError = null;
|
||||
let childExit = null;
|
||||
let attemptStderr = '';
|
||||
proc.once('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
proc.stderr.on('data', data => {
|
||||
attemptStderr += data.toString();
|
||||
});
|
||||
|
||||
const attemptResult = await new Promise(resolve => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function resolveProcessFailure() {
|
||||
if (spawnError) {
|
||||
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
|
||||
} else if (childExit) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tryFetch() {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async response => {
|
||||
resolve({ ok: true, data: await response.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
} else {
|
||||
setTimeout(tryFetch, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
const exitedBeforeCleanup = childExit;
|
||||
const terminated = proc.pid
|
||||
? await terminateProcess(proc, 'aria2 RPC verifier process')
|
||||
: true;
|
||||
rpcStderr = attemptStderr;
|
||||
result = attemptResult;
|
||||
|
||||
if (
|
||||
result.ok
|
||||
&& exitedBeforeCleanup
|
||||
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
|
||||
) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (portAttempt + 1 < maxPortAttempts) {
|
||||
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/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'));
|
||||
}
|
||||
|
||||
function exactVersionTag(extensionRoot, expectedTag) {
|
||||
try {
|
||||
const tags = execFileSync(
|
||||
'git',
|
||||
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
)
|
||||
.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,118 @@
|
||||
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 { 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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDebianPackagePath(line) {
|
||||
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse a Debian package path: ${line}`);
|
||||
}
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
export function isSafePackagePath(packagePath) {
|
||||
if (packagePath === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = packagePath.split('/');
|
||||
return !parts.includes('..') && (parts[0] === 'usr' || packagePath === 'usr');
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
? lines.map(line => {
|
||||
try {
|
||||
return parseDebianPackagePath(line);
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
if (!isSafePackagePath(packagePath)) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bsdtar', [
|
||||
'--extract',
|
||||
'--file', packageFile,
|
||||
'--directory', destination,
|
||||
'--no-same-owner',
|
||||
'--no-same-permissions',
|
||||
]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
|
||||
'usr/share/'
|
||||
);
|
||||
});
|
||||
|
||||
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
|
||||
'usr/bin/firelink'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts the package root in legacy dpkg-deb listings', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x root/root 0 2026-07-12 07:24 ./'),
|
||||
''
|
||||
);
|
||||
assert.equal(isSafePackagePath(''), true);
|
||||
});
|
||||
|
||||
test('rejects paths outside the package usr tree', () => {
|
||||
assert.equal(isSafePackagePath('../tmp/firelink'), false);
|
||||
assert.equal(isSafePackagePath('etc/firelink'), false);
|
||||
});
|
||||
|
||||
test('rejects malformed dpkg-deb listing lines', () => {
|
||||
assert.throws(
|
||||
() => parseDebianPackagePath('not a dpkg-deb listing'),
|
||||
/Could not parse a Debian package path/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const appArg = argValue('--app');
|
||||
const dmgArg = argValue('--dmg');
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
console.error('macOS signing verification must run on a macOS host.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!appArg && !dmgArg) {
|
||||
console.error('Pass --app <Firelink.app> and/or --dmg <Firelink.dmg>.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`[OK] ${message}`);
|
||||
}
|
||||
|
||||
function note(message) {
|
||||
console.log(`[INFO] ${message}`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function runResult(command, args) {
|
||||
return spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function assertAppPath(appPath, label) {
|
||||
if (!fs.existsSync(appPath)) {
|
||||
fail(`${label} does not exist at ${appPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(appPath);
|
||||
if (!stat.isDirectory() || path.extname(appPath) !== '.app') {
|
||||
fail(`${label} is not an .app bundle: ${appPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
ok(`${label} exists: ${appPath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function codesignDetails(targetPath) {
|
||||
const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]);
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
output: `${result.stdout || ''}${result.stderr || ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
function verifyCodeSignature(targetPath, label, options = {}) {
|
||||
const {
|
||||
deep = false,
|
||||
quietOk = false,
|
||||
requireAdhoc = false,
|
||||
warnOnVerifyFailure = false,
|
||||
} = options;
|
||||
const verifyArgs = ['--verify'];
|
||||
if (deep) {
|
||||
verifyArgs.push('--deep');
|
||||
}
|
||||
verifyArgs.push('--strict', '--verbose=2', targetPath);
|
||||
|
||||
const details = codesignDetails(targetPath);
|
||||
if (!details.ok) {
|
||||
fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
let status = 'verified';
|
||||
try {
|
||||
run('codesign', verifyArgs);
|
||||
if (!quietOk) {
|
||||
ok(`${label}: codesign verification passed`);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
if (!warnOnVerifyFailure) {
|
||||
fail(`${label}: codesign verification failed: ${detail}`);
|
||||
return 'failed';
|
||||
}
|
||||
note(`${label}: signed, but individual verification warned: ${detail}`);
|
||||
status = 'warning';
|
||||
}
|
||||
|
||||
if (requireAdhoc && !details.output.includes('Signature=adhoc')) {
|
||||
fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`);
|
||||
return 'failed';
|
||||
}
|
||||
if (requireAdhoc && !quietOk) {
|
||||
ok(`${label}: ad-hoc signature present`);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function walkFiles(root, visitor) {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(entryPath, visitor);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
visitor(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fileBrief(filePath) {
|
||||
try {
|
||||
return run('file', ['--brief', filePath], { timeout: 5000 }).trim();
|
||||
} catch (error) {
|
||||
fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMachOObjects(appPath, label) {
|
||||
const machOFiles = [];
|
||||
walkFiles(appPath, filePath => {
|
||||
const description = fileBrief(filePath);
|
||||
if (description.includes('Mach-O')) {
|
||||
machOFiles.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (machOFiles.length === 0) {
|
||||
fail(`${label}: no Mach-O files found inside app bundle`);
|
||||
return;
|
||||
}
|
||||
|
||||
let warningCount = 0;
|
||||
let signedCount = 0;
|
||||
let verifiedCount = 0;
|
||||
for (const filePath of machOFiles) {
|
||||
const relative = path.relative(appPath, filePath).split(path.sep).join('/');
|
||||
const isPrimaryExecutable = relative === 'Contents/MacOS/firelink';
|
||||
const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative);
|
||||
const mayWarn = !isPrimaryExecutable && !isDirectEngine;
|
||||
|
||||
const result = verifyCodeSignature(filePath, `${label} ${relative}`, {
|
||||
quietOk: true,
|
||||
warnOnVerifyFailure: mayWarn,
|
||||
});
|
||||
if (result !== 'failed') {
|
||||
signedCount += 1;
|
||||
if (result === 'verified') {
|
||||
verifiedCount += 1;
|
||||
} else {
|
||||
warningCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`);
|
||||
ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`);
|
||||
if (warningCount > 0) {
|
||||
note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assessGatekeeper(appPath, label) {
|
||||
const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]);
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||
|
||||
if (result.status === 0) {
|
||||
note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) {
|
||||
fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`);
|
||||
return;
|
||||
}
|
||||
|
||||
note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`);
|
||||
}
|
||||
|
||||
function reportQuarantine(targetPath, label) {
|
||||
const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]);
|
||||
if (result.status === 0) {
|
||||
fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`);
|
||||
} else {
|
||||
ok(`${label}: no quarantine xattr on generated artifact`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyApp(appPath, label) {
|
||||
const resolved = path.resolve(appPath);
|
||||
if (!assertAppPath(resolved, label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportQuarantine(resolved, label);
|
||||
verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true });
|
||||
verifyMachOObjects(resolved, label);
|
||||
assessGatekeeper(resolved, label);
|
||||
}
|
||||
|
||||
function attachDmg(dmgPath) {
|
||||
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-'));
|
||||
try {
|
||||
const plist = run('hdiutil', [
|
||||
'attach',
|
||||
'-plist',
|
||||
'-nobrowse',
|
||||
'-readonly',
|
||||
'-mountpoint',
|
||||
mountPoint,
|
||||
dmgPath,
|
||||
], { timeout: 60000 });
|
||||
return { mountPoint, plist };
|
||||
} catch (error) {
|
||||
fs.rmSync(mountPoint, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function detachDmg(mountPoint) {
|
||||
const result = runResult('hdiutil', ['detach', mountPoint]);
|
||||
if (result.status !== 0) {
|
||||
note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`);
|
||||
const forced = runResult('hdiutil', ['detach', '-force', mountPoint]);
|
||||
if (forced.status !== 0) {
|
||||
fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`);
|
||||
}
|
||||
}
|
||||
fs.rmSync(mountPoint, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function verifyDmg(dmgPath) {
|
||||
const resolved = path.resolve(dmgPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
fail(`DMG does not exist at ${resolved}`);
|
||||
return;
|
||||
}
|
||||
|
||||
reportQuarantine(resolved, 'DMG');
|
||||
let mount;
|
||||
try {
|
||||
mount = attachDmg(resolved);
|
||||
ok(`DMG mounted at ${mount.mountPoint}`);
|
||||
const apps = fs.readdirSync(mount.mountPoint)
|
||||
.filter(name => name.endsWith('.app'))
|
||||
.map(name => path.join(mount.mountPoint, name));
|
||||
if (apps.length !== 1) {
|
||||
fail(`Expected exactly one .app inside DMG, found ${apps.length}`);
|
||||
return;
|
||||
}
|
||||
verifyApp(apps[0], 'DMG app');
|
||||
} catch (error) {
|
||||
fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`);
|
||||
} finally {
|
||||
if (mount) {
|
||||
detachDmg(mount.mountPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appArg) {
|
||||
verifyApp(appArg, 'Built app');
|
||||
}
|
||||
|
||||
if (dmgArg) {
|
||||
verifyDmg(dmgArg);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (exitCode !== 0) {
|
||||
console.error('[FAIL] macOS signing verification failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[PASS] macOS ad-hoc signing verification passed.');
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = path.resolve(scriptDirectory, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function readPackageVersion(root) {
|
||||
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
}
|
||||
|
||||
function readTauriVersion(root) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')
|
||||
).version;
|
||||
}
|
||||
|
||||
function readCargoVersion(root) {
|
||||
const cargo = fs.readFileSync(path.join(root, 'src-tauri', 'Cargo.toml'), 'utf8');
|
||||
const packageSection = cargo.match(/^\[package\]\s*([\s\S]*?)(?=^\[)/m)?.[1];
|
||||
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (!version) {
|
||||
throw new Error('Could not read the [package] version from src-tauri/Cargo.toml.');
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function versionFromRef(ref) {
|
||||
if (!ref || !ref.startsWith('v')) {
|
||||
throw new Error(`Expected a semantic version tag such as v1.2.3, received ${ref || 'nothing'}.`);
|
||||
}
|
||||
const version = ref.slice(1);
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`Release tag ${ref} does not contain a valid semantic version.`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
const versions = {
|
||||
'package.json': readPackageVersion(repositoryRoot),
|
||||
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
|
||||
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
|
||||
};
|
||||
const uniqueVersions = new Set(Object.values(versions));
|
||||
if (uniqueVersions.size !== 1) {
|
||||
console.error('Application version manifests do not agree:');
|
||||
for (const [file, version] of Object.entries(versions)) console.error(` ${file}=${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const verifier = path.join(repositoryRoot, 'scripts', 'verify-release-version.js');
|
||||
const currentVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
function runVerifier(tag, ...extraArguments) {
|
||||
return spawnSync(process.execPath, [verifier, '--tag', tag, ...extraArguments], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('release version verifier accepts the aligned current version', () => {
|
||||
const result = runVerifier(`v${currentVersion}`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`Release version ${currentVersion} matches`));
|
||||
});
|
||||
|
||||
test('release version verifier rejects the already-published prior tag', () => {
|
||||
const result = runVerifier('v1.1.1');
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /does not match the application manifests/);
|
||||
});
|
||||
|
||||
test('release version verifier rejects non-semver tag names', () => {
|
||||
const result = runVerifier('release-candidate');
|
||||
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,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const capability = JSON.parse(fs.readFileSync('src-tauri/capabilities/default.json', 'utf8'));
|
||||
const permissions = new Set(capability.permissions);
|
||||
|
||||
test('custom window controls have required Tauri permissions', () => {
|
||||
const windowControls = fs.readFileSync('src/components/WindowControls.tsx', 'utf8');
|
||||
const requiredPermissions = new Map([
|
||||
['.close()', 'core:window:allow-close'],
|
||||
['.minimize()', 'core:window:allow-minimize'],
|
||||
['.startDragging()', 'core:window:allow-start-dragging'],
|
||||
['.toggleMaximize()', 'core:window:allow-toggle-maximize'],
|
||||
]);
|
||||
|
||||
for (const [apiCall, permission] of requiredPermissions) {
|
||||
if (windowControls.includes(apiCall)) {
|
||||
assert.equal(
|
||||
permissions.has(permission),
|
||||
true,
|
||||
`${apiCall} requires ${permission} in src-tauri/capabilities/default.json`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Generated
+1286
-960
File diff suppressed because it is too large
Load Diff
+21
-14
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.0"
|
||||
version = "1.4.0"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -25,44 +25,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"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-shell = "2"
|
||||
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.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
||||
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"] }
|
||||
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"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.18"
|
||||
thiserror = "2.0.19"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.6.11", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
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"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
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"] }
|
||||
objc = "0.2.7"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
unicode-normalization = "0.1.25"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
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]
|
||||
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
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"
|
||||
@@ -3528,35 +3499,6 @@ xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6
|
||||
t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
|
||||
# Subject: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
|
||||
# Label: "SecureSign Root CA12"
|
||||
# Serial: 587887345431707215246142177076162061960426065942
|
||||
# MD5 Fingerprint: c6:89:ca:64:42:9b:62:08:49:0b:1e:7f:e9:07:3d:e8
|
||||
# SHA1 Fingerprint: 7a:22:1e:3d:de:1b:06:ac:9e:c8:47:70:16:8e:3c:e5:f7:6b:06:f4
|
||||
# SHA256 Fingerprint: 3f:03:4b:b5:70:4d:44:b2:d0:85:45:a0:20:57:de:93:eb:f3:90:5f:ce:72:1a:cb:c7:30:c0:6d:da:ee:90:4e
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL
|
||||
BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u
|
||||
LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw
|
||||
NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD
|
||||
eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS
|
||||
b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF
|
||||
KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt
|
||||
p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd
|
||||
J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur
|
||||
FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J
|
||||
hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K
|
||||
h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
|
||||
AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF
|
||||
AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld
|
||||
mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ
|
||||
mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA
|
||||
8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV
|
||||
55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/
|
||||
yOPiZwud9AzqVN/Ssq+xIvEg37xEHA==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
|
||||
# Subject: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
|
||||
# Label: "SecureSign Root CA14"
|
||||
@@ -3890,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=lpNNgQ8844-2Q1nCwxiwtLw07Je0dsNXHPwhnLfnit4,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=bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab", "hashes": {"sha256": "bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab"}}, "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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
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