Compare commits
387 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5189146369 | |||
| a92d5689d7 | |||
| e5348d3213 | |||
| f726b058f7 | |||
| 4a322196de | |||
| 84a4ff4bfc | |||
| ef877463da | |||
| 4dc607b964 | |||
| c1f9407adb | |||
| 4026e5bc86 | |||
| 63abf23622 | |||
| ae9f14ad6e | |||
| 98be029203 | |||
| cfb5a5b6e0 | |||
| d274e3af17 | |||
| 7cde15b4c1 | |||
| 3458df01d1 | |||
| 53982eaef1 | |||
| 276446a4dd | |||
| 1e61d05873 | |||
| 6b7eaa1f66 | |||
| fdca17910a | |||
| 21fc115d98 | |||
| 2042f5c251 | |||
| e412b7c72b | |||
| b6e82491f3 | |||
| fcb7fc91f6 | |||
| 74275013dc | |||
| 35ccd5659e | |||
| 1d9b898e9c | |||
| 84e8b325c0 | |||
| b33d7d9ac5 | |||
| 78ce0ed006 | |||
| fc4eb6a3fb | |||
| 9f88c27b83 | |||
| df311374fd | |||
| 29885aa8fd | |||
| 5b4fe6cf82 | |||
| f2f9da1489 | |||
| d1863a76d8 | |||
| e957dd0b85 | |||
| dbbfd5dc7c | |||
| 20cbacd785 | |||
| 7e6706b3cf | |||
| 220290f6f0 | |||
| cf0af00887 | |||
| 0a77e15f86 | |||
| 4448895a6b | |||
| 2ff7a1be44 | |||
| 01ca6e604c | |||
| e79d32af74 | |||
| d5e0caac33 | |||
| 672565e274 | |||
| 97b3489004 | |||
| 34f4174213 | |||
| 2783460d50 | |||
| 4688c941e8 | |||
| f666b2e8f2 | |||
| 9edf0a570b | |||
| 19b4ab8a84 | |||
| 64b98282d8 | |||
| f7487c8ef6 | |||
| 1fd4a18d17 | |||
| 4a81945f9f | |||
| 73f894c154 | |||
| 191a08e817 | |||
| 77b940df60 | |||
| 1cba5ee556 | |||
| 8206d1d5e4 | |||
| b2b370161a | |||
| a22a26f4ce | |||
| e1e1061511 | |||
| eb7be82e71 | |||
| e1507a6c2c | |||
| d406cd327f | |||
| 9fdb6f1647 | |||
| 069c1b6788 | |||
| b4ff14805b | |||
| 8eb1a55e72 | |||
| 09f103ea04 | |||
| 0a647cd612 | |||
| 47373b4565 | |||
| e03a3b5dbb | |||
| b3f4074d67 | |||
| 68607d6909 | |||
| 8a258981ce | |||
| 187d70f348 | |||
| 00e84d6935 | |||
| b147ec5f78 | |||
| 1a687d0081 | |||
| fec20db57c | |||
| e1f62c949d | |||
| 7213606182 | |||
| 2fe1683f4b | |||
| 6f426c1a18 | |||
| af2c101ca8 | |||
| 8d23f0752f | |||
| 29193cca74 | |||
| d720308f30 | |||
| d05f4fb251 | |||
| 9043ebe429 | |||
| 941d05112b | |||
| b726d3a310 | |||
| 7ed7bceacb | |||
| 66252473d5 | |||
| 61bd79969c | |||
| 82ed38d55a | |||
| f603b74a99 | |||
| 0e3118ec2c | |||
| eae3cf6180 | |||
| ddd4662898 | |||
| 6301bf9fd0 | |||
| a802c947ec | |||
| 34f25ff3ea | |||
| 58fb0155b7 | |||
| 25a58c8356 | |||
| c5ee3ec3cd | |||
| 7c835b022f | |||
| 6b3753949b | |||
| f469c5d7eb | |||
| db4adc7f89 | |||
| f1ee3812ed | |||
| 25595d7ebd | |||
| 06b14df307 | |||
| d535bdac8f | |||
| 3660ceb47a | |||
| de1318deb6 | |||
| bf28029d2e | |||
| 9e8c4aacf7 | |||
| cd0397ea00 | |||
| 226c7913f5 | |||
| 340ef09efa | |||
| a8b792074f | |||
| 7ec855efd4 | |||
| 262633f441 | |||
| c59db4e032 | |||
| 15ab6520a2 | |||
| bb7387dbd1 | |||
| 52a7ce11eb | |||
| a7cb84d4b6 | |||
| 070c210d85 | |||
| 2cc00e433e | |||
| 038f31b988 | |||
| 79b579790d | |||
| 487e1fea43 | |||
| 894c238bb7 | |||
| 1660dd112d | |||
| d921876b72 | |||
| 6db21b7f14 | |||
| 3ca126e5db | |||
| 85df857678 | |||
| a2d7b689f3 | |||
| 70581e8524 | |||
| 28df7ca809 | |||
| 8a1619f056 | |||
| 1ae2fe7703 | |||
| de4d81ecd0 | |||
| 62945a5516 | |||
| 645d566540 | |||
| 34fcdcc04a | |||
| 94628b865f | |||
| 91d3b2cd67 | |||
| c90ae8bcc3 | |||
| c548cb4fe0 | |||
| 43e21bfc1e | |||
| 2130412e84 | |||
| 3a76c6f5d7 | |||
| e2dd387a8c | |||
| 5200c1e453 | |||
| 97802adb70 | |||
| 605992439d | |||
| 18de275d42 | |||
| db99e10238 | |||
| 2d27b14513 | |||
| a300e440e0 | |||
| 0a1d367a99 | |||
| 292240cb71 | |||
| 7d1d3e1f9b | |||
| b8b4f9e437 | |||
| 18063dc488 | |||
| 0b4dace54c | |||
| e638926598 | |||
| cb39113117 | |||
| 69a7192fda | |||
| 7252e43e68 | |||
| 1f150d78b1 | |||
| 6521457cfe | |||
| 374b861246 | |||
| 58f4a8a14d | |||
| 7c7317adc9 | |||
| f6851682dd | |||
| bb618aef7d | |||
| 1ec4d2aa17 | |||
| 4b7c00dae3 | |||
| 2727d07d62 | |||
| e41e761b33 | |||
| 4027ac39ab | |||
| 85cdabd72b | |||
| 275eb6e5c4 | |||
| b6678493d3 | |||
| 113f5d3943 | |||
| a40e6cfef8 | |||
| fde5eaacba | |||
| b4b3104c67 | |||
| ea6ede8a7f | |||
| e2744526d8 | |||
| f4b74898fd | |||
| 44c089fadf | |||
| 12761caf10 | |||
| d15e537916 | |||
| 601b04c4cb | |||
| 58f0f0dfb1 | |||
| 1b04860c94 | |||
| 789a8161b2 | |||
| 285ec0a81d | |||
| 22d59e3c11 | |||
| 503e69a499 | |||
| 9e26be5b2c | |||
| d046c95c28 | |||
| 6cf360bce0 | |||
| 2ddcd2d99a | |||
| e7608dc8b4 | |||
| 6593f9e76a | |||
| ab7550d39e | |||
| 20dcb69ae2 | |||
| f806fd2cbf | |||
| 77fdc30b80 | |||
| b1b17b7ad8 | |||
| 4cfd59b45b | |||
| 34f51884bb | |||
| 58cbd0567d | |||
| 0e588fa360 | |||
| c82757fc2e | |||
| 7ab592b3d4 | |||
| 796d00d527 | |||
| 4ec1c4fdf6 | |||
| ae19f69e7b | |||
| d6fa8b6865 | |||
| a2b64aa6d1 | |||
| 240f62bc6e | |||
| 79cad4213b | |||
| 6284a0a8d6 | |||
| f0859cb4cd | |||
| 19e366089e | |||
| ba37d78cba | |||
| f6c7a0b8d2 | |||
| 88b97862c3 | |||
| c79dcfa74d | |||
| f8c89e2f88 | |||
| 9119cafdcd | |||
| 6de0cd268c | |||
| dc9289fece | |||
| 610eda203e | |||
| 8cd6c1972d | |||
| f378fc0d8d | |||
| 4c21a36083 | |||
| a5022fbf8a | |||
| 3074438147 | |||
| c3ad5c3c75 | |||
| 90b5397a46 | |||
| 59a1fa5cd5 | |||
| d6690c7769 | |||
| e6ab997b00 | |||
| 32c6dcc2d6 | |||
| 79f4c8f0e9 | |||
| 2ab3325f30 | |||
| 4856b3f3b2 | |||
| 99500254a1 | |||
| aa9b3aad2a | |||
| 1c8f189b54 | |||
| 11b030f66a | |||
| e165366820 | |||
| 7b3d620efa | |||
| db293a2613 | |||
| 49a4358212 | |||
| a916ff3f92 | |||
| b4185306ce | |||
| 39355f8d2d | |||
| 55c6dbf41b | |||
| ae3476293e | |||
| facab68237 | |||
| 3a7c594d7c | |||
| 0ace3e4111 | |||
| 7e2e0eeb77 | |||
| 2a5452b7c6 | |||
| fdbacb8a7f | |||
| ffba883961 | |||
| 3b695169d6 | |||
| a5828ec586 | |||
| efb4446739 | |||
| fd90055ccd | |||
| 14924cf254 | |||
| 85f55db66f | |||
| cc89f48221 | |||
| 22cc11f751 | |||
| 88e99a8528 | |||
| 07e4a586a1 | |||
| f93f0daef5 | |||
| 3b4402def3 | |||
| 62297f61ac | |||
| 17dc080a20 | |||
| 709f189f27 | |||
| ce9fb4a072 | |||
| b7380bea35 | |||
| 0ec2213a4c | |||
| 17aa34b95f |
@@ -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 |
@@ -0,0 +1,80 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend checks
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: node --test scripts/*.node-test.js
|
||||
- run: npm test -- --run
|
||||
- run: npm run build
|
||||
|
||||
desktop:
|
||||
name: Desktop checks (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- 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 \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libdbus-1-dev \
|
||||
pkg-config
|
||||
- run: npm ci
|
||||
- name: Test Rust backend
|
||||
if: runner.os != 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets --target ${{ matrix.target }}
|
||||
- name: Test Rust backend
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
cargo test --tests --target ${{ matrix.target }}
|
||||
cargo test --lib --no-run --target ${{ matrix.target }}
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Stage and verify engines
|
||||
run: |
|
||||
node scripts/stage-engines.js --target ${{ matrix.target }}
|
||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||
@@ -2,126 +2,272 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version, for example 0.1.0"
|
||||
required: true
|
||||
default: "0.1.0"
|
||||
publish_release:
|
||||
description: 'Publish the GitHub release after all platform build and verification jobs pass'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos-arm64-dmg:
|
||||
name: Build macOS ARM64 DMG
|
||||
runs-on: macos-26
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.label }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: macOS-arm64
|
||||
os: macos-26
|
||||
target: aarch64-apple-darwin
|
||||
bundles: app,dmg
|
||||
artifact: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
- label: Windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundles: nsis
|
||||
artifact: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
- label: Linux-x64-AppImage
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
bundles: appimage
|
||||
artifact: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
TAG_NAME="v${VERSION}"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Show build environment
|
||||
run: |
|
||||
uname -a
|
||||
swift --version
|
||||
xcodebuild -version
|
||||
xcode-select -p
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
|
||||
- name: Verify macOS 26 SDK
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
|
||||
SDK_MAJOR="${SDK_VERSION%%.*}"
|
||||
|
||||
if [[ "$SDK_MAJOR" != "26" ]]; then
|
||||
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install aria2 dylibbundler
|
||||
|
||||
- name: Fetch media engines
|
||||
run: |
|
||||
mkdir -p Sources/Firelink
|
||||
|
||||
# Download the one-folder macOS build. The direct one-file binary is not code-signable.
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o yt-dlp.zip
|
||||
mkdir -p yt-dlp-macos
|
||||
unzip -q -o yt-dlp.zip -d yt-dlp-macos
|
||||
mv yt-dlp-macos/yt-dlp_macos Sources/Firelink/yt-dlp
|
||||
cp -R yt-dlp-macos/_internal Sources/Firelink/_internal
|
||||
rm -rf yt-dlp.zip yt-dlp-macos
|
||||
chmod +x Sources/Firelink/yt-dlp
|
||||
|
||||
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
|
||||
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
|
||||
unzip -q -o ffmpeg.zip -d Sources/Firelink
|
||||
rm ffmpeg.zip
|
||||
chmod +x Sources/Firelink/ffmpeg
|
||||
test -x Sources/Firelink/yt-dlp
|
||||
test -x Sources/Firelink/ffmpeg
|
||||
|
||||
- name: Build app bundle
|
||||
env:
|
||||
MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
BUILD_NUMBER: ${{ github.run_number }}
|
||||
run: Scripts/create_app_bundle.sh
|
||||
|
||||
- name: Verify ARM64 binary
|
||||
run: |
|
||||
file build/Firelink.app/Contents/MacOS/Firelink
|
||||
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
|
||||
codesign --verify --deep build/Firelink.app
|
||||
|
||||
- name: Create DMG
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
ARCH: arm64
|
||||
run: Scripts/create_dmg.sh
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
|
||||
path: dist/*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG_NAME="${{ steps.version.outputs.tag_name }}"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
appstream \
|
||||
curl \
|
||||
patchelf \
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
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
|
||||
- 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
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0
|
||||
run: |
|
||||
curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool"
|
||||
echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c -
|
||||
chmod +x "$RUNNER_TEMP/appimagetool"
|
||||
- name: Repack AppImage with engines (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool"
|
||||
- name: Verify macOS packaged engines and launch
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
||||
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
- name: Verify Windows installer payload and launch
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1
|
||||
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
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
|
||||
$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
|
||||
if ($install.ExitCode -ne 0) { throw "Silent NSIS install failed with exit code $($install.ExitCode)." }
|
||||
$exe = $null
|
||||
foreach ($attempt in 1..60) {
|
||||
$exe = Get-ChildItem $installRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1
|
||||
if ($exe) { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." }
|
||||
node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows
|
||||
- name: Verify Linux AppImage payload and launch
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
APPIMAGE="$(find src-tauri/target/${{ matrix.target }}/release/bundle/appimage -name '*.AppImage' -print -quit)"
|
||||
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 }}
|
||||
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
|
||||
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: >-
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
(
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
)
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
- name: Extract changelog release notes
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
|
||||
if [[ -z "$source" ]]; then
|
||||
echo "::error::Missing release asset matching $pattern"
|
||||
exit 1
|
||||
fi
|
||||
mv "$source" "release-assets/$destination"
|
||||
}
|
||||
|
||||
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
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 ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-assets/**
|
||||
body_path: release_notes.md
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
.build/
|
||||
build/
|
||||
dist/
|
||||
*.dmg
|
||||
DerivedData/
|
||||
skills-lock.json
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.sw?
|
||||
*.xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Local secrets and exported signing keys
|
||||
# Local agent and planning notes
|
||||
AGENT.md
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
|
||||
# Frontend output and logs
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.local
|
||||
|
||||
# Rust and Tauri output
|
||||
target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/engine-dist/
|
||||
src-tauri/provisioned-engines/
|
||||
|
||||
# Locally provisioned native engines
|
||||
src-tauri/binaries/aria2c
|
||||
src-tauri/binaries/deno
|
||||
src-tauri/binaries/ffmpeg
|
||||
src-tauri/binaries/yt-dlp
|
||||
|
||||
# Packaged artifacts
|
||||
build/
|
||||
*.dmg
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.msi
|
||||
*.exe
|
||||
|
||||
# Local secrets and signing material
|
||||
.env
|
||||
.env.*
|
||||
*.key
|
||||
*.pem
|
||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||
*.p8
|
||||
*.p12
|
||||
*.cer
|
||||
@@ -22,9 +68,17 @@ sparkle_private_key*
|
||||
SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
ffmpeg
|
||||
Sources/Firelink/_internal/
|
||||
!Sources/Firelink/_internal/
|
||||
Sources/Firelink/_internal/*
|
||||
!Sources/Firelink/_internal/.gitkeep
|
||||
|
||||
# Legacy Swift build output and downloaded engines
|
||||
legacy/swift/.build/
|
||||
legacy/swift/DerivedData/
|
||||
legacy/swift/Sources/Firelink/*-version.txt
|
||||
legacy/swift/Sources/Firelink/yt-dlp
|
||||
legacy/swift/Sources/Firelink/deno
|
||||
legacy/swift/Sources/Firelink/ffmpeg
|
||||
legacy/swift/Sources/Firelink/aria2c
|
||||
legacy/swift/Sources/Firelink/_internal/*
|
||||
legacy/swift/Sources/Firelink/aria2-libs/
|
||||
legacy/swift/Sources/Firelink/aria2-licenses/
|
||||
legacy/swift/Sources/Firelink/aria2-cacert.pem
|
||||
!legacy/swift/Sources/Firelink/_internal/.gitkeep
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
@@ -1,10 +1,207 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
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.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
|
||||
- Firelink is now a cross-platform desktop download manager built with Rust, Tauri, React, and TypeScript.
|
||||
- macOS, Windows, and Linux release builds are prepared through native GitHub Actions runners.
|
||||
- The older macOS-only Swift app has been removed from the active product and replaced by the new desktop app.
|
||||
|
||||
### New
|
||||
- Add full browser-to-desktop integration through Firelink Companion, including signed local requests, desktop identity checks, and automatic-capture protection.
|
||||
- Add a complete Add window for links captured from the browser, pasted manually, or extracted from media pages.
|
||||
- Add segmented direct downloads, media downloads, queue controls, scheduling, speed limits, and duplicate handling.
|
||||
- Add persistent downloads, categories, save-location settings, site logins, and safe redownload behavior.
|
||||
- Add native tray controls, notifications, completion sounds, sleep prevention, file reveal/open/trash actions, and OS keychain support where available.
|
||||
- Add built-in diagnostics for media engines, app logs, packaged-engine validation, and release smoke checks.
|
||||
|
||||
### Improved
|
||||
- Rework the main window, settings, logs, Add window, download list, toasts, context menus, and table interactions for the new desktop interface.
|
||||
- Improve media extraction with better YouTube metadata loading, format filtering, size estimates, output names, progress, retries, and cleanup.
|
||||
- Improve download reliability on rate-limited servers, servers with broken range support, interrupted app sessions, and queue-concurrency changes.
|
||||
- Improve extension captures so browser downloads are resumed unless Firelink confirms the handoff.
|
||||
- Improve cross-platform packaging by bundling target-specific aria2, yt-dlp, FFmpeg, and Deno payloads.
|
||||
- Improve privacy by avoiding startup keychain prompts, redacting secrets from local persistence, and narrowing when browser cookies are forwarded.
|
||||
|
||||
### Fixed
|
||||
- Fix stuck queued/downloading/completed states, stale aria2 dispatches, queue shrink behavior, retry caps, scheduler actions, and post-queue system actions.
|
||||
- Fix many Windows, Linux, and macOS packaging issues, including hidden helper consoles, AppImage engine handling, installer validation, and smoke-test timing.
|
||||
- Fix media child-process cleanup so paused, canceled, failed, or removed downloads do not leave stray processes or partial files behind.
|
||||
- Fix UI hangs, overflow, sorting, selection, animation, and contrast issues across the main list, Add window, settings, and log viewer.
|
||||
- Fix security issues around local server authentication, request signing, replay protection, private IP metadata checks, path ownership, header sanitization, and command execution boundaries.
|
||||
|
||||
### Notes
|
||||
- This is a major release from `0.7.3`. Older Firelink Companion builds are not compatible with every 1.0 capture path; update the extension together with the desktop app.
|
||||
- macOS builds are unsigned and not notarized. Windows builds are unsigned. See [RELEASE.md](RELEASE.md) for distribution details.
|
||||
|
||||
## [0.7.3] - 2026-06-11
|
||||
|
||||
### New Features & Improvements
|
||||
- Add Deno to about credits and engines list.
|
||||
- Enhance speed and ETA display logic during pause and drop.
|
||||
- Accumulate track sizes to present a unified overall progress bar.
|
||||
|
||||
### Fixes
|
||||
- Resolve unknown speed flickering and ultra-wide high-resolution detection.
|
||||
- Emit distinct status messages for individual tracks during download.
|
||||
- Pad overall progress total for first track to prevent 100% snapback.
|
||||
|
||||
## [0.7.2] - 2026-06-11
|
||||
|
||||
### Fixed
|
||||
- Prevented yt-dlp and JavaScript child processes from keeping metadata fetches or canceled downloads alive indefinitely.
|
||||
- Replaced the repeatedly extracted one-file yt-dlp build with a stable prewarmed runtime cache.
|
||||
- Bundled Deno so YouTube JavaScript challenges and formats above 720p do not depend on system-installed tools.
|
||||
- Stopped masking empty-format extraction failures and removed brittle forced YouTube client selection.
|
||||
|
||||
### Changed
|
||||
- Pinned and checksum-verified yt-dlp, Deno, FFmpeg, aria2, and aria2's libraries for matching local and GitHub Actions builds.
|
||||
- Removed aria2's runtime dependency on Homebrew and configured its bundled CA certificate for direct and yt-dlp-delegated HTTPS downloads.
|
||||
- Added bounded network retries and optional aria2c acceleration for large direct media downloads.
|
||||
|
||||
## [0.7.1] - 2026-06-11
|
||||
|
||||
### Fixes
|
||||
- Increased the `yt-dlp` metadata extraction timeout to 120 seconds to properly handle YouTube's new JavaScript Proof-of-Work bot protection challenges.
|
||||
- Improved the `AddDownloadsView` UI to display the exact underlying error message during extraction failures rather than a generic masked string.
|
||||
|
||||
### Security Fixes
|
||||
- Addressed multiple vulnerabilities identified in the v0.7.0 security audit.
|
||||
- Moved `yt-dlp` credential passing from CLI arguments to secure temporary configuration files to prevent process list leakage.
|
||||
- Enforced strict `0o600` POSIX permissions on `aria2c` temporary configuration files to protect generated RPC secrets.
|
||||
- Replaced the unauthenticated local connection protocol with a secure HMAC-SHA256 signature validation.
|
||||
- Excluded sensitive properties like `rpcSecret` and `rpcPort` from `DownloadItem` serialization so they are never saved to disk in plaintext.
|
||||
- Mitigated SSRF (Server-Side Request Forgery) by strictly validating metadata fetch requests against private IP addresses and loopback ranges.
|
||||
- Prevented potential path traversal vulnerabilities by validating destination file URLs during duplicate resolution.
|
||||
- Sanitized custom HTTP headers to prevent CR/LF injection vectors.
|
||||
- Re-architected `aria2c` port-finding with POSIX sockets to eliminate a known race-condition window.
|
||||
- Applied rate-limiting and text length bounds to the custom `firelink://` scheme to mitigate DoS and injection attempts.
|
||||
|
||||
### Fixes
|
||||
- Fixed a metadata extraction timeout when downloading from YouTube by preventing child processes from holding process pipes open.
|
||||
- Resolved an issue to correctly assign filenames for auto-captured downloads.
|
||||
- Restored the UUID fallback for token generation to prevent silent failures if secure random byte generation fails.
|
||||
- Hardened local API security by immediately rejecting requests if the expected pairing token is completely empty.
|
||||
- Implemented a thread-safe cleanup mechanism for temporary directories to resolve a concurrency race condition during engine cancellation.
|
||||
|
||||
## [0.7.0] - 2026-06-11
|
||||
|
||||
### New Features & Improvements
|
||||
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
|
||||
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
|
||||
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
|
||||
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
|
||||
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
|
||||
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
|
||||
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
|
||||
|
||||
### Fixes
|
||||
- Fix issues regarding proxy environment propagation into media download processes.
|
||||
- Resolve multiple critical bugs related to configuration storage and download stability.
|
||||
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
|
||||
|
||||
## [0.6.6] - 2026-06-10
|
||||
|
||||
### New Features
|
||||
- Add cascading media format pickers with inline loading states during metadata extraction.
|
||||
- Redesign the Integration settings pane for a more modern experience.
|
||||
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
|
||||
|
||||
### Improvements
|
||||
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
|
||||
- Optimize core UI components to significantly improve rendering performance and overall app stability.
|
||||
|
||||
### Fixes
|
||||
- Fix layout and dynamic sizing bugs in the Add Downloads window.
|
||||
- Fix formatting inconsistencies in media options selection.
|
||||
- Fix toast notification rendering glitches.
|
||||
|
||||
## [0.6.5] - 2026-06-09
|
||||
|
||||
### Fixes
|
||||
- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
|
||||
|
||||
## [0.6.4] - 2026-06-09
|
||||
|
||||
### New Features
|
||||
@@ -236,7 +433,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.4.0] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
|
||||
- Reorganized Settings sections so related download preferences sit together and app logs live under App.
|
||||
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
|
||||
- Prefer the bundled `aria2c` binary inside release builds.
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.PHONY: build app dmg release run verify clean
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
|
||||
app:
|
||||
Scripts/create_app_bundle.sh
|
||||
|
||||
dmg: app
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
release:
|
||||
Scripts/create_app_bundle.sh
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
run:
|
||||
swift run Firelink
|
||||
|
||||
verify:
|
||||
Scripts/verify.sh
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf build dist
|
||||
@@ -1,29 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Firelink",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "Firelink", targets: ["Firelink"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
dependencies: [],
|
||||
path: "Sources/Firelink",
|
||||
exclude: [
|
||||
"_internal"
|
||||
],
|
||||
resources: [
|
||||
.process("Assets.xcassets"),
|
||||
.copy("yt-dlp"),
|
||||
.copy("ffmpeg")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,94 +1,194 @@
|
||||
<div align="center">
|
||||
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
|
||||
<h1>Firelink</h1>
|
||||
<p><strong>The modern, blazing-fast download manager built natively for Apple Silicon macOS.</strong></p>
|
||||
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
|
||||
|
||||
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
|
||||
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
|
||||
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
|
||||
<a href="https://github.com/yt-dlp/yt-dlp"><img src="https://img.shields.io/badge/Engine-yt--dlp-red?logo=youtube&logoColor=white" alt="yt-dlp Engine" /></a>
|
||||
<a href="https://ffmpeg.org/"><img src="https://img.shields.io/badge/Engine-ffmpeg-red?logo=ffmpeg&logoColor=white" alt="ffmpeg Engine" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
|
||||
# Firelink
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](LICENSE)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
|
||||
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**Firelink** reimagines macOS file downloading by wrapping legendary open-source engines (`aria2c`, `yt-dlp`, and `ffmpeg`) in a beautifully crafted, highly polished SwiftUI interface. Enjoy high-speed segmented downloads, native media extraction, seamless browser integration, and intelligent file organization without compromising on the aesthetics of your Mac.
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/Settings.png" alt="Settings" width="32%" />
|
||||
<br/>
|
||||
<sub>A premium native experience, from batch linking to advanced settings.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>Light Theme Screenshots</b></summary>
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window light theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window light theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/Settings.png" alt="Settings light theme" width="32%" />
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
|
||||
</details>
|
||||
## Why Firelink
|
||||
|
||||
---
|
||||
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
|
||||
## ✨ Features
|
||||
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
|
||||
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
|
||||
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
||||
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
||||
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes files into `Musics`, `Movies`, `Compressed`, and more.
|
||||
- 🛡️ **Reliable & Secure:** Deep Keychain integration for authenticated downloads, zero-configuration setup, and automatic recovery.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates without third-party frameworks.
|
||||
The current desktop release is **1.1.0**, paired with Firelink Companion **2.0.4**.
|
||||
|
||||
---
|
||||
This stability-focused release adds a secure Windows portable build and strengthens queues, browser handoffs, media metadata, persistence, and packaged release checks.
|
||||
|
||||
## 🧩 Browser Integration
|
||||
## Features
|
||||
|
||||
We are live! Send downloads directly from your browser to the Firelink app with zero friction.
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
||||
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20on%20Firefox-FF7139?logo=firefox-browser&logoColor=white&style=for-the-badge" alt="Install on Firefox" /></a>
|
||||
## Installation
|
||||
|
||||
*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
|
||||
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
|
||||
|
||||
---
|
||||
| Platform | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
|
||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. App data stays under the archive's `data/` directory. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
|
||||
|
||||
## 🛠️ Quick Start
|
||||
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
|
||||
|
||||
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
|
||||
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
|
||||
|
||||
Run the application directly:
|
||||
```bash
|
||||
swift run Firelink
|
||||
### Windows portable
|
||||
|
||||
The portable ZIP is an opt-in secondary distribution:
|
||||
|
||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
||||
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
||||
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
|
||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||
|
||||
## Browser Extension
|
||||
|
||||
<p align="center">
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
|
||||
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
</p>
|
||||
|
||||
Firelink Companion sends browser links and downloads to the desktop app.
|
||||
|
||||
What it adds:
|
||||
|
||||
- Automatic capture for regular browser downloads.
|
||||
- Explicit Fetch media actions from the popup and page context menu.
|
||||
- Context-menu actions for links and selected text.
|
||||
- Firefox and Chromium support.
|
||||
- Signed local requests using the token from **Settings -> Integrations**.
|
||||
- Fallback to the browser download when Firelink is closed or rejects a handoff.
|
||||
- Captured links always open Firelink's Add window before anything is added to the download list.
|
||||
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.4 is the matching extension release for Firelink 1.1.0.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
|
||||
|
||||
## Platforms
|
||||
|
||||
| Target | Status |
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
|
||||
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
|
||||
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 22 or newer
|
||||
- npm
|
||||
- Rust and Cargo
|
||||
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
Clone the repository with its browser-extension submodule:
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
|
||||
cd Firelink
|
||||
```
|
||||
|
||||
Or build a production `.app` bundle:
|
||||
```bash
|
||||
make app && open build/Firelink.app
|
||||
Install dependencies and launch the desktop app:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
---
|
||||
Run the core checks:
|
||||
|
||||
## 🏆 Credits
|
||||
```sh
|
||||
node --test scripts/*.node-test.js
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Firelink stands on the shoulders of giants. A massive thank you to the contributors of these phenomenal open-source projects:
|
||||
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
|
||||
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
|
||||
Create a production bundle:
|
||||
|
||||
---
|
||||
```sh
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
|
||||
|
||||
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
|
||||
```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/Browser/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
|
||||
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
|
||||
|
||||
## Technology & Credits
|
||||
|
||||
Firelink is made possible by these open-source projects:
|
||||
|
||||
- **[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
|
||||
|
||||
## License
|
||||
|
||||
Firelink is available under the [MIT License](LICENSE).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Firelink Release Process
|
||||
|
||||
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 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.
|
||||
|
||||
## Engine supply chain
|
||||
|
||||
Firelink never falls back to system-installed media tools.
|
||||
|
||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||
- `scripts/provision-engines.js` downloads and verifies target archives.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
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
|
||||
|
||||
Keep versions aligned:
|
||||
|
||||
- `package.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
|
||||
## Local macOS build
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri && cargo test --all-targets
|
||||
cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
|
||||
```bash
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
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 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.
|
||||
|
||||
## Automated release builds
|
||||
|
||||
Push a version tag to build and verify native artifacts:
|
||||
|
||||
```bash
|
||||
git tag v<version>
|
||||
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
|
||||
publishes the GitHub Release after the build matrix passes.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 328 KiB |
@@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
CONFIGURATION="${CONFIGURATION:-release}"
|
||||
DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
|
||||
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
|
||||
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
|
||||
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
ICON_NAME="AppIcon"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
is_valid_mach_o() {
|
||||
local path="$1"
|
||||
if ! file "$path" | grep -q 'Mach-O'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
lipo -archs "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_ytdlp() {
|
||||
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
|
||||
local ytdlp_internal_path="$ROOT_DIR/Sources/Firelink/_internal"
|
||||
|
||||
if [[ -x "$ytdlp_path" ]] && [[ -d "$ytdlp_internal_path" ]] && is_valid_mach_o "$ytdlp_path"; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null || ! command -v unzip >/dev/null; then
|
||||
echo "WARNING: yt-dlp or its runtime is missing or malformed, and curl/unzip are not available to refresh it." >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Refreshing bundled yt-dlp runtime..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o "$temp_dir/yt-dlp.zip"
|
||||
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir"
|
||||
cp "$temp_dir/yt-dlp_macos" "$ytdlp_path"
|
||||
chmod +x "$ytdlp_path"
|
||||
|
||||
if [[ -d "$temp_dir/_internal" ]]; then
|
||||
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
cp -R "$temp_dir/_internal" "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
touch "$ROOT_DIR/Sources/Firelink/_internal/.gitkeep"
|
||||
fi
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
ensure_ytdlp
|
||||
|
||||
swift build -c "$CONFIGURATION"
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
||||
|
||||
for media_engine in yt-dlp ffmpeg; do
|
||||
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
|
||||
if [[ -x "$media_engine_path" ]]; then
|
||||
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
||||
chmod +x "$RESOURCES_DIR/$media_engine"
|
||||
else
|
||||
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -d "$ROOT_DIR/Sources/Firelink/_internal" ]]; then
|
||||
cp -R "$ROOT_DIR/Sources/Firelink/_internal" "$RESOURCES_DIR/_internal"
|
||||
fi
|
||||
|
||||
echo "Packaging Firefox extension..."
|
||||
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json"
|
||||
cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons"
|
||||
cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup"
|
||||
|
||||
|
||||
ARIA2C_PATH=$(which aria2c || true)
|
||||
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
|
||||
echo "Bundling aria2c from $ARIA2C_PATH..."
|
||||
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
|
||||
|
||||
if ! command -v dylibbundler &> /dev/null; then
|
||||
echo "Installing dylibbundler..."
|
||||
brew install dylibbundler
|
||||
fi
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
|
||||
else
|
||||
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
|
||||
fi
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
|
||||
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>local.firelink.swiftui</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>$ICON_NAME</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$MARKETING_VERSION</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$BUILD_NUMBER</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>local.firelink.swiftui</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>firelink</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if command -v codesign &> /dev/null; then
|
||||
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
|
||||
echo "Signing app bundle with identity: $SIGNING_IDENTITY"
|
||||
else
|
||||
CODESIGN_ARGS=(--force --sign -)
|
||||
echo "Ad-hoc signing app bundle for local use."
|
||||
fi
|
||||
|
||||
sign_path() {
|
||||
local path="$1"
|
||||
codesign "${CODESIGN_ARGS[@]}" "$path"
|
||||
}
|
||||
|
||||
sign_mach_o_file() {
|
||||
local path="$1"
|
||||
|
||||
if file "$path" | grep -q 'Mach-O'; then
|
||||
if ! sign_path "$path"; then
|
||||
echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -d "$FRAMEWORKS_DIR" ]]; then
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" -type f -print0)
|
||||
|
||||
while IFS= read -r -d '' bundle_path; do
|
||||
sign_path "$bundle_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$RESOURCES_DIR" -type f -print0)
|
||||
|
||||
while IFS= read -r -d '' bundle_path; do
|
||||
sign_path "$bundle_path"
|
||||
done < <(find "$RESOURCES_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
|
||||
|
||||
sign_path "$MACOS_DIR/$APP_NAME"
|
||||
sign_path "$APP_DIR"
|
||||
codesign --verify --deep --verbose=2 "$APP_DIR" || true
|
||||
fi
|
||||
|
||||
echo "Created $APP_DIR"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
VERSION="${VERSION:-}"
|
||||
ARCH="${ARCH:-arm64}"
|
||||
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
|
||||
|
||||
if [[ ! -d "$APP_DIR" ]]; then
|
||||
echo "Missing app bundle: $APP_DIR" >&2
|
||||
echo "Run Scripts/create_app_bundle.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DIR/Contents/Info.plist")"
|
||||
fi
|
||||
|
||||
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
|
||||
|
||||
rm -rf "$DMG_STAGING_DIR"
|
||||
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
|
||||
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
|
||||
ln -s /Applications "$DMG_STAGING_DIR/Applications"
|
||||
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME $VERSION" \
|
||||
-srcfolder "$DMG_STAGING_DIR" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$DMG_PATH"
|
||||
|
||||
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"
|
||||
fi
|
||||
|
||||
echo "Created $DMG_PATH"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
swift build
|
||||
git diff --check
|
||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||
@@ -1,825 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddDownloadsView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var linkText = ""
|
||||
@State private var pendingDownloads: [PendingDownload] = []
|
||||
@State private var connectionsPerServer = 16.0
|
||||
@State private var overrideDestination = false
|
||||
@State private var destinationPath = ""
|
||||
@State private var metadataTask: Task<Void, Never>?
|
||||
@State private var targetQueueID = DownloadQueue.mainQueueID
|
||||
@State private var speedLimitEnabled = false
|
||||
@State private var speedLimitKiBPerSecond = 1024
|
||||
@State private var showsAdvancedTransfer = false
|
||||
@State private var checksumEnabled = false
|
||||
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
|
||||
@State private var checksumValue = ""
|
||||
@State private var headerText = ""
|
||||
@State private var cookieText = ""
|
||||
@State private var mirrorText = ""
|
||||
@State private var useAuthorization = false
|
||||
@State private var authUsername = ""
|
||||
@State private var authPassword = ""
|
||||
@State private var saveLogin = false
|
||||
|
||||
@State private var conflictingDownloads: [DuplicateDownloadItem] = []
|
||||
@State private var showingDuplicates = false
|
||||
@State private var pendingStartFlag = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
linkSection
|
||||
|
||||
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
|
||||
.sheet(isPresented: $showingDuplicates) {
|
||||
DuplicateResolutionView(
|
||||
conflicts: $conflictingDownloads,
|
||||
onConfirm: {
|
||||
showingDuplicates = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
showingDuplicates = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: linkText) { _, newValue in
|
||||
scheduleMetadataRefresh(for: newValue)
|
||||
}
|
||||
.onChange(of: metadataRequestSignature) { _, _ in
|
||||
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
|
||||
scheduleMetadataRefresh(for: linkText)
|
||||
}
|
||||
.onAppear {
|
||||
connectionsPerServer = Double(settings.perServerConnections)
|
||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||
controller.pendingAddQueueID = nil
|
||||
if let text = controller.pendingPasteboardText {
|
||||
applyPendingReferer()
|
||||
linkText = text
|
||||
controller.pendingPasteboardText = nil
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
metadataTask?.cancel()
|
||||
linkText = ""
|
||||
pendingDownloads = []
|
||||
headerText = ""
|
||||
cookieText = ""
|
||||
mirrorText = ""
|
||||
useAuthorization = false
|
||||
authUsername = ""
|
||||
authPassword = ""
|
||||
checksumEnabled = false
|
||||
checksumValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var linkSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Download Links", systemImage: "link")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
TextEditor(text: $linkText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(height: 72)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
refreshMetadata(for: linkText, isAutoFetch: false)
|
||||
} label: {
|
||||
Label("Refresh Metadata", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(DownloadURLParser.parse(linkText).isEmpty)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Label("Save Location", systemImage: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(!overrideDestination)
|
||||
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.disabled(!overrideDestination)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Queue", systemImage: "tray.full")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Queue", selection: $targetQueueID) {
|
||||
ForEach(controller.queues) { queue in
|
||||
Text(queue.name).tag(queue.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 220, alignment: .leading)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
|
||||
.frame(width: 145)
|
||||
Text("\(Int(connectionsPerServer)) segments")
|
||||
.monospacedDigit()
|
||||
.frame(width: 98, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Speed Limit per File", systemImage: "speedometer")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Limit each file", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(!speedLimitEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Authorization", systemImage: "lock.shield")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if useAuthorization {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
}
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
CompactSummaryStrip(
|
||||
metrics: [
|
||||
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
|
||||
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
|
||||
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
|
||||
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Preview", systemImage: "list.bullet.rectangle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Table($pendingDownloads) {
|
||||
TableColumn("File") { $item in
|
||||
HStack {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.foregroundStyle(categoryColor(item.category))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.fileName)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TableColumn("Size") { $item in
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(86)
|
||||
|
||||
TableColumn("Save To") { $item in
|
||||
Text(item.destinationPath)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
TableColumn("Status") { $item in
|
||||
if item.isMedia {
|
||||
if !item.mediaOptions.isEmpty {
|
||||
Menu {
|
||||
ForEach(item.mediaOptions) { option in
|
||||
Button {
|
||||
item.selectedMediaOption = option
|
||||
if let metadata = item.mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
item.fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
||||
}
|
||||
} label: {
|
||||
Text(option.name)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(item.selectedMediaOption?.name ?? "Select Format")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
} else if case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
}
|
||||
.width(min: 110, ideal: 140, max: 200)
|
||||
}
|
||||
.frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
HStack {
|
||||
Text(actionMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if metadataTask != nil {
|
||||
Button {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
|
||||
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
}
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button("Start Downloads") {
|
||||
addDownloads(start: true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 130)
|
||||
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
private var requiredSpaceText: String {
|
||||
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
|
||||
guard knownBytes > 0 else { return "Unknown" }
|
||||
return ByteFormatter.string(knownBytes)
|
||||
}
|
||||
|
||||
private var unknownSizeCount: Int {
|
||||
pendingDownloads.filter { $0.sizeBytes == nil }.count
|
||||
}
|
||||
|
||||
private var freeSpaceText: String {
|
||||
guard let bytes = availableCapacity() else { return "Unknown" }
|
||||
return ByteFormatter.string(bytes)
|
||||
}
|
||||
|
||||
private var actionMessage: String {
|
||||
if pendingDownloads.isEmpty {
|
||||
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
return validationMessage
|
||||
}
|
||||
|
||||
if unknownSizeCount > 0 {
|
||||
return "Some servers did not report file size before download."
|
||||
}
|
||||
|
||||
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
|
||||
}
|
||||
|
||||
private var canAddDownloads: Bool {
|
||||
!pendingDownloads.isEmpty && validationMessage == nil
|
||||
}
|
||||
|
||||
private var validationMessage: String? {
|
||||
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Add the expected checksum digest, or turn checksum off."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
|
||||
return "Headers must use Name: Value lines."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
|
||||
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
|
||||
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
|
||||
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
|
||||
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
|
||||
)
|
||||
}
|
||||
|
||||
private var metadataRequestSignature: String {
|
||||
[
|
||||
headerText,
|
||||
cookieText,
|
||||
useAuthorization ? "auth" : "no-auth",
|
||||
authUsername,
|
||||
authPassword
|
||||
].joined(separator: "\u{1f}")
|
||||
}
|
||||
|
||||
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
|
||||
if useAuthorization {
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanUsername.isEmpty else { return nil }
|
||||
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
}
|
||||
|
||||
return settings.credentials(for: url)
|
||||
}
|
||||
|
||||
private func scheduleMetadataRefresh(for text: String) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
refreshMetadata(for: text, isAutoFetch: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingReferer() {
|
||||
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!referer.isEmpty,
|
||||
URL(string: referer) != nil else {
|
||||
controller.pendingReferer = nil
|
||||
return
|
||||
}
|
||||
|
||||
let refererHeader = "Referer: \(referer)"
|
||||
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
|
||||
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
|
||||
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? refererHeader
|
||||
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
}
|
||||
|
||||
private func refreshMetadata(for text: String, isAutoFetch: Bool) {
|
||||
let urls = DownloadURLParser.parse(text)
|
||||
metadataTask?.cancel()
|
||||
|
||||
pendingDownloads = urls.map { url in
|
||||
let isMedia = MediaDetector.isSupportedMedia(url: url)
|
||||
let fileName = FileClassifier.fileName(from: url)
|
||||
let category = FileClassifier.category(forFileName: fileName)
|
||||
return PendingDownload(
|
||||
url: url,
|
||||
fileName: fileName,
|
||||
category: category,
|
||||
defaultDirectory: settings.destinationDirectory(for: category),
|
||||
state: .loading,
|
||||
isMedia: isMedia
|
||||
)
|
||||
}
|
||||
|
||||
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
|
||||
useAuthorization = true
|
||||
authUsername = creds.username
|
||||
authPassword = creds.password
|
||||
saveLogin = false
|
||||
}
|
||||
|
||||
guard !urls.isEmpty else {
|
||||
metadataTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
metadataTask = Task {
|
||||
await withTaskGroup(of: PendingDownload.self) { group in
|
||||
for item in pendingDownloads {
|
||||
group.addTask {
|
||||
if item.isMedia {
|
||||
var fetchedItem = item
|
||||
do {
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: item.url,
|
||||
cookieSource: settings.mediaCookieSource,
|
||||
credentials: metadataCredentials(for: item.url),
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
fetchedItem.mediaMetadata = metadata
|
||||
fetchedItem.mediaOptions = options
|
||||
if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
|
||||
fetchedItem.selectedMediaOption = bestVideo
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
|
||||
fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
|
||||
}
|
||||
fetchedItem.state = .loaded
|
||||
} catch {
|
||||
fetchedItem.state = .failed(error.localizedDescription)
|
||||
}
|
||||
return fetchedItem
|
||||
} else {
|
||||
return await DownloadMetadataFetcher.fetch(
|
||||
for: item.url,
|
||||
settings: settings,
|
||||
credentials: metadataCredentials(for: item.url),
|
||||
transferOptions: transferOptions,
|
||||
isAutoFetch: isAutoFetch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await loadedItem in group {
|
||||
guard !Task.isCancelled else { break }
|
||||
await MainActor.run {
|
||||
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
|
||||
pendingDownloads[index] = loadedItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectDestination() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
if let currentURL = overrideDirectory {
|
||||
panel.directoryURL = currentURL
|
||||
}
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
destinationPath = url.path
|
||||
overrideDestination = true
|
||||
}
|
||||
}
|
||||
|
||||
private func addDownloads(start: Bool) {
|
||||
var conflicts: [DuplicateDownloadItem] = []
|
||||
for pending in pendingDownloads {
|
||||
let destURL = overrideDirectory ?? pending.defaultDirectory
|
||||
let destPath = destURL.appendingPathComponent(pending.fileName).path
|
||||
|
||||
if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
|
||||
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
|
||||
} else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
|
||||
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
|
||||
}
|
||||
}
|
||||
|
||||
if !conflicts.isEmpty {
|
||||
conflictingDownloads = conflicts
|
||||
pendingStartFlag = start
|
||||
showingDuplicates = true
|
||||
return
|
||||
}
|
||||
|
||||
executeAddDownloads(start: start)
|
||||
}
|
||||
|
||||
private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
|
||||
var finalDownloads = pendingDownloads
|
||||
|
||||
if let conflicts {
|
||||
for conflict in conflicts {
|
||||
guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
|
||||
switch conflict.resolution {
|
||||
case .skip:
|
||||
finalDownloads.remove(at: index)
|
||||
case .rename:
|
||||
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
|
||||
var newName = finalDownloads[index].fileName
|
||||
var count = 1
|
||||
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
|
||||
let ext = URL(fileURLWithPath: newName).pathExtension
|
||||
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) {
|
||||
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
|
||||
count += 1
|
||||
}
|
||||
finalDownloads[index].fileName = newName
|
||||
case .replace:
|
||||
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
|
||||
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
|
||||
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
|
||||
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
|
||||
} else if FileManager.default.fileExists(atPath: path) {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !finalDownloads.isEmpty else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
|
||||
|
||||
controller.addPendingDownloads(
|
||||
finalDownloads,
|
||||
connectionsPerServer: Int(connectionsPerServer),
|
||||
overrideDirectory: overrideDirectory,
|
||||
startImmediately: start,
|
||||
queueID: targetQueueID,
|
||||
credentials: explicitCredentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? {
|
||||
guard useAuthorization else { return nil }
|
||||
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanUsername.isEmpty else { return nil }
|
||||
|
||||
if saveLogin {
|
||||
var savedHosts = Set<String>()
|
||||
for url in urls {
|
||||
if let host = url.host, !savedHosts.contains(host) {
|
||||
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
|
||||
savedHosts.insert(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
}
|
||||
|
||||
private var overrideDirectory: URL? {
|
||||
guard overrideDestination else { return nil }
|
||||
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true)
|
||||
}
|
||||
|
||||
private func destinationDirectory(for item: PendingDownload) -> URL {
|
||||
overrideDirectory ?? item.defaultDirectory
|
||||
}
|
||||
|
||||
private func availableCapacity() -> Int64? {
|
||||
let urls = pendingDownloads.isEmpty
|
||||
? [settings.destinationDirectory(for: .other)]
|
||||
: pendingDownloads.map { destinationDirectory(for: $0) }
|
||||
|
||||
return urls.compactMap { url in
|
||||
let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [
|
||||
.volumeAvailableCapacityForImportantUsageKey,
|
||||
.volumeAvailableCapacityKey
|
||||
])
|
||||
if let important = values?.volumeAvailableCapacityForImportantUsage {
|
||||
return important
|
||||
}
|
||||
if let available = values?.volumeAvailableCapacity {
|
||||
return Int64(available)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
.min()
|
||||
}
|
||||
|
||||
private func existingVolumeURL(for url: URL) -> URL {
|
||||
var candidate = url
|
||||
while !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let parent = candidate.deletingLastPathComponent()
|
||||
if parent.path == candidate.path {
|
||||
return URL(fileURLWithPath: NSHomeDirectory())
|
||||
}
|
||||
candidate = parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func categoryColor(_ category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: .pink
|
||||
case .movies: .indigo
|
||||
case .compressed: .orange
|
||||
case .pictures: .teal
|
||||
case .documents: .blue
|
||||
case .other: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String
|
||||
let symbolName: String
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private struct CompactSummaryStrip: View {
|
||||
let metrics: [SummaryMetric]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(metrics) { metric in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: metric.symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.value)
|
||||
.font(.caption.weight(.semibold).monospacedDigit())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if metric.id != metrics.last?.id {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataStatusView: View {
|
||||
let state: PendingDownload.MetadataState
|
||||
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .pending:
|
||||
Label("Pending", systemImage: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
case .loading:
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking")
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
case .loaded:
|
||||
Label("Ready", systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.green)
|
||||
case .failed:
|
||||
Label("Unknown", systemImage: "exclamationmark.circle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
|
||||
var id = UUID()
|
||||
var urlPattern: String
|
||||
var username: String
|
||||
}
|
||||
|
||||
enum ProxyMode: String, Codable, CaseIterable, Sendable {
|
||||
case none
|
||||
case system
|
||||
case custom
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: "No proxy"
|
||||
case .system: "Use system proxy"
|
||||
case .custom: "Set proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
|
||||
case none = "None"
|
||||
case safari = "Safari"
|
||||
case chrome = "Chrome"
|
||||
case firefox = "Firefox"
|
||||
case edge = "Edge"
|
||||
case brave = "Brave"
|
||||
|
||||
var ytDlpBrowserName: String? {
|
||||
switch self {
|
||||
case .none:
|
||||
nil
|
||||
case .safari, .chrome, .firefox, .edge, .brave:
|
||||
rawValue.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
var statusTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
"Not using browser cookies"
|
||||
case .safari, .chrome, .firefox, .edge, .brave:
|
||||
"Using \(rawValue) cookies"
|
||||
}
|
||||
}
|
||||
|
||||
var statusDetail: String {
|
||||
switch self {
|
||||
case .none:
|
||||
"Restricted media may fail if the site requires login."
|
||||
case .safari:
|
||||
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
|
||||
case .chrome, .firefox, .edge, .brave:
|
||||
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProxyType: String, Codable, CaseIterable, Sendable {
|
||||
case http
|
||||
case https
|
||||
case ftp
|
||||
case socks5
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .http: "HTTP"
|
||||
case .https: "HTTPS (legacy)"
|
||||
case .ftp: "FTP (legacy)"
|
||||
case .socks5: "SOCKS5"
|
||||
}
|
||||
}
|
||||
|
||||
var uriScheme: String {
|
||||
switch self {
|
||||
case .http, .https, .ftp:
|
||||
"http"
|
||||
case .socks5:
|
||||
"socks5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProxySettings: Codable, Equatable, Sendable {
|
||||
var mode: ProxyMode = .none
|
||||
var type: ProxyType = .http
|
||||
var host = ""
|
||||
var port = 8080
|
||||
|
||||
var normalized: ProxySettings {
|
||||
var copy = self
|
||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
copy.port = min(max(copy.port, 1), 65_535)
|
||||
if copy.type != .http {
|
||||
copy.type = .http
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
var customProxyURI: String? {
|
||||
let clean = normalized
|
||||
guard !clean.host.isEmpty else { return nil }
|
||||
return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProxyConfiguration: Equatable, Sendable {
|
||||
var mode: ProxyMode
|
||||
var customProxyURI: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSettings: ObservableObject {
|
||||
@Published var appTheme: AppTheme = .system {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var appFontSize: AppFontSize = .standard {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var listRowDensity: ListRowDensity = .standard {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var perServerConnections: Int {
|
||||
didSet {
|
||||
let clamped = min(max(perServerConnections, 1), 16)
|
||||
if perServerConnections != clamped {
|
||||
perServerConnections = clamped
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var maxConcurrentDownloads: Int {
|
||||
didSet {
|
||||
let clamped = min(max(maxConcurrentDownloads, 1), 12)
|
||||
if maxConcurrentDownloads != clamped {
|
||||
maxConcurrentDownloads = clamped
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var globalSpeedLimitKiBPerSecond: Int {
|
||||
didSet {
|
||||
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
|
||||
if globalSpeedLimitKiBPerSecond != clamped {
|
||||
globalSpeedLimitKiBPerSecond = clamped
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var preventsSleepWhileDownloading: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var proxySettings: ProxySettings {
|
||||
didSet {
|
||||
let normalized = proxySettings.normalized
|
||||
if proxySettings != normalized {
|
||||
proxySettings = normalized
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var downloadDirectories: [DownloadCategory: String] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var siteLogins: [SiteLogin] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var mediaCookieSource: BrowserCookieSource {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var extensionPairingToken: String {
|
||||
didSet {
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Published var message = ""
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "Firelink.AppSettings.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
||||
appTheme = stored.appTheme ?? .system
|
||||
appFontSize = stored.appFontSize ?? .standard
|
||||
listRowDensity = stored.listRowDensity ?? .standard
|
||||
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
||||
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
|
||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||
siteLogins = stored.siteLogins
|
||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
listRowDensity = .standard
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
mediaCookieSource = .none
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
}
|
||||
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||
downloadDirectories[category] = Self.defaultDirectory(for: category).path
|
||||
}
|
||||
}
|
||||
|
||||
func destinationDirectory(for category: DownloadCategory) -> URL {
|
||||
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
|
||||
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
|
||||
}
|
||||
|
||||
func setDirectory(_ path: String, for category: DownloadCategory) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
|
||||
}
|
||||
|
||||
func resetDirectories() {
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
}
|
||||
|
||||
var downloadProxyConfiguration: DownloadProxyConfiguration {
|
||||
DownloadProxyConfiguration(
|
||||
mode: proxySettings.mode,
|
||||
customProxyURI: proxySettings.customProxyURI
|
||||
)
|
||||
}
|
||||
|
||||
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
||||
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
|
||||
}
|
||||
|
||||
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
|
||||
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !pattern.isEmpty, !cleanUsername.isEmpty else {
|
||||
message = "Add a URL pattern and username."
|
||||
return
|
||||
}
|
||||
|
||||
if let id,
|
||||
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
|
||||
message = "A login for \(pattern) already exists."
|
||||
return
|
||||
}
|
||||
|
||||
if let index = siteLogins.firstIndex(where: { login in
|
||||
if let id {
|
||||
return login.id == id
|
||||
}
|
||||
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
|
||||
}) {
|
||||
let loginID = siteLogins[index].id
|
||||
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins[index].urlPattern = pattern
|
||||
siteLogins[index].username = cleanUsername
|
||||
message = "Updated login for \(pattern)."
|
||||
return
|
||||
}
|
||||
|
||||
guard !password.isEmpty else {
|
||||
message = "Add a password."
|
||||
return
|
||||
}
|
||||
|
||||
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
||||
if !KeychainCredentialStore.setPassword(password, for: login.id) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins.append(login)
|
||||
message = "Added login for \(pattern)."
|
||||
}
|
||||
|
||||
func deleteSiteLogins(at offsets: IndexSet) {
|
||||
for offset in offsets {
|
||||
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
|
||||
}
|
||||
siteLogins.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
func credentials(for url: URL) -> DownloadCredentials? {
|
||||
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
|
||||
let password = KeychainCredentialStore.password(for: login.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func credentials(for login: SiteLogin) -> DownloadCredentials? {
|
||||
guard let password = KeychainCredentialStore.password(for: login.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredSettings(
|
||||
appTheme: appTheme,
|
||||
appFontSize: appFontSize,
|
||||
listRowDensity: listRowDensity,
|
||||
perServerConnections: perServerConnections,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins,
|
||||
mediaCookieSource: mediaCookieSource
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [defaults, storageKey] in
|
||||
let data = await Task.detached(priority: .background) {
|
||||
try? JSONEncoder().encode(stored)
|
||||
}.value
|
||||
|
||||
guard !Task.isCancelled, let encoded = data else { return }
|
||||
defaults.set(encoded, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
|
||||
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !pattern.isEmpty else { return false }
|
||||
|
||||
let host = (url.host(percentEncoded: false) ?? "").lowercased()
|
||||
let absolute = url.absoluteString.lowercased()
|
||||
let normalizedPattern = URL(string: pattern)?.host ?? pattern
|
||||
|
||||
if normalizedPattern.hasPrefix("*.") {
|
||||
let suffix = String(normalizedPattern.dropFirst(2))
|
||||
return host == suffix || host.hasSuffix(".\(suffix)")
|
||||
}
|
||||
|
||||
if normalizedPattern.contains("*") {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
|
||||
.replacingOccurrences(of: "\\*", with: ".*")
|
||||
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
if normalizedPattern.contains("/") {
|
||||
return absolute.contains(normalizedPattern)
|
||||
}
|
||||
|
||||
return host == normalizedPattern
|
||||
}
|
||||
|
||||
private static func generateSecureToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
return UUID().uuidString
|
||||
}
|
||||
return Data(bytes).base64EncodedString()
|
||||
}
|
||||
|
||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
||||
}
|
||||
|
||||
private static func defaultDirectory(for category: DownloadCategory) -> URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
|
||||
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
|
||||
guard let category = DownloadCategory(rawValue: key) else { return nil }
|
||||
return (category, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredSettings: Codable {
|
||||
var appTheme: AppTheme?
|
||||
var appFontSize: AppFontSize?
|
||||
var listRowDensity: ListRowDensity?
|
||||
var perServerConnections: Int
|
||||
var maxConcurrentDownloads: Int?
|
||||
var globalSpeedLimitKiBPerSecond: Int?
|
||||
var preventsSleepWhileDownloading: Bool
|
||||
var proxySettings: ProxySettings?
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
var mediaCookieSource: BrowserCookieSource?
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
let rpcSecret: String
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> Int {
|
||||
var port: UInt16 = 6800
|
||||
let parameters = NWParameters.tcp
|
||||
for p in 6800...6900 {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
|
||||
if let listener = try? NWListener(using: parameters) {
|
||||
listener.cancel()
|
||||
port = UInt16(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
return Int(port)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case executableNotFound
|
||||
case launchFailed(String)
|
||||
case unsupportedProxy(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .executableNotFound:
|
||||
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
|
||||
case .launchFailed(let details):
|
||||
"Could not start aria2c: \(details)"
|
||||
case .unsupportedProxy(let details):
|
||||
details
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let executableURL: URL?
|
||||
|
||||
init(executableURL: URL? = Aria2DownloadEngine.findExecutable()) {
|
||||
self.executableURL = executableURL
|
||||
}
|
||||
|
||||
static func findExecutable() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/aria2c",
|
||||
"/usr/local/bin/aria2c",
|
||||
"/usr/bin/aria2c",
|
||||
"/opt/local/bin/aria2c"
|
||||
]
|
||||
|
||||
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
|
||||
return URL(fileURLWithPath: found)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func versionString() async -> String? {
|
||||
guard let executableURL = findExecutable() else { return nil }
|
||||
|
||||
return await Task.detached {
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = nil
|
||||
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Close the write file handle in the parent process immediately
|
||||
// This guarantees readToEnd() won't hang waiting for the parent itself
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let data = try? outputPipe.fileHandleForReading.readToEnd()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0, let data = data else { return nil }
|
||||
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func start(
|
||||
item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||
) throws -> Handle {
|
||||
guard let executableURL else {
|
||||
throw EngineError.executableNotFound
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: item.destinationDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf")
|
||||
do {
|
||||
let confContent = "rpc-secret=\(rpcSecret)\n"
|
||||
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
|
||||
} catch {
|
||||
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardInput = inputPipe
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
let parser = Aria2ProgressParser()
|
||||
let outputBuffer = LockedDataBuffer()
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
let completionGate = CompletionGate(completion)
|
||||
let completionMonitor = CompletionMonitor()
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
outputBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
for update in parser.parse(text) {
|
||||
progress(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
completionMonitor.cancel()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
completionGate.complete(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
if let input = inputFileContent(for: item).data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(input)
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
completionMonitor.set(
|
||||
Self.monitorCompletion(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
process: process,
|
||||
completionGate: completionGate
|
||||
)
|
||||
)
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionMonitor.cancel()
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func monitorCompletion(
|
||||
rpcPort: Int,
|
||||
rpcSecret: String,
|
||||
process: Process,
|
||||
completionGate: CompletionGate<Void>
|
||||
) -> Task<Void, Never> {
|
||||
Task.detached {
|
||||
while !Task.isCancelled && process.isRunning {
|
||||
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionGate.complete(.success(()))
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func completedDownloadStatus(rpcPort: Int, rpcSecret: String) async -> Bool {
|
||||
guard let stopped = await rpcCall(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
method: "aria2.tellStopped",
|
||||
arguments: [0, 10, ["status", "errorCode", "completedLength", "totalLength"]]
|
||||
) as? [[String: Any]] else {
|
||||
return false
|
||||
}
|
||||
|
||||
if stopped.contains(where: { item in
|
||||
(item["status"] as? String) == "complete"
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
return stopped.contains { item in
|
||||
guard (item["status"] as? String) == "error",
|
||||
(item["errorCode"] as? String) == "0",
|
||||
let completedLength = Int64(item["completedLength"] as? String ?? ""),
|
||||
let totalLength = Int64(item["totalLength"] as? String ?? ""),
|
||||
totalLength > 0 else {
|
||||
return false
|
||||
}
|
||||
return completedLength >= totalLength
|
||||
}
|
||||
}
|
||||
|
||||
private static func rpcCall(
|
||||
rpcPort: Int,
|
||||
rpcSecret: String,
|
||||
method: String,
|
||||
arguments: [Any]
|
||||
) async -> Any? {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/jsonrpc") else { return nil }
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"id": UUID().uuidString,
|
||||
"params": ["token:\(rpcSecret)"] + arguments
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
request.timeoutInterval = 3
|
||||
|
||||
guard let (responseData, _) = try? await URLSession.shared.data(for: request),
|
||||
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return json["result"]
|
||||
}
|
||||
|
||||
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
||||
|
||||
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.changeGlobalOption",
|
||||
"id": UUID().uuidString,
|
||||
"params": [
|
||||
"token:\(handle.rpcSecret)",
|
||||
["max-overall-download-limit": limitValue]
|
||||
]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
request.timeoutInterval = 3
|
||||
|
||||
_ = try? await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
rpcPort: Int,
|
||||
confURL: URL
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--conf-path=\(confURL.path)",
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
"--auto-file-renaming=true",
|
||||
"--summary-interval=1",
|
||||
"--console-log-level=warn",
|
||||
"--download-result=hide",
|
||||
"--file-allocation=none",
|
||||
"--min-split-size=1M",
|
||||
"--max-tries=10",
|
||||
"--retry-wait=5",
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-",
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-port=\(rpcPort)",
|
||||
"--rpc-listen-all=false"
|
||||
]
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||
return arguments
|
||||
}
|
||||
|
||||
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
|
||||
switch configuration.mode {
|
||||
case .none:
|
||||
return clearedProxyArguments()
|
||||
case .system:
|
||||
switch systemProxyResolution(for: item.url) {
|
||||
case .direct:
|
||||
return clearedProxyArguments()
|
||||
case .proxy(let proxyURI):
|
||||
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
|
||||
case .unsupported(let message):
|
||||
throw EngineError.unsupportedProxy(message)
|
||||
}
|
||||
case .custom:
|
||||
guard let proxyURI = configuration.customProxyURI else { return [] }
|
||||
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
|
||||
}
|
||||
}
|
||||
|
||||
private func clearedProxyArguments() -> [String] {
|
||||
[
|
||||
"--all-proxy=",
|
||||
"--http-proxy=",
|
||||
"--https-proxy=",
|
||||
"--ftp-proxy="
|
||||
]
|
||||
}
|
||||
|
||||
private func proxyArgumentName(for urlScheme: String?) -> String {
|
||||
switch urlScheme?.lowercased() {
|
||||
case "http": "--http-proxy"
|
||||
case "https": "--https-proxy"
|
||||
case "ftp": "--ftp-proxy"
|
||||
default: "--all-proxy"
|
||||
}
|
||||
}
|
||||
|
||||
private enum SystemProxyResolution {
|
||||
case direct
|
||||
case proxy(String)
|
||||
case unsupported(String)
|
||||
}
|
||||
|
||||
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
|
||||
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
|
||||
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
|
||||
return .direct
|
||||
}
|
||||
|
||||
for proxy in proxies {
|
||||
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
|
||||
if type == kCFProxyTypeNone as String {
|
||||
return .direct
|
||||
}
|
||||
if type == kCFProxyTypeSOCKS as String {
|
||||
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
|
||||
}
|
||||
if type == kCFProxyTypeAutoConfigurationURL as String ||
|
||||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
|
||||
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
|
||||
}
|
||||
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
|
||||
return .proxy(uri)
|
||||
}
|
||||
}
|
||||
|
||||
return .direct
|
||||
}
|
||||
|
||||
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
|
||||
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
|
||||
!host.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
|
||||
let scheme: String
|
||||
if type == kCFProxyTypeHTTPS as String {
|
||||
scheme = "https"
|
||||
} else if type == kCFProxyTypeFTP as String {
|
||||
scheme = "ftp"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
guard let port else {
|
||||
return "\(scheme)://\(host)"
|
||||
}
|
||||
return "\(scheme)://\(host):\(port)"
|
||||
}
|
||||
|
||||
private func inputFileContent(for item: DownloadItem) -> String {
|
||||
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||
let urls = ([item.url] + (item.mirrorURLs ?? []))
|
||||
.map { sanitizedOptionValue($0.absoluteString) }
|
||||
.joined(separator: "\t")
|
||||
var lines = [
|
||||
urls,
|
||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||
" out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
|
||||
" split=\(connections)",
|
||||
" max-connection-per-server=\(connections)"
|
||||
]
|
||||
|
||||
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
|
||||
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
|
||||
}
|
||||
|
||||
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
|
||||
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
|
||||
}
|
||||
|
||||
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
|
||||
}
|
||||
|
||||
if let credentials = item.credentials, !credentials.isEmpty {
|
||||
let scheme = item.url.scheme?.lowercased()
|
||||
if scheme == "ftp" || scheme == "sftp" {
|
||||
lines.append(" ftp-user=\(sanitizedOptionValue(credentials.username))")
|
||||
lines.append(" ftp-passwd=\(sanitizedOptionValue(credentials.password))")
|
||||
} else {
|
||||
lines.append(" http-user=\(sanitizedOptionValue(credentials.username))")
|
||||
lines.append(" http-passwd=\(sanitizedOptionValue(credentials.password))")
|
||||
lines.append(" http-auth-challenge=true")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
private func sanitizedOptionValue(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = Data()
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxBytes: Int = 512 * 1024) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
lock.withLock { storage }
|
||||
}
|
||||
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionGate<Success>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didComplete = false
|
||||
private let completion: @Sendable (Result<Success, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func complete(_ result: Result<Success, Error>) {
|
||||
lock.lock()
|
||||
let shouldComplete = !didComplete
|
||||
if shouldComplete {
|
||||
didComplete = true
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard shouldComplete else { return }
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionMonitor: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func set(_ task: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
self.task = task
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
let task = self.task
|
||||
self.task = nil
|
||||
lock.unlock()
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
final class Aria2ProgressParser: @unchecked Sendable {
|
||||
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
|
||||
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
|
||||
private let speedRegex = try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)
|
||||
private let etaRegex = try? NSRegularExpression(pattern: #"ETA:([^\s\]]+)"#)
|
||||
private let bytesRegex = try? NSRegularExpression(pattern: #"\s([0-9.]+(?:KiB|MiB|GiB|TiB|B)?/[0-9.]+(?:KiB|MiB|GiB|TiB|B)?)\("#)
|
||||
|
||||
func parse(_ text: String) -> [DownloadProgress] {
|
||||
text
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.compactMap { parseLine(String($0)) }
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> DownloadProgress? {
|
||||
guard line.contains("%") else { return nil }
|
||||
|
||||
let percentage = firstCapture(in: line, regex: percentageRegex).flatMap(Double.init) ?? 0
|
||||
let connections = firstCapture(in: line, regex: connectionRegex).flatMap(Int.init) ?? 0
|
||||
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
|
||||
let bytes = firstCapture(in: line, regex: bytesRegex) ?? "-"
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(percentage / 100, 0), 1),
|
||||
bytesText: bytes,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: connections
|
||||
)
|
||||
}
|
||||
|
||||
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
|
||||
guard let regex else { return nil }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
guard let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "github-mark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "MenuBarIconTemplate.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChunkMapView: View {
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var bitfield: String = ""
|
||||
@State private var numPieces: Int = 0
|
||||
@State private var pollTask: Task<Void, Never>?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if numPieces > 0 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
|
||||
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await fetchStatus(port: port, secret: secret)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStatus(port: Int, secret: String) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.tellActive",
|
||||
"id": "1",
|
||||
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
request.httpBody = data
|
||||
|
||||
do {
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
let active = result.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedBitfield = active["bitfield"] as? String ?? ""
|
||||
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
|
||||
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
|
||||
|
||||
await MainActor.run {
|
||||
self.bitfield = fetchedBitfield
|
||||
self.numPieces = fetchedNumPieces
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
let boxSize: CGFloat = 10
|
||||
let spacing: CGFloat = 2
|
||||
let cornerSize = CGSize(width: 2, height: 2)
|
||||
let width = size.width
|
||||
|
||||
let x: CGFloat = 0
|
||||
let y: CGFloat = 0
|
||||
|
||||
let completedPath = Path { p in
|
||||
var cx = x
|
||||
var cy = y
|
||||
for piece in itemPieces {
|
||||
if piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPath = Path { p in
|
||||
var cx: CGFloat = 0
|
||||
var cy: CGFloat = 0
|
||||
for piece in itemPieces {
|
||||
if !piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
|
||||
context.fill(completedPath, with: .color(Color.accentColor))
|
||||
}
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var selection: Set<DownloadItem.ID> = []
|
||||
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(selection: $sidebarSelection)
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
|
||||
.themeBackground(settings.appTheme.theme.secondaryBackground)
|
||||
} detail: {
|
||||
detailView
|
||||
.themeBackground(settings.appTheme.theme.background)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
if provider.canLoadObject(ofClass: URL.self) {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url = url {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: String.self) {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch sidebarSelection {
|
||||
case .downloads(let filter):
|
||||
downloadsView(filter: filter)
|
||||
case .queue(let queueID):
|
||||
queueView(queueID: queueID)
|
||||
case .scheduler:
|
||||
SchedulerView()
|
||||
case .speedLimiter:
|
||||
SpeedLimiterView()
|
||||
case .settings:
|
||||
SettingsPaneContainer()
|
||||
}
|
||||
}
|
||||
|
||||
private func queueView(queueID: UUID) -> some View {
|
||||
downloadsView(
|
||||
filter: .all,
|
||||
title: controller.queueName(for: queueID),
|
||||
items: controller.queueItems(for: queueID),
|
||||
queueID: queueID
|
||||
)
|
||||
}
|
||||
|
||||
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
|
||||
downloadsView(
|
||||
filter: filter,
|
||||
title: filter.title,
|
||||
items: filteredDownloads(for: filter),
|
||||
queueID: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
DownloadTable(
|
||||
items: items,
|
||||
selection: $selection,
|
||||
title: title,
|
||||
queueID: queueID
|
||||
)
|
||||
Divider()
|
||||
StatusBar()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup {
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!canStop)
|
||||
|
||||
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
|
||||
controller.resume(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(!canStart)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
Button("") {
|
||||
if !selection.isEmpty {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [])
|
||||
.opacity(0)
|
||||
|
||||
Button("") {
|
||||
handlePaste(queueID: queueID)
|
||||
}
|
||||
.keyboardShortcut("v", modifiers: .command)
|
||||
.opacity(0)
|
||||
|
||||
Button("") {
|
||||
selectAll(items: items)
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: .command)
|
||||
.opacity(0)
|
||||
|
||||
Button("") {
|
||||
if let item = selectedItems.first {
|
||||
performPrimaryAction(for: item)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.opacity(0)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
|
||||
isPresented: $showDeleteConfirmation
|
||||
) {
|
||||
Button("Remove from List") {
|
||||
deleteSelected(deleteFiles: false)
|
||||
}
|
||||
Button("Move Files and Cache to Trash", role: .destructive) {
|
||||
deleteSelected(deleteFiles: true)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete the selected downloads?")
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedItems: [DownloadItem] {
|
||||
controller.downloads.filter { selection.contains($0.id) }
|
||||
}
|
||||
|
||||
private func deleteSelected(deleteFiles: Bool) {
|
||||
for item in selectedItems {
|
||||
controller.delete(item, deleteFiles: deleteFiles)
|
||||
}
|
||||
selection.removeAll()
|
||||
}
|
||||
|
||||
private func handlePaste(queueID: UUID?) {
|
||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
|
||||
private func selectAll(items: [DownloadItem]) {
|
||||
selection = Set(items.map { $0.id })
|
||||
}
|
||||
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
if item.status == .completed {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
|
||||
} else {
|
||||
openWindow(id: "download-properties", value: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
|
||||
}
|
||||
|
||||
return controller.activeCount > 0
|
||||
}
|
||||
|
||||
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.queueItems(for: queueID).contains { $0.status == .queued }
|
||||
}
|
||||
|
||||
return controller.queuedCount > 0
|
||||
}
|
||||
|
||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||
switch filter {
|
||||
case .all:
|
||||
controller.downloads
|
||||
case .queued:
|
||||
controller.downloads.filter { $0.status == .queued }
|
||||
case .active:
|
||||
controller.downloads.filter { $0.status == .downloading }
|
||||
case .completed:
|
||||
controller.downloads.filter { $0.status == .completed }
|
||||
case .unfinished:
|
||||
controller.downloads.filter { $0.status != .completed }
|
||||
case .category(let category):
|
||||
controller.downloads.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadURLParser {
|
||||
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
|
||||
static func parse(_ text: String) -> [URL] {
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
||||
|
||||
let tokenized = text
|
||||
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")))
|
||||
.compactMap { token -> URL? in
|
||||
let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()"))
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return (detected + tokenized).filter { url in
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
return false
|
||||
}
|
||||
return seen.insert(url.absoluteString).inserted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DownloadMetadataFetcher {
|
||||
static func fetch(
|
||||
for url: URL,
|
||||
settings: AppSettings,
|
||||
credentials: DownloadCredentials? = nil,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
||||
isAutoFetch: Bool = false
|
||||
) async -> PendingDownload {
|
||||
let initialName = FileClassifier.fileName(from: url)
|
||||
let initialCategory = FileClassifier.category(forFileName: initialName)
|
||||
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
|
||||
var pending = PendingDownload(
|
||||
url: url,
|
||||
fileName: initialName,
|
||||
category: initialCategory,
|
||||
defaultDirectory: initialDirectory,
|
||||
state: .loading
|
||||
)
|
||||
|
||||
guard url.scheme?.lowercased().hasPrefix("http") == true else {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
|
||||
if isAutoFetch, let host = url.host, isPrivateHost(host) {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 12
|
||||
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
|
||||
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
|
||||
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
|
||||
let token = "\(credentials.username):\(credentials.password)"
|
||||
.data(using: .utf8)?
|
||||
.base64EncodedString()
|
||||
if let token {
|
||||
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
for header in normalizedHeaders {
|
||||
request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
|
||||
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
||||
let fileName = fileName(fromContentDisposition: disposition) {
|
||||
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
||||
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
||||
}
|
||||
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let bytes = Int64(contentLength) {
|
||||
pending.sizeBytes = bytes
|
||||
} else if response.expectedContentLength > 0 {
|
||||
pending.sizeBytes = response.expectedContentLength
|
||||
}
|
||||
|
||||
pending.mimeType = httpResponse.mimeType
|
||||
pending.state = .loaded
|
||||
} catch {
|
||||
pending.state = .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
private static func fileName(fromContentDisposition header: String) -> String? {
|
||||
let parts = header.components(separatedBy: ";")
|
||||
for part in parts {
|
||||
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("filename*="),
|
||||
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
|
||||
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
|
||||
if trimmed.lowercased().hasPrefix("filename=") {
|
||||
return String(trimmed.dropFirst("filename=".count))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isPrivateHost(_ host: String) -> Bool {
|
||||
let h = host.lowercased()
|
||||
if h == "localhost" || h.hasSuffix(".local") { return true }
|
||||
if !h.contains(".") && !h.contains(":") { return true }
|
||||
|
||||
var hints = addrinfo(
|
||||
ai_flags: 0,
|
||||
ai_family: AF_UNSPEC,
|
||||
ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_canonname: nil,
|
||||
ai_addr: nil,
|
||||
ai_next: nil
|
||||
)
|
||||
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
if getaddrinfo(host, nil, &hints, &res) == 0 {
|
||||
var current = res
|
||||
while let info = current {
|
||||
let family = info.pointee.ai_family
|
||||
if family == AF_INET {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
|
||||
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
|
||||
let first = (ip >> 24) & 0xFF
|
||||
let second = (ip >> 16) & 0xFF
|
||||
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
} else if family == AF_INET6 {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
|
||||
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
|
||||
|
||||
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
|
||||
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
|
||||
|
||||
let isULA = (bytes.0 & 0xFE) == 0xFC
|
||||
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
|
||||
|
||||
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
|
||||
|
||||
if isLoopback || isULA || isLinkLocal {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if isIPv4Mapped {
|
||||
let first = bytes.12
|
||||
let second = bytes.13
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
current = info.pointee.ai_next
|
||||
}
|
||||
freeaddrinfo(res)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum ByteFormatter {
|
||||
static func string(_ bytes: Int64?) -> String {
|
||||
guard let bytes else { return "Unknown" }
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.includesCount = true
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadPropertiesWindow: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
let downloadID: UUID
|
||||
|
||||
var body: some View {
|
||||
if let item = controller.downloads.first(where: { $0.id == downloadID }) {
|
||||
DownloadPropertiesView(item: item)
|
||||
} else {
|
||||
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
|
||||
.frame(width: 420, height: 240)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadPropertiesView: View {
|
||||
enum LoginMode: String, CaseIterable, Identifiable {
|
||||
case matching = "Matching site login"
|
||||
case custom = "Custom credentials"
|
||||
case none = "No login"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var urlText: String
|
||||
@State private var fileName: String
|
||||
@State private var destinationPath: String
|
||||
@State private var connections: Int
|
||||
@State private var loginMode: LoginMode
|
||||
@State private var username: String
|
||||
@State private var password: String
|
||||
@State private var speedLimitEnabled: Bool
|
||||
@State private var speedLimitKiBPerSecond: Int
|
||||
@State private var checksumEnabled: Bool
|
||||
@State private var checksumAlgorithm: ChecksumAlgorithm
|
||||
@State private var checksumValue: String
|
||||
@State private var headerText: String
|
||||
@State private var cookieText: String
|
||||
@State private var mirrorText: String
|
||||
@State private var errorMessage = ""
|
||||
@State private var showsAdvancedTransfer = false
|
||||
@State private var showsChunkMap = false
|
||||
|
||||
init(item: DownloadItem) {
|
||||
self.item = item
|
||||
_urlText = State(initialValue: item.url.absoluteString)
|
||||
_fileName = State(initialValue: item.fileName)
|
||||
_destinationPath = State(initialValue: item.destinationDirectory.path)
|
||||
_connections = State(initialValue: item.connectionsPerServer)
|
||||
_speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0)
|
||||
_speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1))
|
||||
if let credentials = item.credentials {
|
||||
_loginMode = State(initialValue: .custom)
|
||||
_username = State(initialValue: credentials.username)
|
||||
_password = State(initialValue: credentials.password)
|
||||
} else {
|
||||
_loginMode = State(initialValue: .matching)
|
||||
_username = State(initialValue: "")
|
||||
_password = State(initialValue: "")
|
||||
}
|
||||
if let checksum = item.checksum {
|
||||
_checksumEnabled = State(initialValue: true)
|
||||
_checksumAlgorithm = State(initialValue: checksum.algorithm)
|
||||
_checksumValue = State(initialValue: checksum.value)
|
||||
} else {
|
||||
_checksumEnabled = State(initialValue: false)
|
||||
_checksumAlgorithm = State(initialValue: .sha256)
|
||||
_checksumValue = State(initialValue: "")
|
||||
}
|
||||
_headerText = State(initialValue: (item.requestHeaders ?? []).map(\.headerLine).joined(separator: "\n"))
|
||||
_cookieText = State(initialValue: item.cookieHeader ?? "")
|
||||
_mirrorText = State(initialValue: (item.mirrorURLs ?? []).map(\.absoluteString).joined(separator: "\n"))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
DownloadSummaryHeader(item: item)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
Form {
|
||||
if let noticeText {
|
||||
Section {
|
||||
Label(noticeText, systemImage: noticeSystemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Download") {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("URL")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("URL", text: $urlText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("File name")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("File name", text: $fileName)
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Save location")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
TextField("Save location", text: $destinationPath)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(fileIdentityLocked)
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Connections")
|
||||
.foregroundStyle(.secondary)
|
||||
Stepper("\(connections) per file", value: $connections, in: 1...16)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Speed")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Toggle("Limit", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(item.status == .completed ? "Site Login for Redownload" : "Site Login") {
|
||||
Picker("Login", selection: $loginMode) {
|
||||
ForEach(LoginMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(transferSettingsLocked)
|
||||
|
||||
if loginMode == .matching {
|
||||
Text(matchingLoginText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if loginMode == .custom {
|
||||
TextField("Username", text: $username)
|
||||
.disabled(transferSettingsLocked)
|
||||
SecureField("Password", text: $password)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("Checksum")
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Verify", isOn: $checksumEnabled)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
if checksumEnabled {
|
||||
GridRow {
|
||||
Text("Algorithm")
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Digest")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Cookies")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Cookies", text: $cookieText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
CompactEditor(title: "Headers", text: $headerText)
|
||||
.disabled(transferSettingsLocked)
|
||||
CompactEditor(title: "Mirrors", text: $mirrorText)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
if item.status == .downloading && item.rpcPort != nil {
|
||||
Section {
|
||||
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
|
||||
ChunkMapView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
Label("Save", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
.frame(width: 720, height: 580)
|
||||
}
|
||||
|
||||
private var fileIdentityLocked: Bool {
|
||||
item.status == .completed || item.status == .downloading
|
||||
}
|
||||
|
||||
private var transferSettingsLocked: Bool {
|
||||
item.status == .downloading
|
||||
}
|
||||
|
||||
private var noticeText: String? {
|
||||
switch item.status {
|
||||
case .completed:
|
||||
return "File identity is read-only. Transfer settings are saved for redownload."
|
||||
case .downloading:
|
||||
return "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var noticeSystemImage: String {
|
||||
item.status == .completed ? "checkmark.circle" : "bolt.horizontal.circle"
|
||||
}
|
||||
|
||||
private var advancedTransferTitle: String {
|
||||
item.status == .completed ? "Advanced Transfer for Redownload" : "Advanced Transfer"
|
||||
}
|
||||
|
||||
private struct CompactEditor: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $text)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.frame(minHeight: 44, maxHeight: 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollapsibleGroup<Content: View>: View {
|
||||
let title: String
|
||||
@Binding var isExpanded: Bool
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 12)
|
||||
Text(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
content()
|
||||
.padding(.leading, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var matchingLoginText: String {
|
||||
guard let url = URL(string: urlText),
|
||||
let credentials = settings.credentials(for: url) else {
|
||||
return "No matching saved login for this URL."
|
||||
}
|
||||
|
||||
return "Will use saved login for \(credentials.username)."
|
||||
}
|
||||
|
||||
private func selectDestination() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.directoryURL = URL(fileURLWithPath: NSString(string: destinationPath).expandingTildeInPath)
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
destinationPath = url.path
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
errorMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
|
||||
return
|
||||
}
|
||||
|
||||
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
|
||||
guard !cleanFileName.isEmpty else {
|
||||
errorMessage = "File name cannot be empty."
|
||||
return
|
||||
}
|
||||
|
||||
let destination = URL(
|
||||
fileURLWithPath: NSString(string: destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)).expandingTildeInPath,
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
let credentials: DownloadCredentials?
|
||||
switch loginMode {
|
||||
case .matching:
|
||||
credentials = settings.credentials(for: url)
|
||||
case .custom:
|
||||
let custom = DownloadCredentials(username: username, password: password)
|
||||
credentials = custom.isEmpty ? nil : custom
|
||||
case .none:
|
||||
credentials = nil
|
||||
}
|
||||
|
||||
guard let transferOptions = validatedTransferOptions else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.updateDownload(
|
||||
id: item.id,
|
||||
url: url,
|
||||
fileName: cleanFileName,
|
||||
destinationDirectory: destination,
|
||||
connectionsPerServer: connections,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private var validatedTransferOptions: DownloadTransferOptions? {
|
||||
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
errorMessage = "Add the expected checksum digest, or turn checksum off."
|
||||
return nil
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
|
||||
errorMessage = "Headers must use Name: Value lines."
|
||||
return nil
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
|
||||
errorMessage = "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadTransferOptions(
|
||||
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
|
||||
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
|
||||
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
|
||||
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadSummaryHeader: View {
|
||||
let item: DownloadItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Label(item.status.rawValue, systemImage: item.category.symbolName)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
ProgressView(value: item.status == .completed ? 1.0 : item.progress)
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
|
||||
GridRow {
|
||||
summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
|
||||
summary("Size", ByteFormatter.string(item.sizeBytes))
|
||||
summary("Speed", item.displaySpeedText)
|
||||
summary("ETA", item.displayETAText)
|
||||
}
|
||||
GridRow {
|
||||
summary("Live connections", "\(item.connectionCount)")
|
||||
summary("Speed cap", item.speedLimitText)
|
||||
summary("Category", item.category.rawValue)
|
||||
summary("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
||||
}
|
||||
GridRow {
|
||||
summary("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.gridCellColumns(2)
|
||||
summary("Destination", item.destinationPath)
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch item.status {
|
||||
case .queued:
|
||||
.secondary
|
||||
case .downloading:
|
||||
.accentColor
|
||||
case .paused:
|
||||
.orange
|
||||
case .completed:
|
||||
.green
|
||||
case .failed, .canceled:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private func summary(_ label: String, _ value: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(label)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DownloadTable: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
let items: [DownloadItem]
|
||||
@Binding var selection: Set<DownloadItem.ID>
|
||||
let title: String
|
||||
var queueID: UUID?
|
||||
|
||||
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
|
||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||
|
||||
var sortedItems: [DownloadItem] {
|
||||
items.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text("\(items.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
TableColumn("File Name", value: \.fileName) { item in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor(for: item.category))
|
||||
.frame(width: 22)
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
TableColumn("Size", value: \.sortableSize) { item in
|
||||
if let size = item.sizeBytes, size > 0 {
|
||||
Text(ByteFormatter.string(size))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
|
||||
Text(item.bytesText)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text("Unknown")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
statusCell(for: item)
|
||||
}
|
||||
.width(min: 115, ideal: 170)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
Text(item.displayETAText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
for target in targetItems {
|
||||
if target.status == .completed {
|
||||
openFile(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Download",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteItems != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: pendingDeleteItems
|
||||
) { ids in
|
||||
Button("Remove from List") {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: false) }
|
||||
selection.subtract(ids)
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Move to Trash", role: .destructive) {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: true) }
|
||||
selection.subtract(ids)
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
} message: { ids in
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
if items.allSatisfy({ $0.status == .completed }) {
|
||||
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink, or also move the downloaded files to Trash.")
|
||||
} else {
|
||||
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial files there.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusCell(for item: DownloadItem) -> some View {
|
||||
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.status == .downloading, !message.isEmpty {
|
||||
Text(message)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text(item.status.rawValue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
if !targetItems.isEmpty {
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Move to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
|
||||
NSPasteboard.general.setString(urls, forType: .string)
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(id: "download-properties", value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = itemIDs
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func categoryColor(for category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(for status: DownloadStatus) -> Color {
|
||||
switch status {
|
||||
case .queued: return .secondary
|
||||
case .downloading: return .blue
|
||||
case .paused: return .orange
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .canceled: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressBarCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor(for: item.status))
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private func showInFinder(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
|
||||
} else {
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
private func existingFolder(for url: URL) -> URL {
|
||||
var candidate = url
|
||||
while !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let parent = candidate.deletingLastPathComponent()
|
||||
if parent.path == candidate.path {
|
||||
return URL(fileURLWithPath: NSHomeDirectory())
|
||||
}
|
||||
candidate = parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func openFile(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.open(fileURL)
|
||||
} else {
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DuplicateDownloadItem: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var pendingItem: PendingDownload
|
||||
var resolution: DuplicateResolutionAction = .rename
|
||||
let reason: DuplicateReason
|
||||
|
||||
enum DuplicateReason: Equatable {
|
||||
case existingURL(String)
|
||||
case existingFile(String)
|
||||
}
|
||||
}
|
||||
|
||||
enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
|
||||
case rename = "Rename"
|
||||
case replace = "Replace"
|
||||
case skip = "Skip"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct DuplicateResolutionView: View {
|
||||
@Binding var conflicts: [DuplicateDownloadItem]
|
||||
let onConfirm: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Duplicate Downloads Detected")
|
||||
.font(.headline)
|
||||
Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Divider()
|
||||
|
||||
List($conflicts) { $conflict in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(conflict.pendingItem.fileName)
|
||||
.lineLimit(1)
|
||||
.font(.body.weight(.medium))
|
||||
Text(reasonText(for: conflict.reason))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("", selection: $conflict.resolution) {
|
||||
ForEach(DuplicateResolutionAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 100)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(minHeight: 200)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button("Cancel", action: onCancel)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Continue", action: onConfirm)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
|
||||
private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
|
||||
switch reason {
|
||||
case .existingURL(let msg): return msg
|
||||
case .existingFile(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum FileClassifier {
|
||||
private static let musicExtensions: Set<String> = [
|
||||
"aac", "aif", "aiff", "alac", "amr", "ape", "au", "caf", "flac", "m4a",
|
||||
"m4b", "mid", "midi", "mp3", "oga", "ogg", "opus", "ra", "wav", "weba",
|
||||
"wma"
|
||||
]
|
||||
|
||||
private static let movieExtensions: Set<String> = [
|
||||
"3g2", "3gp", "avi", "divx", "f4v", "flv", "m2ts", "m4v", "mkv", "mov",
|
||||
"mp4", "mpeg", "mpg", "mts", "ogm", "ogv", "rm", "rmvb", "ts", "vob",
|
||||
"webm", "wmv"
|
||||
]
|
||||
|
||||
private static let compressedExtensions: Set<String> = [
|
||||
"7z", "ace", "alz", "apk", "appx", "ar", "arc", "arj", "bz", "bz2",
|
||||
"cab", "cpio", "deb", "dmg", "gz", "gzip", "iso", "jar", "lha", "lzh",
|
||||
"lz", "lz4", "lzip", "lzma", "pak", "pkg", "rar", "rpm", "sit", "sitx",
|
||||
"tar", "tbz", "tbz2", "tgz", "tlz", "txz", "war", "whl", "xar", "xz",
|
||||
"z", "zip", "zipx", "zst"
|
||||
]
|
||||
|
||||
private static let pictureExtensions: Set<String> = [
|
||||
"ai", "apng", "avif", "bmp", "cr2", "cr3", "dng", "emf", "eps", "gif",
|
||||
"heic", "heif", "ico", "indd", "jfif", "jpeg", "jpg", "jxl", "nef",
|
||||
"orf", "pbm", "pgm", "png", "pnm", "ppm", "psd", "raw", "rw2",
|
||||
"svg", "tga", "tif", "tiff", "webp", "wmf"
|
||||
]
|
||||
|
||||
private static let documentExtensions: Set<String> = [
|
||||
"azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx",
|
||||
"epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf",
|
||||
"numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx",
|
||||
"ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm",
|
||||
"xlsx", "xml", "xps", "yaml", "yml"
|
||||
]
|
||||
|
||||
static func category(forFileName fileName: String) -> DownloadCategory {
|
||||
let ext = (fileName as NSString).pathExtension.lowercased()
|
||||
guard !ext.isEmpty else { return .other }
|
||||
|
||||
if musicExtensions.contains(ext) { return .musics }
|
||||
if movieExtensions.contains(ext) { return .movies }
|
||||
if compressedExtensions.contains(ext) { return .compressed }
|
||||
if pictureExtensions.contains(ext) { return .pictures }
|
||||
if documentExtensions.contains(ext) { return .documents }
|
||||
return .other
|
||||
}
|
||||
|
||||
static func destinationDirectory(for category: DownloadCategory) -> URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
static func fileName(from url: URL) -> String {
|
||||
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
||||
if !pathName.isEmpty, pathName != "/" {
|
||||
return sanitizedFileName(pathName)
|
||||
}
|
||||
|
||||
let host = url.host(percentEncoded: false) ?? "download"
|
||||
return sanitizedFileName("\(host)-download")
|
||||
}
|
||||
|
||||
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
|
||||
let separators = CharacterSet(charactersIn: "/\\")
|
||||
let invalidCharacters = separators
|
||||
.union(.newlines)
|
||||
.union(.controlCharacters)
|
||||
|
||||
let components = rawValue
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.filter { !$0.isEmpty }
|
||||
let clean = components.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !clean.isEmpty, clean != ".", clean != ".." else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return String(clean.prefix(255))
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FirelinkApp: App {
|
||||
@StateObject private var updateChecker: ReleaseUpdateChecker
|
||||
@StateObject private var settings: AppSettings
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
|
||||
// Server must be retained to keep listening
|
||||
private let extensionServer: LocalExtensionServer?
|
||||
|
||||
init() {
|
||||
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
|
||||
|
||||
let settings = AppSettings()
|
||||
let controller = DownloadController(settings: settings)
|
||||
_settings = StateObject(wrappedValue: settings)
|
||||
_controller = StateObject(wrappedValue: controller)
|
||||
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
|
||||
|
||||
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: "main") {
|
||||
ContentView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(updateChecker)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.task {
|
||||
updateChecker.checkAutomaticallyIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.scheme == "firelink" {
|
||||
if url.host == "add",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let link = queryItems.first(where: { $0.name == "url" })?.value {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = link
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
Window("Add Downloads", id: "add-downloads") {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
|
||||
if let downloadID {
|
||||
DownloadPropertiesWindow(downloadID: downloadID)
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
} else {
|
||||
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Add Downloads...") {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Start Queue") {
|
||||
controller.startQueue()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
|
||||
Button("Stop Downloads") {
|
||||
controller.pauseActiveDownloads()
|
||||
}
|
||||
.disabled(controller.activeCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 23, height: 23)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
Image(nsImage: nsImage)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarIconURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeIcon = projectRoot
|
||||
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainCredentialStore {
|
||||
private static let service = "local.firelink.site-login"
|
||||
|
||||
static func password(for id: UUID) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setPassword(_ password: String, for id: UUID) -> Bool {
|
||||
deletePassword(for: id)
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecValueData as String: Data(password.utf8),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
|
||||
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func deletePassword(for id: UUID) -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
private static let extensionTokenService = "local.firelink.extension-token"
|
||||
private static let extensionTokenAccount = "pairing-token"
|
||||
|
||||
static func extensionToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setExtensionToken(_ token: String) -> Bool {
|
||||
deleteExtensionToken()
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount,
|
||||
kSecValueData as String: Data(token.utf8),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
|
||||
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func deleteExtensionToken() -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let settings: AppSettings
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController, settings: AppSettings) {
|
||||
self.downloadController = downloadController
|
||||
self.settings = settings
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
var selectedPort: UInt16?
|
||||
for portValue in Constants.portRange {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||
do {
|
||||
createdListener = try NWListener(using: parameters)
|
||||
selectedPort = UInt16(portValue)
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let createdListener else {
|
||||
print("Failed to create listener on ports 6412-6422")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.listener = createdListener
|
||||
self.port = selectedPort ?? 6412
|
||||
}
|
||||
|
||||
func start() {
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
listener.stateUpdateHandler = { state in
|
||||
print("LocalExtensionServer state: \(state)")
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receiveRequest(from: connection, accumulatedData: Data())
|
||||
}
|
||||
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
var requestData = accumulatedData
|
||||
if let data {
|
||||
requestData.append(data)
|
||||
}
|
||||
|
||||
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
|
||||
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let request = HTTPRequest(data: requestData) {
|
||||
let status = self.processRequest(request)
|
||||
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
self.sendResponse(.badRequest, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
|
||||
var headers = [
|
||||
"HTTP/1.1 \(status.rawValue) \(status.reason)",
|
||||
"Content-Length: 0",
|
||||
"Connection: close"
|
||||
]
|
||||
|
||||
if let origin, isAllowedExtensionOrigin(origin) {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
|
||||
guard let url = URL(string: origin),
|
||||
let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == "moz-extension" || scheme == "chrome-extension"
|
||||
}
|
||||
|
||||
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.method == "OPTIONS" {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
token == expectedToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.path == "/ping" {
|
||||
return request.method == "GET" ? .ok : .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
return .unsupportedMediaType
|
||||
}
|
||||
|
||||
struct Payload: Decodable {
|
||||
let urls: [String]
|
||||
let referer: String?
|
||||
}
|
||||
|
||||
do {
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
|
||||
let validURLs = payload.urls
|
||||
.prefix(Constants.maxURLCount)
|
||||
.compactMap { rawURL -> String? in
|
||||
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
Constants.allowedSchemes.contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
guard !validURLs.isEmpty else {
|
||||
return .badRequest
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
return .ok
|
||||
} catch {
|
||||
print("Failed to parse local request JSON: \(error)")
|
||||
return .badRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HTTPRequest {
|
||||
var method: String
|
||||
var path: String
|
||||
var headers: [String: String]
|
||||
var body: Data
|
||||
|
||||
init?(data: Data) {
|
||||
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let headerData = data[..<headerRange.lowerBound]
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
guard let requestLine = lines.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
|
||||
guard requestParts.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedHeaders: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else {
|
||||
continue
|
||||
}
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
parsedHeaders[name] = value
|
||||
}
|
||||
|
||||
let bodyStart = headerRange.upperBound
|
||||
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
|
||||
guard expectedBodyLength >= 0,
|
||||
data.count >= bodyStart + expectedBodyLength else {
|
||||
return nil
|
||||
}
|
||||
|
||||
method = String(requestParts[0]).uppercased()
|
||||
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
|
||||
headers = parsedHeaders
|
||||
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
|
||||
}
|
||||
|
||||
func header(named name: String) -> String? {
|
||||
headers[name.lowercased()]
|
||||
}
|
||||
}
|
||||
|
||||
private enum HTTPStatus: Int {
|
||||
case ok = 200
|
||||
case noContent = 204
|
||||
case badRequest = 400
|
||||
case forbidden = 403
|
||||
case notFound = 404
|
||||
case methodNotAllowed = 405
|
||||
case payloadTooLarge = 413
|
||||
case unsupportedMediaType = 415
|
||||
|
||||
var reason: String {
|
||||
switch self {
|
||||
case .ok: "OK"
|
||||
case .noContent: "No Content"
|
||||
case .badRequest: "Bad Request"
|
||||
case .forbidden: "Forbidden"
|
||||
case .notFound: "Not Found"
|
||||
case .methodNotAllowed: "Method Not Allowed"
|
||||
case .payloadTooLarge: "Payload Too Large"
|
||||
case .unsupportedMediaType: "Unsupported Media Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum MediaDetector {
|
||||
private static let supportedDomains: Set<String> = [
|
||||
"youtube.com", "youtu.be",
|
||||
"twitter.com", "x.com",
|
||||
"vimeo.com",
|
||||
"twitch.tv",
|
||||
"instagram.com",
|
||||
"tiktok.com",
|
||||
"facebook.com", "fb.watch",
|
||||
"reddit.com", "v.redd.it",
|
||||
"soundcloud.com"
|
||||
]
|
||||
|
||||
static func isSupportedMedia(url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
|
||||
for domain in supportedDomains {
|
||||
if host == domain || host.hasSuffix(".\(domain)") {
|
||||
// Ignore raw files that happen to be hosted on these domains, if any,
|
||||
// though usually these domains serve web pages for media.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class MediaDownloadEngine: @unchecked Sendable {
|
||||
struct Handle {
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case missingEngine(String)
|
||||
case launchFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingEngine(let msg): return msg
|
||||
case .launchFailed(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start(
|
||||
item: DownloadItem,
|
||||
cookieSource: BrowserCookieSource,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
messageUpdate: @escaping @Sendable (String) -> Void,
|
||||
completion: @escaping @Sendable (Result<URL, Error>) -> Void
|
||||
) async throws -> Handle {
|
||||
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
|
||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||
|
||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = ytDlpURL
|
||||
|
||||
var arguments = [
|
||||
"--newline",
|
||||
"--ffmpeg-location", ffmpegURL.path,
|
||||
"--force-ipv4",
|
||||
"-o", item.destinationPath
|
||||
]
|
||||
|
||||
if let format = item.mediaFormatSelector {
|
||||
arguments.append("-f")
|
||||
arguments.append(format)
|
||||
|
||||
if item.isAudioOnlyMedia == true {
|
||||
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
|
||||
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
|
||||
} else {
|
||||
let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
|
||||
arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
|
||||
}
|
||||
}
|
||||
|
||||
MediaExtractionEngine.appendCommonArguments(
|
||||
to: &arguments,
|
||||
cookieSource: cookieSource,
|
||||
credentials: item.credentials,
|
||||
transferOptions: item.transferOptions
|
||||
)
|
||||
|
||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||
arguments.append(contentsOf: ["--proxy", proxyURI])
|
||||
}
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
|
||||
}
|
||||
|
||||
appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer)
|
||||
|
||||
arguments.append(item.url.absoluteString)
|
||||
process.arguments = arguments
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
let parser = YTDLPProgressParser()
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
let outputPathTracker = YTDLPOutputPathTracker()
|
||||
let completionGate = CompletionGate(completion)
|
||||
let outputHandler = YTDLPOutputHandler(
|
||||
parser: parser,
|
||||
outputPathTracker: outputPathTracker,
|
||||
progress: progress,
|
||||
messageUpdate: messageUpdate
|
||||
)
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||
} else {
|
||||
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
|
||||
}
|
||||
}
|
||||
|
||||
try process.run()
|
||||
messageUpdate("Fetching media data...")
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
errorPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
return Handle(cancel: {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
|
||||
let expectedURL = URL(fileURLWithPath: item.destinationPath)
|
||||
if FileManager.default.fileExists(atPath: expectedURL.path) {
|
||||
return expectedURL
|
||||
}
|
||||
|
||||
if let observedURL = tracker.lastExistingOutputURL {
|
||||
return observedURL
|
||||
}
|
||||
|
||||
let baseName = expectedURL.deletingPathExtension().lastPathComponent
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: item.destinationDirectory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return expectedURL
|
||||
}
|
||||
|
||||
return contents
|
||||
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
|
||||
.max { lhs, rhs in
|
||||
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return lhsDate < rhsDate
|
||||
} ?? expectedURL
|
||||
}
|
||||
|
||||
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
|
||||
guard !message.isEmpty else {
|
||||
return "Exit code \(status)"
|
||||
}
|
||||
|
||||
if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
|
||||
message.localizedCaseInsensitiveContains("not a bot") ||
|
||||
message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
|
||||
return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
|
||||
}
|
||||
|
||||
if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
|
||||
message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
|
||||
return "YouTube challenge solving failed. Install Deno or Node, then retry."
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
|
||||
let connections = min(max(connectionsPerServer, 1), 16)
|
||||
guard connections > 1 else { return }
|
||||
|
||||
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
|
||||
// Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPOutputHandler: @unchecked Sendable {
|
||||
private let parser: YTDLPProgressParser
|
||||
private let outputPathTracker: YTDLPOutputPathTracker
|
||||
private let progress: @Sendable (DownloadProgress) -> Void
|
||||
private let messageUpdate: @Sendable (String) -> Void
|
||||
|
||||
init(
|
||||
parser: YTDLPProgressParser,
|
||||
outputPathTracker: YTDLPOutputPathTracker,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
messageUpdate: @escaping @Sendable (String) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.outputPathTracker = outputPathTracker
|
||||
self.progress = progress
|
||||
self.messageUpdate = messageUpdate
|
||||
}
|
||||
|
||||
func handle(_ text: String) {
|
||||
for line in text.split(whereSeparator: \.isNewline) {
|
||||
let stringLine = String(line)
|
||||
outputPathTracker.observe(stringLine)
|
||||
if let update = parser.parse(stringLine) {
|
||||
progress(update)
|
||||
messageUpdate("Downloading Media")
|
||||
} else if let message = statusMessage(for: stringLine) {
|
||||
messageUpdate(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusMessage(for line: String) -> String? {
|
||||
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
|
||||
return "Processing Media..."
|
||||
}
|
||||
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
|
||||
return "Fetching YouTube data..."
|
||||
}
|
||||
if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
|
||||
return "Preparing media stream..."
|
||||
}
|
||||
if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
|
||||
line.localizedCaseInsensitiveContains("not a bot") ||
|
||||
line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
|
||||
return "YouTube requires browser cookies"
|
||||
}
|
||||
if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
|
||||
line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
|
||||
return "YouTube challenge solver unavailable"
|
||||
}
|
||||
if line.contains("Destination:") {
|
||||
return "Starting media download..."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPOutputPathTracker: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var observedPaths: [String] = []
|
||||
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
|
||||
|
||||
var lastExistingOutputURL: URL? {
|
||||
lock.withLock {
|
||||
observedPaths
|
||||
.reversed()
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
.first { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
}
|
||||
|
||||
func observe(_ line: String) {
|
||||
let candidates = pathCandidates(from: line)
|
||||
guard !candidates.isEmpty else { return }
|
||||
|
||||
lock.withLock {
|
||||
for candidate in candidates where !observedPaths.contains(candidate) {
|
||||
observedPaths.append(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pathCandidates(from line: String) -> [String] {
|
||||
var paths: [String] = []
|
||||
|
||||
if line.contains("Destination:"),
|
||||
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
destination.hasPrefix("/") {
|
||||
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
|
||||
}
|
||||
|
||||
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
|
||||
paths.append(quoted)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
private func quotedCaptures(in text: String) -> [String] {
|
||||
guard let quotedPathRegex else { return [] }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
|
||||
guard match.numberOfRanges > 1,
|
||||
let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func fileExtension(defaultValue: String) -> String {
|
||||
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return ext.isEmpty ? defaultValue : ext
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPProgressParser: @unchecked Sendable {
|
||||
private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
|
||||
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
|
||||
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
|
||||
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
|
||||
|
||||
func parse(_ line: String) -> DownloadProgress? {
|
||||
if line.contains("[download]") && line.contains("%") {
|
||||
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
|
||||
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
|
||||
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: 1
|
||||
)
|
||||
} else if line.contains("[#") && line.contains("DL:") {
|
||||
let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
|
||||
let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
|
||||
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
|
||||
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: cn
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
|
||||
guard let regex else { return nil }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
guard let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
enum AddonState: Equatable, Sendable {
|
||||
case notInstalled
|
||||
case installed(version: String)
|
||||
case failed(error: String)
|
||||
}
|
||||
|
||||
enum AddonType: String, CaseIterable, Sendable {
|
||||
case ytDlp = "yt-dlp"
|
||||
case ffmpeg
|
||||
|
||||
var binaryName: String {
|
||||
switch self {
|
||||
case .ytDlp: return "yt-dlp"
|
||||
case .ffmpeg: return "ffmpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MediaEngineManager: ObservableObject {
|
||||
static let shared = MediaEngineManager()
|
||||
|
||||
@Published var ytDlpState: AddonState = .notInstalled
|
||||
@Published var ffmpegState: AddonState = .notInstalled
|
||||
|
||||
private init() {
|
||||
checkLocalInstallation()
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
|
||||
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||
#if SWIFT_PACKAGE
|
||||
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkLocalInstallation() {
|
||||
for addon in AddonType.allCases {
|
||||
if binaryPath(for: addon) != nil {
|
||||
setState(for: addon, to: .installed(version: "Bundled"))
|
||||
} else {
|
||||
setState(for: addon, to: .notInstalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
|
||||
checkLocalInstallation()
|
||||
let missingAddons = requiredAddons.filter { addon in
|
||||
switch state(for: addon) {
|
||||
case .installed:
|
||||
return false
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !missingAddons.isEmpty else { return }
|
||||
|
||||
for missing in missingAddons {
|
||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||
}
|
||||
|
||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
||||
}
|
||||
|
||||
private func state(for addon: AddonType) -> AddonState {
|
||||
switch addon {
|
||||
case .ytDlp: return ytDlpState
|
||||
case .ffmpeg: return ffmpegState
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(for addon: AddonType, to state: AddonState) {
|
||||
switch addon {
|
||||
case .ytDlp: ytDlpState = state
|
||||
case .ffmpeg: ffmpegState = state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RawMediaFormat: Decodable, Sendable, Equatable {
|
||||
let format_id: String?
|
||||
let ext: String?
|
||||
let resolution: String?
|
||||
let format_note: String?
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let height: Int?
|
||||
let filesize: Int64?
|
||||
let filesize_approx: Int64?
|
||||
}
|
||||
|
||||
struct MediaMetadata: Decodable, Sendable, Equatable {
|
||||
let id: String?
|
||||
let title: String?
|
||||
let uploader: String?
|
||||
let channel: String?
|
||||
let thumbnail: URL?
|
||||
let duration: Double?
|
||||
let formats: [RawMediaFormat]?
|
||||
|
||||
var displayUploader: String? {
|
||||
channel ?? uploader
|
||||
}
|
||||
}
|
||||
|
||||
struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let formatSelector: String
|
||||
let isAudioOnly: Bool
|
||||
let symbol: String
|
||||
let outputExtension: String
|
||||
let detail: String
|
||||
let estimatedBytes: Int64?
|
||||
}
|
||||
|
||||
enum MediaExtractionEngine {
|
||||
private static let metadataTimeoutSeconds: UInt64 = 75
|
||||
|
||||
enum ExtractionError: Error, LocalizedError {
|
||||
case processFailed(String)
|
||||
case invalidOutput
|
||||
case parsingFailed(Error)
|
||||
case timedOut
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
||||
case .invalidOutput: return "Invalid output from media engine."
|
||||
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
||||
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchMetadata(
|
||||
for url: URL,
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
}
|
||||
let ytDlpPath = ytDlpURL.path
|
||||
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
args.append(url.absoluteString)
|
||||
|
||||
let data = try await YTDLPMetadataProcess(
|
||||
executableURL: URL(fileURLWithPath: ytDlpPath),
|
||||
arguments: args
|
||||
).run(timeoutSeconds: metadataTimeoutSeconds)
|
||||
|
||||
guard !data.isEmpty else {
|
||||
throw ExtractionError.invalidOutput
|
||||
}
|
||||
|
||||
do {
|
||||
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
|
||||
let options = extractOptions(from: metadata)
|
||||
return (metadata, options)
|
||||
} catch {
|
||||
throw ExtractionError.parsingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendCommonArguments(
|
||||
to args: inout [String],
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
||||
}
|
||||
|
||||
appendJavaScriptRuntimeArguments(to: &args)
|
||||
|
||||
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||
args.append(contentsOf: ["--add-header", header.headerLine])
|
||||
}
|
||||
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!cookieHeader.isEmpty {
|
||||
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
|
||||
}
|
||||
|
||||
if let credentials, !credentials.isEmpty {
|
||||
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
|
||||
var runtimes: [String] = []
|
||||
if let denoPath = executablePath(named: "deno", candidates: [
|
||||
"/opt/homebrew/bin/deno",
|
||||
"/usr/local/bin/deno"
|
||||
]) {
|
||||
runtimes.append("deno:\(denoPath)")
|
||||
}
|
||||
|
||||
if let nodePath = executablePath(named: "node", candidates: [
|
||||
"/opt/homebrew/bin/node",
|
||||
"/usr/local/bin/node",
|
||||
"/usr/bin/node"
|
||||
]) {
|
||||
runtimes.append("node:\(nodePath)")
|
||||
}
|
||||
|
||||
if !runtimes.isEmpty {
|
||||
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
|
||||
}
|
||||
}
|
||||
|
||||
private static func executablePath(named name: String, candidates: [String]) -> String? {
|
||||
var safeCandidates = candidates
|
||||
safeCandidates.append(contentsOf: [
|
||||
"/opt/homebrew/bin/\(name)",
|
||||
"/usr/local/bin/\(name)",
|
||||
"/usr/bin/\(name)",
|
||||
"/opt/local/bin/\(name)"
|
||||
])
|
||||
|
||||
for candidate in safeCandidates {
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
|
||||
var options: [CleanFormatOption] = []
|
||||
let rawFormats = metadata.formats ?? []
|
||||
|
||||
let standardResolutions = [
|
||||
(2160, "4K"),
|
||||
(1440, "1440p"),
|
||||
(1080, "1080p"),
|
||||
(720, "720p"),
|
||||
(480, "480p"),
|
||||
(360, "360p")
|
||||
]
|
||||
|
||||
let availableResolutions = standardResolutions.filter { resolution, _ in
|
||||
rawFormats.contains { format in
|
||||
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
|
||||
}
|
||||
}
|
||||
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
|
||||
let videoContainers = [
|
||||
("mp4", "MP4"),
|
||||
("mkv", "MKV"),
|
||||
("webm", "WebM")
|
||||
]
|
||||
|
||||
for (height, qualityName) in videoQualities {
|
||||
for (container, containerName) in videoContainers {
|
||||
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
|
||||
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
|
||||
options.append(CleanFormatOption(
|
||||
name: "\(qualityName) \(containerName)",
|
||||
formatSelector: videoSelector(height: height, container: container),
|
||||
isAudioOnly: false,
|
||||
symbol: "play.tv.fill",
|
||||
outputExtension: container,
|
||||
detail: optionDetail(
|
||||
base: height == nil ? "Best available video" : "Up to \(qualityName)",
|
||||
estimatedBytes: estimatedBytes
|
||||
),
|
||||
estimatedBytes: estimatedBytes
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: nil) {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio MP3",
|
||||
formatSelector: "bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "music.note",
|
||||
outputExtension: "mp3",
|
||||
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
))
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio M4A",
|
||||
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "waveform",
|
||||
outputExtension: "m4a",
|
||||
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
))
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: "webm") {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio Opus",
|
||||
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "waveform",
|
||||
outputExtension: "opus",
|
||||
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
))
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
|
||||
formats.contains { format in
|
||||
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
||||
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
|
||||
formats.contains { format in
|
||||
guard isAudio(format) else { return false }
|
||||
guard let preferredExtension else { return true }
|
||||
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
|
||||
let videoBytes = formats
|
||||
.filter { format in
|
||||
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
||||
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
||||
}
|
||||
.compactMap { formatSize($0) }
|
||||
.max()
|
||||
|
||||
guard let videoBytes else { return nil }
|
||||
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
|
||||
estimatedAudioBytes(formats, preferredExtension: nil) ??
|
||||
0
|
||||
return videoBytes + audioBytes
|
||||
}
|
||||
|
||||
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
|
||||
let preferred = formats
|
||||
.filter { format in
|
||||
guard isAudio(format) else { return false }
|
||||
guard let preferredExtension else { return true }
|
||||
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
||||
}
|
||||
.compactMap { formatSize($0) }
|
||||
.max()
|
||||
|
||||
if preferred != nil || preferredExtension == nil {
|
||||
return preferred
|
||||
}
|
||||
|
||||
return estimatedAudioBytes(formats, preferredExtension: nil)
|
||||
}
|
||||
|
||||
private static func isVideo(_ format: RawMediaFormat) -> Bool {
|
||||
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private static func isAudio(_ format: RawMediaFormat) -> Bool {
|
||||
let acodec = format.acodec?.lowercased()
|
||||
let vcodec = format.vcodec?.lowercased()
|
||||
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
|
||||
}
|
||||
|
||||
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
|
||||
guard let height else { return true }
|
||||
guard let formatHeight = format.height else { return false }
|
||||
return formatHeight <= height && formatHeight >= height - 100
|
||||
}
|
||||
|
||||
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
|
||||
format.filesize ?? format.filesize_approx
|
||||
}
|
||||
|
||||
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
|
||||
guard let estimatedBytes, estimatedBytes > 0 else { return base }
|
||||
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
|
||||
}
|
||||
|
||||
private static func videoSelector(height: Int?, container: String) -> String {
|
||||
let filter = heightFilter(height)
|
||||
switch container {
|
||||
case "mp4":
|
||||
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
case "webm":
|
||||
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
default:
|
||||
return "bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func heightFilter(_ height: Int?) -> String {
|
||||
guard let height else { return "" }
|
||||
return "[height<=\(height)]"
|
||||
}
|
||||
}
|
||||
|
||||
private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
private let executableURL: URL
|
||||
private let arguments: [String]
|
||||
private let lock = NSLock()
|
||||
private var process: Process?
|
||||
|
||||
init(executableURL: URL, arguments: [String]) {
|
||||
self.executableURL = executableURL
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
func run(timeoutSeconds: UInt64) async throws -> Data {
|
||||
try await withTaskCancellationHandler {
|
||||
try await withThrowingTaskGroup(of: Data.self) { group in
|
||||
group.addTask {
|
||||
try await self.runProcess()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
self.terminate()
|
||||
throw MediaExtractionEngine.ExtractionError.timedOut
|
||||
}
|
||||
|
||||
guard let result = try await group.next() else {
|
||||
throw MediaExtractionEngine.ExtractionError.invalidOutput
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
} onCancel: {
|
||||
self.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func runProcess() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
|
||||
process.executableURL = executableURL
|
||||
process.arguments = arguments
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = nil
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
errorPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
// We do not care about the DispatchGroup if we throw immediately here
|
||||
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func terminate() {
|
||||
lock.withLock {
|
||||
if let process, process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadStatus: String, Codable, CaseIterable, Sendable {
|
||||
case queued = "Queued"
|
||||
case downloading = "Downloading"
|
||||
case paused = "Paused"
|
||||
case completed = "Completed"
|
||||
case failed = "Failed"
|
||||
case canceled = "Canceled"
|
||||
}
|
||||
|
||||
enum DownloadCategory: String, Codable, CaseIterable, Sendable {
|
||||
case musics = "Musics"
|
||||
case movies = "Movies"
|
||||
case compressed = "Compressed"
|
||||
case pictures = "Pictures"
|
||||
case documents = "Documents"
|
||||
case other = "Other"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .musics: "music.note"
|
||||
case .movies: "film"
|
||||
case .compressed: "archivebox"
|
||||
case .pictures: "photo"
|
||||
case .documents: "doc.text"
|
||||
case .other: "folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadQueue: Identifiable, Codable, Equatable, Sendable {
|
||||
static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||
|
||||
var id = UUID()
|
||||
var name: String
|
||||
|
||||
var isMain: Bool {
|
||||
id == Self.mainQueueID
|
||||
}
|
||||
|
||||
static var main: DownloadQueue {
|
||||
DownloadQueue(id: mainQueueID, name: "Main queue")
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadCredentials: Codable, Equatable, Sendable {
|
||||
var username: String
|
||||
var password: String
|
||||
|
||||
var isEmpty: Bool {
|
||||
username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
password.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case md5
|
||||
case sha1 = "sha-1"
|
||||
case sha256 = "sha-256"
|
||||
case sha512 = "sha-512"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .md5: "MD5"
|
||||
case .sha1: "SHA-1"
|
||||
case .sha256: "SHA-256"
|
||||
case .sha512: "SHA-512"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadChecksum: Codable, Equatable, Sendable {
|
||||
var algorithm: ChecksumAlgorithm
|
||||
var value: String
|
||||
|
||||
var normalized: DownloadChecksum {
|
||||
DownloadChecksum(
|
||||
algorithm: algorithm,
|
||||
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadRequestHeader: Codable, Equatable, Sendable {
|
||||
var name: String
|
||||
var value: String
|
||||
|
||||
var normalized: DownloadRequestHeader {
|
||||
DownloadRequestHeader(
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
let clean = normalized
|
||||
return clean.name.isEmpty && clean.value.isEmpty
|
||||
}
|
||||
|
||||
var headerLine: String {
|
||||
let clean = normalized
|
||||
return "\(clean.name): \(clean.value)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadTransferOptions: Equatable, Sendable {
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader] = []
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL] = []
|
||||
}
|
||||
|
||||
enum DownloadTransferOptionParser {
|
||||
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
|
||||
headerLines(text).compactMap { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let header = DownloadRequestHeader(name: name, value: value).normalized
|
||||
return header.isEmpty || header.name.isEmpty ? nil : header
|
||||
}
|
||||
}
|
||||
|
||||
static func invalidHeaderLines(_ text: String) -> [String] {
|
||||
headerLines(text).filter { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return true }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanCookieHeader(_ text: String) -> String? {
|
||||
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.lowercased().hasPrefix("cookie:") {
|
||||
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
static func parseMirrorURLs(_ text: String) -> [URL] {
|
||||
DownloadURLParser.parse(text)
|
||||
}
|
||||
|
||||
static func invalidMirrorLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
|
||||
}
|
||||
|
||||
private static func headerLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var id = UUID()
|
||||
var url: URL
|
||||
var fileName: String
|
||||
var category: DownloadCategory
|
||||
var destinationDirectory: URL
|
||||
var connectionsPerServer: Int
|
||||
var credentials: DownloadCredentials?
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader]?
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL]?
|
||||
var speedLimitKiBPerSecond: Int?
|
||||
var status: DownloadStatus = .queued
|
||||
var progress: Double = 0
|
||||
var speedText: String = "-"
|
||||
var etaText: String = "-"
|
||||
var connectionCount: Int = 0
|
||||
var sizeBytes: Int64?
|
||||
var bytesText: String = "-"
|
||||
var message: String = ""
|
||||
var createdAt = Date()
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
var mediaFormatSelector: String?
|
||||
var isAudioOnlyMedia: Bool?
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
var sortableSize: Int64 {
|
||||
sizeBytes ?? 0
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
requestHeaders: requestHeaders ?? [],
|
||||
cookieHeader: cookieHeader,
|
||||
mirrorURLs: mirrorURLs ?? []
|
||||
)
|
||||
}
|
||||
|
||||
var speedLimitText: String {
|
||||
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
|
||||
return "No limit"
|
||||
}
|
||||
return "\(speedLimitKiBPerSecond) KiB/s"
|
||||
}
|
||||
|
||||
var redactedForPersistence: DownloadItem {
|
||||
var item = self
|
||||
if item.credentials != nil {
|
||||
item.credentials?.password = ""
|
||||
}
|
||||
item.cookieHeader = nil
|
||||
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadRequestHeader {
|
||||
var containsSensitiveValue: Bool {
|
||||
switch normalized.name.lowercased() {
|
||||
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgress: Equatable, Sendable {
|
||||
var fraction: Double
|
||||
var bytesText: String
|
||||
var speedText: String
|
||||
var etaText: String
|
||||
var connectionCount: Int
|
||||
}
|
||||
|
||||
struct PendingDownload: Identifiable, Equatable, Sendable {
|
||||
enum MetadataState: Equatable, Sendable {
|
||||
case pending
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
var id = UUID()
|
||||
var url: URL
|
||||
var fileName: String
|
||||
var category: DownloadCategory
|
||||
var defaultDirectory: URL
|
||||
var sizeBytes: Int64?
|
||||
var mimeType: String?
|
||||
var state: MetadataState = .pending
|
||||
|
||||
var isMedia: Bool = false
|
||||
var mediaOptions: [CleanFormatOption] = []
|
||||
var selectedMediaOption: CleanFormatOption?
|
||||
var mediaMetadata: MediaMetadata?
|
||||
|
||||
var destinationPath: String {
|
||||
defaultDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct GitHubReleaseCheckService: @unchecked Sendable {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
init(
|
||||
owner: String = "nimbold",
|
||||
repository: String = "Firelink",
|
||||
fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
|
||||
try await URLSession.shared.data(for: request)
|
||||
}
|
||||
) {
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
|
||||
guard let current = AppVersion(currentVersion) else {
|
||||
throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
|
||||
}
|
||||
|
||||
let release = try await latestStableRelease()
|
||||
guard let latest = AppVersion(release.tagName) else {
|
||||
throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
|
||||
}
|
||||
|
||||
let update = AvailableReleaseUpdate(
|
||||
version: latest.description,
|
||||
tagName: release.tagName,
|
||||
title: release.name?.isEmpty == false ? release.name! : release.tagName,
|
||||
releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
|
||||
releaseURL: release.htmlURL,
|
||||
publishedAt: release.publishedAt
|
||||
)
|
||||
|
||||
if latest > current {
|
||||
return .updateAvailable(update)
|
||||
}
|
||||
|
||||
return .upToDate(latestVersion: latest.description, localVersion: current.description)
|
||||
}
|
||||
|
||||
private func latestStableRelease() async throws -> GitHubRelease {
|
||||
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
|
||||
throw ReleaseCheckFailure.invalidReleaseURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await fetch(request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ReleaseCheckFailure.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
|
||||
let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
|
||||
guard !stableReleases.isEmpty else {
|
||||
throw ReleaseCheckFailure.noStableRelease
|
||||
}
|
||||
|
||||
let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
|
||||
guard let version = AppVersion(release.tagName) else { return nil }
|
||||
return (release, version)
|
||||
}
|
||||
|
||||
guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
|
||||
throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
|
||||
}
|
||||
|
||||
return latest.release
|
||||
}
|
||||
}
|
||||
|
||||
struct GitHubRelease: Decodable, Equatable {
|
||||
let tagName: String
|
||||
let name: String?
|
||||
let body: String?
|
||||
let htmlURL: URL
|
||||
let draft: Bool
|
||||
let prerelease: Bool
|
||||
let publishedAt: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tagName = "tag_name"
|
||||
case name
|
||||
case body
|
||||
case htmlURL = "html_url"
|
||||
case draft
|
||||
case prerelease
|
||||
case publishedAt = "published_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct AvailableReleaseUpdate: Equatable, Identifiable {
|
||||
var id: String { tagName }
|
||||
let version: String
|
||||
let tagName: String
|
||||
let title: String
|
||||
let releaseNotes: String
|
||||
let releaseURL: URL
|
||||
let publishedAt: Date?
|
||||
}
|
||||
|
||||
enum ReleaseCheckOutcome: Equatable {
|
||||
case updateAvailable(AvailableReleaseUpdate)
|
||||
case upToDate(latestVersion: String, localVersion: String)
|
||||
}
|
||||
|
||||
enum ReleaseUpdateState: Equatable {
|
||||
case idle
|
||||
case checking
|
||||
case updateAvailable(AvailableReleaseUpdate)
|
||||
case upToDate(latestVersion: String, localVersion: String)
|
||||
case failed(message: String, recovery: String)
|
||||
}
|
||||
|
||||
enum ReleaseCheckFailure: Error, Equatable {
|
||||
case invalidReleaseURL
|
||||
case invalidResponse
|
||||
case httpStatus(Int)
|
||||
case noStableRelease
|
||||
case invalidCurrentVersion(String)
|
||||
case invalidReleaseVersion(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReleaseUpdateChecker: ObservableObject {
|
||||
@Published private(set) var state: ReleaseUpdateState = .idle
|
||||
@Published var automaticallyChecksForUpdates: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private let service: GitHubReleaseCheckService
|
||||
private let bundle: Bundle
|
||||
private var automaticCheckTask: Task<Void, Never>?
|
||||
|
||||
private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
|
||||
private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
|
||||
private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
|
||||
|
||||
init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
|
||||
self.service = service
|
||||
self.bundle = bundle
|
||||
|
||||
if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
|
||||
self.automaticallyChecksForUpdates = true
|
||||
} else {
|
||||
self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
|
||||
}
|
||||
}
|
||||
|
||||
var currentVersion: String {
|
||||
bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
guard state != .checking else { return }
|
||||
state = .checking
|
||||
|
||||
Task {
|
||||
do {
|
||||
let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
|
||||
UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
|
||||
apply(outcome)
|
||||
} catch {
|
||||
state = Self.failedState(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomaticallyIfNeeded() {
|
||||
guard automaticallyChecksForUpdates else { return }
|
||||
guard state == .idle else { return }
|
||||
|
||||
if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
|
||||
Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
|
||||
return
|
||||
}
|
||||
|
||||
automaticCheckTask?.cancel()
|
||||
automaticCheckTask = Task { [weak self] in
|
||||
self?.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ outcome: ReleaseCheckOutcome) {
|
||||
switch outcome {
|
||||
case .updateAvailable(let update):
|
||||
state = .updateAvailable(update)
|
||||
case .upToDate(let latestVersion, let localVersion):
|
||||
state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private static func failedState(for error: Error) -> ReleaseUpdateState {
|
||||
if let urlError = error as? URLError {
|
||||
switch urlError.code {
|
||||
case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
|
||||
return .failed(
|
||||
message: "Couldn't reach GitHub.",
|
||||
recovery: "Check your internet connection, then try again."
|
||||
)
|
||||
default:
|
||||
return .failed(
|
||||
message: "The update check couldn't finish.",
|
||||
recovery: urlError.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let failure = error as? ReleaseCheckFailure {
|
||||
switch failure {
|
||||
case .httpStatus(let statusCode):
|
||||
return .failed(
|
||||
message: "GitHub returned HTTP \(statusCode).",
|
||||
recovery: "Try again in a moment, or open the releases page manually."
|
||||
)
|
||||
case .noStableRelease:
|
||||
return .failed(
|
||||
message: "No stable release was found.",
|
||||
recovery: "Open GitHub Releases to check the project manually."
|
||||
)
|
||||
case .invalidCurrentVersion(let version):
|
||||
return .failed(
|
||||
message: "Firelink's current version could not be read.",
|
||||
recovery: "The app reported version \(version)."
|
||||
)
|
||||
case .invalidReleaseVersion(let tag):
|
||||
return .failed(
|
||||
message: "The latest release tag could not be compared.",
|
||||
recovery: "GitHub reported \(tag)."
|
||||
)
|
||||
case .invalidReleaseURL, .invalidResponse:
|
||||
return .failed(
|
||||
message: "The GitHub response could not be read.",
|
||||
recovery: "Try again in a moment."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return .failed(
|
||||
message: "The update check failed.",
|
||||
recovery: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppVersion: Comparable, CustomStringConvertible {
|
||||
private let components: [Int]
|
||||
|
||||
init?(_ rawValue: String) {
|
||||
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("v") || value.hasPrefix("V") {
|
||||
value.removeFirst()
|
||||
}
|
||||
|
||||
let prefix = value.prefix { character in
|
||||
character.isNumber || character == "."
|
||||
}
|
||||
|
||||
let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
guard !version.isEmpty else { return nil }
|
||||
|
||||
let components = version.split(separator: ".").compactMap { Int($0) }
|
||||
guard !components.isEmpty, components.count == version.split(separator: ".").count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.components = components
|
||||
}
|
||||
|
||||
var description: String {
|
||||
components.map(String.init).joined(separator: ".")
|
||||
}
|
||||
|
||||
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func == (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
|
||||
if left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension JSONDecoder {
|
||||
static var githubReleaseDecoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
|
||||
case doNothing = "Do nothing"
|
||||
case sleep = "Sleep"
|
||||
case restart = "Restart"
|
||||
case shutdown = "Shut down"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .sunday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday: "T"
|
||||
case .wednesday: "W"
|
||||
case .thursday: "T"
|
||||
case .friday: "F"
|
||||
case .saturday: "S"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SchedulerController: ObservableObject {
|
||||
@Published var settings: SchedulerSettings
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var hasAutomationPermission: Bool = false
|
||||
private let downloadController: DownloadController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timer: Timer?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.SchedulerSettings.v1"
|
||||
|
||||
// We only trigger once per minute to prevent multiple triggers in the same minute
|
||||
private var lastTriggeredMinute: Date?
|
||||
|
||||
init(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
|
||||
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
|
||||
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
|
||||
self.settings = stored
|
||||
} else {
|
||||
self.settings = SchedulerSettings()
|
||||
}
|
||||
if self.settings.targetQueueIDs.isEmpty {
|
||||
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
startTimer()
|
||||
|
||||
$settings
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe downloads to check if we should trigger post-action
|
||||
downloadController.$downloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.checkIfRunningFinished()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkSchedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSchedule() {
|
||||
guard settings.isEnabled else { return }
|
||||
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Check if we already triggered in this exact minute
|
||||
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
|
||||
return
|
||||
}
|
||||
|
||||
let startHour = calendar.component(.hour, from: settings.startTime)
|
||||
let startMinute = calendar.component(.minute, from: settings.startTime)
|
||||
|
||||
let currentHour = calendar.component(.hour, from: now)
|
||||
let currentMinute = calendar.component(.minute, from: now)
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
let shouldRun: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRun = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRun {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerQueues() {
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let runnableQueueIDs = targetQueueIDs.filter { queueID in
|
||||
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
||||
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
||||
}
|
||||
|
||||
guard !runnableQueueIDs.isEmpty else { return }
|
||||
|
||||
isRunning = true
|
||||
|
||||
for queueID in runnableQueueIDs {
|
||||
downloadController.startQueue(queueID: queueID)
|
||||
}
|
||||
|
||||
checkIfRunningFinished()
|
||||
}
|
||||
|
||||
private func checkIfRunningFinished() {
|
||||
guard isRunning else { return }
|
||||
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let hasActiveItems = targetQueueIDs.contains { queueID in
|
||||
downloadController.queueItems(for: queueID).contains {
|
||||
$0.status == .queued || $0.status == .downloading
|
||||
}
|
||||
}
|
||||
|
||||
if !hasActiveItems {
|
||||
isRunning = false
|
||||
performPostAction()
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveTargetQueueIDs() -> Set<UUID> {
|
||||
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func performPostAction() {
|
||||
guard settings.postQueueAction != .doNothing else { return }
|
||||
|
||||
var scriptCode = ""
|
||||
switch settings.postQueueAction {
|
||||
case .sleep:
|
||||
scriptCode = "tell application \"Finder\" to sleep"
|
||||
case .restart:
|
||||
scriptCode = "tell application \"Finder\" to restart"
|
||||
case .shutdown:
|
||||
scriptCode = "tell application \"Finder\" to shut down"
|
||||
case .doNothing:
|
||||
break
|
||||
}
|
||||
|
||||
guard !scriptCode.isEmpty else { return }
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to perform scheduler post action: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
|
||||
hasAutomationPermission = (status == noErr)
|
||||
}
|
||||
|
||||
func requestAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
|
||||
|
||||
if status != noErr {
|
||||
triggerAutomationConsentPrompt()
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
|
||||
if !hasAutomationPermission {
|
||||
openAutomationPermissionSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func openAutomationPermissionSettings() {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
|
||||
return
|
||||
}
|
||||
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func triggerAutomationConsentPrompt() {
|
||||
let scriptCode = "tell application \"Finder\" to get name"
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to trigger Automation permission prompt: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SchedulerView: View {
|
||||
@EnvironmentObject private var downloadController: DownloadController
|
||||
@EnvironmentObject private var schedulerController: SchedulerController
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var startTime: Date = Date()
|
||||
@State private var isEveryday: Bool = true
|
||||
@State private var selectedDays: Set<SchedulerDay> = []
|
||||
@State private var postQueueAction: PostQueueAction = .doNothing
|
||||
@State private var targetQueueIDs: Set<UUID> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
timeSelectionSection
|
||||
queueSelectionSection
|
||||
postActionSection
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
Divider()
|
||||
permissionsSection
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Scheduler")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save Settings") {
|
||||
saveState()
|
||||
withAnimation(.spring()) {
|
||||
showSaveToast = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation {
|
||||
showSaveToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var timeSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Start Time")
|
||||
.font(.headline)
|
||||
|
||||
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
|
||||
Toggle("Everyday", isOn: $isEveryday)
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Toggle(day.shortName, isOn: Binding(
|
||||
get: { selectedDays.contains(day) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
selectedDays.insert(day)
|
||||
} else {
|
||||
selectedDays.remove(day)
|
||||
}
|
||||
}
|
||||
))
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var queueSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Queues to Start")
|
||||
.font(.headline)
|
||||
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(downloadController.queues) { queue in
|
||||
Toggle(queue.name, isOn: Binding(
|
||||
get: { targetQueueIDs.contains(queue.id) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
targetQueueIDs.insert(queue.id)
|
||||
} else {
|
||||
targetQueueIDs.remove(queue.id)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var postActionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("After Completion")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("System Permissions")
|
||||
.font(.headline)
|
||||
|
||||
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if schedulerController.hasAutomationPermission {
|
||||
Button("Revoke Permissions") {
|
||||
schedulerController.openAutomationPermissionSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
schedulerController.requestAutomationPermission()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Settings Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func loadState() {
|
||||
isEnabled = schedulerController.settings.isEnabled
|
||||
startTime = schedulerController.settings.startTime
|
||||
isEveryday = schedulerController.settings.isEveryday
|
||||
selectedDays = schedulerController.settings.selectedDays
|
||||
postQueueAction = schedulerController.settings.postQueueAction
|
||||
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: schedulerController.settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
schedulerController.settings.isEnabled = isEnabled
|
||||
schedulerController.settings.startTime = startTime
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: targetQueueIDs
|
||||
schedulerController.saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettingsPane: View {
|
||||
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
|
||||
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
|
||||
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
|
||||
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section("Updates") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
updateStatusView
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 2)
|
||||
|
||||
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.animation(.easeInOut, value: updateChecker.state)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Created by NimBold")
|
||||
Spacer()
|
||||
Link(destination: projectURL) {
|
||||
HStack(spacing: 4) {
|
||||
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
|
||||
let nsImage = NSImage(contentsOfFile: imgPath) {
|
||||
Image(nsImage: nsImage)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Source Code")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Powered by")
|
||||
HStack(spacing: 4) {
|
||||
Link("aria2", destination: aria2URL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("yt-dlp", destination: ytDlpURL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("ffmpeg", destination: ffmpegURL)
|
||||
}
|
||||
Spacer()
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
|
||||
Text("Copyright © 2026 NimBold. All rights reserved.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var updateStatusView: some View {
|
||||
switch updateChecker.state {
|
||||
case .idle:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "arrow.down.circle",
|
||||
tint: .blue,
|
||||
title: "Check for Updates",
|
||||
subtitle: "Firelink checks GitHub Releases and opens the download page when a new version is available."
|
||||
)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .checking:
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Checking GitHub Releases")
|
||||
.font(.headline)
|
||||
Text("Looking for the latest stable Firelink release.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
case .updateAvailable(let update):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "arrow.down.circle.fill",
|
||||
tint: .green,
|
||||
title: "Firelink \(update.version) Is Available",
|
||||
subtitle: "You have Firelink \(appVersion). Download the new release from GitHub when you're ready."
|
||||
)
|
||||
|
||||
releaseNotesDisclosure(for: update)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
NSWorkspace.shared.open(update.releaseURL)
|
||||
} label: {
|
||||
Label("Open GitHub Release", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
case .upToDate(let latestVersion, let localVersion):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let subtitle = latestVersion == localVersion
|
||||
? "Firelink \(localVersion) is the newest stable release."
|
||||
: "Firelink \(localVersion) is newer than the latest stable GitHub release, \(latestVersion)."
|
||||
|
||||
updateHeader(
|
||||
systemImage: "checkmark.seal.fill",
|
||||
tint: .green,
|
||||
title: "You're Up to Date",
|
||||
subtitle: subtitle
|
||||
)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed(let message, let recovery):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange,
|
||||
title: message,
|
||||
subtitle: recovery
|
||||
)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Open Releases", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHeader(systemImage: String, tint: Color, title: String, subtitle: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseNotesDisclosure(for update: AvailableReleaseUpdate) -> some View {
|
||||
DisclosureGroup("What's New") {
|
||||
ScrollView {
|
||||
Text(releaseNotes(from: update.releaseNotes))
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxHeight: 180)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseNotes(from markdown: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(
|
||||
"Default connections per server: \(settings.perServerConnections)",
|
||||
value: $settings.perServerConnections,
|
||||
in: 1...16
|
||||
)
|
||||
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Stepper(
|
||||
"Parallel downloads: \(settings.maxConcurrentDownloads)",
|
||||
value: $settings.maxConcurrentDownloads,
|
||||
in: 1...12
|
||||
)
|
||||
Text("Controls how many files Firelink downloads at the same time.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Global speed limit") {
|
||||
HStack {
|
||||
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct EngineSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@StateObject private var engineManager = MediaEngineManager.shared
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Binary Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.help(executableURL?.path ?? "")
|
||||
} header: {
|
||||
Text("Core Downloader (Aria2)")
|
||||
} footer: {
|
||||
if executableURL == nil {
|
||||
Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.")
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Handles core HTTP/FTP and BitTorrent downloads.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
Text(source.rawValue).tag(source)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
} header: {
|
||||
Text("Media Extractors")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Powers video and audio extraction from supported sites.")
|
||||
|
||||
if settings.mediaCookieSource != .none {
|
||||
Text(settings.mediaCookieSource.statusDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
|
||||
LabeledContent(title) {
|
||||
VStack(alignment: .trailing) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Text("Error")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationSettingsPane: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showToast = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: "puzzlepiece.extension.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connect Browser Extension")
|
||||
.font(.title.weight(.bold))
|
||||
Text("Capture downloads directly from your browser in three easy steps.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.body)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Step 1: Copy Token
|
||||
StepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Pairing Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
}
|
||||
)
|
||||
|
||||
// Step 2: Get Extension
|
||||
StepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your favorite browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Step 3: Paste and Save
|
||||
StepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Diagnostics
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(.top, 8)
|
||||
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepCardView: View {
|
||||
let stepNumber: Int
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let actionText: String?
|
||||
let action: (() -> Void)?
|
||||
var secondaryActionText: String? = nil
|
||||
var secondaryAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.frame(width: 32, height: 32)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.headline, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
|
||||
// Text Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action Button
|
||||
HStack(spacing: 8) {
|
||||
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||
Button(action: secondaryAction) {
|
||||
Text(secondaryActionText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundColor(.primary)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
GridRow {
|
||||
Divider()
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Folders will be created automatically when applied.")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
let category: DownloadCategory
|
||||
|
||||
@State private var path = ""
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
|
||||
Text(displayMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncPathFromSettings()
|
||||
}
|
||||
.onChange(of: settings.downloadDirectories[category]) { _, _ in
|
||||
syncPathFromSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func selectFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.directoryURL = settings.destinationDirectory(for: category)
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
path = url.path
|
||||
settings.setDirectory(url.path, for: category)
|
||||
message = "Saved."
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPathFromSettings() {
|
||||
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
|
||||
message = ""
|
||||
}
|
||||
|
||||
private func applyPath() {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
message = "Enter a folder path."
|
||||
return
|
||||
}
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
guard isDirectory.boolValue else {
|
||||
message = "This path points to a file, not a folder."
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: expanded, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
message = "Could not create folder: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard FileManager.default.isWritableFile(atPath: expanded) else {
|
||||
message = "Firelink cannot write to this folder."
|
||||
return
|
||||
}
|
||||
|
||||
settings.setDirectory(expanded, for: category)
|
||||
path = expanded
|
||||
message = "Saved."
|
||||
}
|
||||
|
||||
private func statusMessage(for path: String) -> String? {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "Enter a folder path." }
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
if !isDirectory.boolValue {
|
||||
return "This path points to a file, not a folder."
|
||||
}
|
||||
return FileManager.default.isWritableFile(atPath: expanded)
|
||||
? nil
|
||||
: "Firelink cannot write to this folder."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isErrorMessage(_ message: String) -> Bool {
|
||||
message == "This path points to a file, not a folder." ||
|
||||
message.hasPrefix("Could not create folder:") ||
|
||||
message == "Firelink cannot write to this folder." ||
|
||||
message == "Enter a folder path."
|
||||
}
|
||||
}
|
||||
|
||||
struct BulkDirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var path = ""
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Base folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
|
||||
} else {
|
||||
Text("Automatically creates all category folders in the selected path.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
path = url.path
|
||||
applyPath()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPath() {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
message = "Enter a base folder path."
|
||||
return
|
||||
}
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
guard isDirectory.boolValue else {
|
||||
message = "This path points to a file, not a folder."
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: expanded, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
message = "Could not create folder: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard FileManager.default.isWritableFile(atPath: expanded) else {
|
||||
message = "Firelink cannot write to this folder."
|
||||
return
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases {
|
||||
let categoryPath = (expanded as NSString).appendingPathComponent(category.rawValue)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: categoryPath, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
settings.setDirectory(categoryPath, for: category)
|
||||
} catch {
|
||||
message = "Could not create category folder \(category.rawValue): \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
message = "Created all categories in base folder."
|
||||
path = ""
|
||||
}
|
||||
|
||||
private func isErrorMessage(_ message: String) -> Bool {
|
||||
message == "This path points to a file, not a folder." ||
|
||||
message.hasPrefix("Could not create folder:") ||
|
||||
message.hasPrefix("Could not create category folder") ||
|
||||
message == "Firelink cannot write to this folder." ||
|
||||
message == "Enter a base folder path."
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LookAndFeelSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("App Theme") {
|
||||
Picker("Theme", selection: $settings.appTheme) {
|
||||
ForEach(AppTheme.allCases) { theme in
|
||||
Text(theme.rawValue)
|
||||
.tag(theme)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text("Select a color palette for the app's user interface.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Display") {
|
||||
Picker("Font Size", selection: $settings.appFontSize) {
|
||||
ForEach(AppFontSize.allCases) { size in
|
||||
Text(size.rawValue).tag(size)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("List Row Density", selection: $settings.listRowDensity) {
|
||||
ForEach(ListRowDensity.allCases) { density in
|
||||
Text(density.rawValue).tag(density)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
|
||||
Text("Provides quick access to downloads and queues from the macOS menu bar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NetworkSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Proxy", selection: proxyBinding(\.mode)) {
|
||||
ForEach(ProxyMode.allCases, id: \.self) { mode in
|
||||
Text(mode.title)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
if settings.proxySettings.mode == .custom {
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("IP or Host")
|
||||
TextField("127.0.0.1", text: proxyBinding(\.host))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Port")
|
||||
TextField("8080", value: proxyBinding(\.port), format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 110)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(networkSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if settings.proxySettings.mode == .custom {
|
||||
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var networkSummary: String {
|
||||
switch settings.proxySettings.mode {
|
||||
case .none:
|
||||
"Downloads ignore configured proxies."
|
||||
case .system:
|
||||
"Downloads use the matching macOS system proxy when one is configured."
|
||||
case .custom:
|
||||
if let proxyURI = settings.proxySettings.customProxyURI {
|
||||
"Downloads use \(proxyURI)."
|
||||
} else {
|
||||
"Enter a proxy host and port to enable the custom proxy."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
|
||||
Binding {
|
||||
settings.proxySettings[keyPath: keyPath]
|
||||
} set: { newValue in
|
||||
var proxySettings = settings.proxySettings
|
||||
proxySettings[keyPath: keyPath] = newValue
|
||||
settings.proxySettings = proxySettings
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PowerSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
|
||||
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
activeTab = filter
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: filter.symbolName)
|
||||
.font(.system(size: 16))
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(activeTab.rawValue)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch activeTab {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .lookAndFeel:
|
||||
LookAndFeelSettingsPane()
|
||||
case .network:
|
||||
NetworkSettingsPane()
|
||||
case .locations:
|
||||
LocationsSettingsPane()
|
||||
case .siteLogins:
|
||||
SiteLoginsSettingsPane()
|
||||
case .power:
|
||||
PowerSettingsPane()
|
||||
case .engine:
|
||||
EngineSettingsPane()
|
||||
case .integration:
|
||||
IntegrationSettingsPane()
|
||||
case .about:
|
||||
AboutSettingsPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var editingLoginID: UUID?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if editingLoginID != nil {
|
||||
Button("Cancel Edit") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
settings.saveSiteLogin(
|
||||
id: editingLoginID,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
resetForm()
|
||||
}
|
||||
} label: {
|
||||
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Saved Logins") {
|
||||
if settings.siteLogins.isEmpty {
|
||||
Text("No saved logins.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
edit(login)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func edit(_ login: SiteLogin) {
|
||||
editingLoginID = login.id
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
password = ""
|
||||
}
|
||||
|
||||
private func resetForm() {
|
||||
editingLoginID = nil
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastNotification: ViewModifier {
|
||||
var message: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
|
||||
if isShowing {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.zIndex(1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String) -> some View {
|
||||
self.modifier(ToastNotification(message: message, isShowing: isShowing))
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum DownloadSidebarFilter: Hashable {
|
||||
case all
|
||||
case queued
|
||||
case active
|
||||
case completed
|
||||
case unfinished
|
||||
case category(DownloadCategory)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All Downloads"
|
||||
case .queued: "Queue"
|
||||
case .active: "Active"
|
||||
case .completed: "Completed"
|
||||
case .unfinished: "Unfinished"
|
||||
case .category(let category): category.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case lookAndFeel = "Look and feel"
|
||||
case network = "Network"
|
||||
case locations = "Locations"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case integration = "Integrations"
|
||||
case about = "About"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .lookAndFeel: "paintpalette"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .integration: "puzzlepiece.extension"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case downloads(DownloadSidebarFilter)
|
||||
case queue(UUID)
|
||||
case scheduler
|
||||
case speedLimiter
|
||||
case settings
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@Binding var selection: SidebarSelection
|
||||
@State private var queueBeingRenamed: DownloadQueue?
|
||||
@State private var queueBeingRemoved: DownloadQueue?
|
||||
@State private var queueName = ""
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Library") {
|
||||
Label("All", systemImage: "tray.full")
|
||||
.badge(controller.downloads.count)
|
||||
.tag(SidebarSelection.downloads(.all))
|
||||
Label("Active", systemImage: "bolt.fill")
|
||||
.badge(controller.activeCount)
|
||||
.tag(SidebarSelection.downloads(.active))
|
||||
Label("Completed", systemImage: "checkmark.circle")
|
||||
.badge(controller.completedCount)
|
||||
.tag(SidebarSelection.downloads(.completed))
|
||||
Label("Unfinished", systemImage: "circle.dashed")
|
||||
.badge(controller.unfinishedCount)
|
||||
.tag(SidebarSelection.downloads(.unfinished))
|
||||
}
|
||||
|
||||
Section("Folders") {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
folderRow(for: category)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Queues") {
|
||||
ForEach(controller.queues) { queue in
|
||||
queueRow(for: queue)
|
||||
}
|
||||
|
||||
Button {
|
||||
let queue = controller.addQueue()
|
||||
selection = .queue(queue.id)
|
||||
} label: {
|
||||
Label("Add new queue", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Section("Tools") {
|
||||
Label("Scheduler", systemImage: "calendar.badge.clock")
|
||||
.tag(SidebarSelection.scheduler)
|
||||
Label("Speed Limiter", systemImage: "speedometer")
|
||||
.tag(SidebarSelection.speedLimiter)
|
||||
}
|
||||
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(selection == .settings ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
.alert("Rename Queue", isPresented: Binding(
|
||||
get: { queueBeingRenamed != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
queueBeingRenamed = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
TextField("Queue name", text: $queueName)
|
||||
Button("Rename") {
|
||||
if let queue = queueBeingRenamed {
|
||||
controller.renameQueue(id: queue.id, name: queueName)
|
||||
}
|
||||
queueBeingRenamed = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
queueBeingRenamed = nil
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Queue",
|
||||
isPresented: Binding(
|
||||
get: { queueBeingRemoved != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
queueBeingRemoved = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: queueBeingRemoved
|
||||
) { queue in
|
||||
Button("Delete Queue", role: .destructive) {
|
||||
controller.removeQueue(id: queue.id)
|
||||
if selection == .queue(queue.id) {
|
||||
selection = .downloads(.unfinished)
|
||||
}
|
||||
queueBeingRemoved = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
queueBeingRemoved = nil
|
||||
}
|
||||
} message: { queue in
|
||||
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
|
||||
}
|
||||
}
|
||||
|
||||
private func folderRow(for category: DownloadCategory) -> some View {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.badge(controller.downloads.filter { $0.category == category }.count)
|
||||
.tag(SidebarSelection.downloads(.category(category)))
|
||||
}
|
||||
|
||||
private func queueRow(for queue: DownloadQueue) -> some View {
|
||||
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
|
||||
.badge(controller.queueCount(for: queue.id))
|
||||
.tag(SidebarSelection.queue(queue.id))
|
||||
.onDrop(
|
||||
of: [.text],
|
||||
delegate: QueueSidebarDropDelegate(
|
||||
queueID: queue.id,
|
||||
selection: $selection,
|
||||
controller: controller
|
||||
)
|
||||
)
|
||||
.contextMenu {
|
||||
if !queue.isMain {
|
||||
Button("Rename") {
|
||||
queueBeingRenamed = queue
|
||||
queueName = queue.name
|
||||
}
|
||||
Button("Delete", role: .destructive) {
|
||||
queueBeingRemoved = queue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueSidebarDropDelegate: DropDelegate {
|
||||
let queueID: UUID
|
||||
@Binding var selection: SidebarSelection
|
||||
let controller: DownloadController
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
guard let provider = info.itemProviders(for: [.text]).first else {
|
||||
return false
|
||||
}
|
||||
|
||||
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
|
||||
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
|
||||
Task { @MainActor in
|
||||
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
|
||||
selection = .queue(queueID)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
|
||||
let text: String?
|
||||
if let data = item as? Data {
|
||||
text = String(data: data, encoding: .utf8)
|
||||
} else {
|
||||
text = item as? String
|
||||
}
|
||||
|
||||
guard let text else { return nil }
|
||||
return Set(text
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
|
||||
.compactMap { UUID(uuidString: String($0)) })
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SpeedLimiterView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var speedLimitKiBPerSecond: Int = 1024
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
limitSelectionSection
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Speed Limiter")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save Limit") {
|
||||
saveState()
|
||||
withAnimation(.spring()) {
|
||||
showSaveToast = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation {
|
||||
showSaveToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var limitSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Global Speed Limit")
|
||||
.font(.headline)
|
||||
|
||||
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
|
||||
Text("Maximum Speed:")
|
||||
}
|
||||
|
||||
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Helpful presets
|
||||
HStack(spacing: 12) {
|
||||
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
|
||||
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
|
||||
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Speed Limit Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
|
||||
|
||||
private func loadState() {
|
||||
let currentLimit = settings.globalSpeedLimitKiBPerSecond
|
||||
isEnabled = currentLimit > 0
|
||||
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
// Clamp to ensure it doesn't break aria2
|
||||
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
|
||||
speedLimitKiBPerSecond = clampedSpeed
|
||||
|
||||
lastCustomSpeedLimit = clampedSpeed
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusBar: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(controller.engineMessage.isEmpty ? "Ready" : controller.engineMessage)
|
||||
.foregroundStyle(controller.hasAria2 ? Color.secondary : Color.orange)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(controller.activeCount) active")
|
||||
Text("\(controller.queuedCount) queued")
|
||||
Text("\(controller.completedCount) done")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case small = "Small"
|
||||
case standard = "Standard"
|
||||
case large = "Large"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var dynamicTypeSize: DynamicTypeSize {
|
||||
switch self {
|
||||
case .small: return .xSmall
|
||||
case .standard: return .medium
|
||||
case .large: return .xxLarge
|
||||
}
|
||||
}
|
||||
|
||||
var defaultFont: Font {
|
||||
switch self {
|
||||
case .small:
|
||||
return .system(size: 12)
|
||||
case .standard:
|
||||
return .system(size: 13)
|
||||
case .large:
|
||||
return .system(size: 15)
|
||||
}
|
||||
}
|
||||
|
||||
var controlSize: ControlSize {
|
||||
switch self {
|
||||
case .small:
|
||||
return .small
|
||||
case .standard:
|
||||
return .regular
|
||||
case .large:
|
||||
return .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case compact = "Compact"
|
||||
case standard = "Standard"
|
||||
case relaxed = "Relaxed"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var verticalPadding: CGFloat {
|
||||
switch self {
|
||||
case .compact: return 4
|
||||
case .standard: return 8
|
||||
case .relaxed: return 14
|
||||
}
|
||||
}
|
||||
|
||||
var minRowHeight: CGFloat {
|
||||
switch self {
|
||||
case .compact: return 24
|
||||
case .standard: return 32
|
||||
case .relaxed: return 44
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case system = "System Default"
|
||||
case light = "Light"
|
||||
case dark = "Dark"
|
||||
case dracula = "Dracula"
|
||||
case nord = "Nord"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var theme: Theme {
|
||||
switch self {
|
||||
case .system, .dark:
|
||||
return Theme.system
|
||||
case .light:
|
||||
return Theme.modernLight
|
||||
case .dracula:
|
||||
return Theme.dracula
|
||||
case .nord:
|
||||
return Theme.nord
|
||||
}
|
||||
}
|
||||
|
||||
var appearance: NSAppearance? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return NSAppearance(named: .aqua)
|
||||
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Theme: Equatable, Sendable {
|
||||
var background: Color?
|
||||
var secondaryBackground: Color?
|
||||
var text: Color?
|
||||
var secondaryText: Color?
|
||||
var accent: Color?
|
||||
|
||||
static let system = Theme(
|
||||
background: nil,
|
||||
secondaryBackground: nil,
|
||||
text: nil,
|
||||
secondaryText: nil,
|
||||
accent: nil
|
||||
)
|
||||
|
||||
static let modernLight = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
|
||||
text: Color.primary,
|
||||
secondaryText: Color.secondary,
|
||||
accent: Color.accentColor
|
||||
)
|
||||
|
||||
static let dracula = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
|
||||
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
|
||||
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
|
||||
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
|
||||
)
|
||||
|
||||
static let nord = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
|
||||
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
|
||||
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
|
||||
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
|
||||
)
|
||||
}
|
||||
|
||||
struct AppThemeModifier: ViewModifier {
|
||||
let theme: AppTheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.tint(theme.theme.accent)
|
||||
.onAppear {
|
||||
NSApp.appearance = theme.appearance
|
||||
}
|
||||
.onChange(of: theme) { _, newTheme in
|
||||
NSApp.appearance = newTheme.appearance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppFontSizeModifier: ViewModifier {
|
||||
let fontSize: AppFontSize
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(fontSize.defaultFont)
|
||||
.controlSize(fontSize.controlSize)
|
||||
.dynamicTypeSize(fontSize.dynamicTypeSize)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeBackgroundModifier: ViewModifier {
|
||||
let color: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let color {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(color)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func themeBackground(_ color: Color?) -> some View {
|
||||
modifier(ThemeBackgroundModifier(color: color))
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrayMenuView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
|
||||
var body: some View {
|
||||
Button("Show main window") {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Button("Add downloads") {
|
||||
openWindow(id: "add-downloads")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Menu("Start queue") {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.startQueue(queueID: queue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Stop downloads") {
|
||||
for download in controller.downloads where download.status == .downloading {
|
||||
controller.pause(download)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Exit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
|
||||
|
||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
||||
|
||||
## aria2
|
||||
|
||||
- Project: <https://aria2.github.io/>
|
||||
- Source: <https://github.com/aria2/aria2>
|
||||
- License: GNU General Public License version 2 or later
|
||||
|
||||
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
|
||||
|
||||
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
|
||||
|
||||
## FFmpeg
|
||||
|
||||
- Project and source: <https://ffmpeg.org/>
|
||||
- License information: <https://ffmpeg.org/legal.html>
|
||||
|
||||
Current macOS binary reports `--enable-gpl --enable-version3`; distribution therefore follows GNU GPL version 3 requirements. Exact build identity is recorded in `engines.lock.json`.
|
||||
|
||||
Windows and Linux archives come from checksum-pinned BtbN FFmpeg GPL builds. Build project: <https://github.com/BtbN/FFmpeg-Builds>.
|
||||
|
||||
## yt-dlp
|
||||
|
||||
- Project and source: <https://github.com/yt-dlp/yt-dlp>
|
||||
- License: The Unlicense
|
||||
|
||||
Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python packages keep their own license files inside `_internal` where supplied.
|
||||
|
||||
## Deno
|
||||
|
||||
- Project and source: <https://github.com/denoland/deno>
|
||||
- License: MIT
|
||||
|
||||
## OpenSSL and bundled native libraries
|
||||
|
||||
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
|
||||
|
||||
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"targets": {
|
||||
"x86_64-pc-windows-msvc": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "fb627ebbf9b16a3142fa48b8acd27cd9196d57aee1f719e1233416e95da7e877"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
|
||||
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
|
||||
}
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "96cf2585a4b2874044320cbc5d4078af57e266f0c86cca9888507c5faab3aee8"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
|
||||
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
|
||||
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"targets": {
|
||||
"aarch64-apple-darwin": {
|
||||
"engines": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.07.04",
|
||||
"source": "https://github.com/yt-dlp/yt-dlp",
|
||||
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
|
||||
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"source": "https://github.com/aria2/aria2",
|
||||
"build": "arm64 executable with adjacent aria2-libs",
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125610-g312c830916",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
|
||||
},
|
||||
"aria2-libs": {
|
||||
"files": 7,
|
||||
"sha256": "f6735fa4162513ca79adcc6bb65afdccdcd34c7c05e5d45c78da6d2e0c359e57"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.1.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",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nimbold/Firelink.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/nimbold/Firelink/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"download-manager",
|
||||
"tauri",
|
||||
"rust",
|
||||
"react",
|
||||
"typescript",
|
||||
"aria2",
|
||||
"yt-dlp",
|
||||
"ffmpeg",
|
||||
"desktop"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
||||
"build": "tsc && vite build",
|
||||
"check:updates": "node scripts/check-updates.js",
|
||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"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.3",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def process_images(src_path):
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
|
||||
# Crop the 28px black padding
|
||||
img = img.crop((28, 28, 1226, 1226))
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
# Lighter color (+1) at top, original color (0) at bottom
|
||||
bg_color = pixels[100, 100]
|
||||
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
|
||||
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
|
||||
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
|
||||
|
||||
new_img = Image.new("RGBA", (width, height))
|
||||
new_pixels = new_img.load()
|
||||
|
||||
for y in range(height):
|
||||
ratio = y / float(height - 1)
|
||||
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
|
||||
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
|
||||
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
|
||||
grad_color = (grad_r, grad_g, grad_b, 255)
|
||||
|
||||
for x in range(width):
|
||||
p = pixels[x, y]
|
||||
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
|
||||
|
||||
# Replace pure black corners or background with gradient
|
||||
if p[0] < 15 and p[1] < 15 and p[2] < 15:
|
||||
new_pixels[x, y] = grad_color
|
||||
elif dist < 15:
|
||||
new_pixels[x, y] = grad_color
|
||||
elif dist < 60:
|
||||
alpha = (dist - 15) / 45.0
|
||||
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
|
||||
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
|
||||
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
|
||||
new_pixels[x, y] = (r, g, b, 255)
|
||||
else:
|
||||
new_pixels[x, y] = p
|
||||
|
||||
img = new_img
|
||||
radius = int(width * 0.225)
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
|
||||
img.putalpha(mask)
|
||||
|
||||
# Save standard png
|
||||
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
|
||||
img_1024.save("Resources/AppIcon.png")
|
||||
|
||||
# Save Firefox extension icons
|
||||
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
|
||||
img_48.save("Extensions/Firefox/icons/icon-48.png")
|
||||
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
|
||||
img_128.save("Extensions/Firefox/icons/icon-128.png")
|
||||
|
||||
# MenuBarIconTemplate (64x64 monochrome)
|
||||
data = img.getdata()
|
||||
new_data = []
|
||||
|
||||
for item in data:
|
||||
r, g, b, a = item
|
||||
if r > 100 and r > b * 1.5 and a > 0:
|
||||
alpha = min(255, max(0, int((r - 40) * 1.2)))
|
||||
new_data.append((0, 0, 0, alpha))
|
||||
else:
|
||||
new_data.append((0, 0, 0, 0))
|
||||
|
||||
menu_bar_full = Image.new("RGBA", img.size)
|
||||
menu_bar_full.putdata(new_data)
|
||||
|
||||
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
|
||||
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
|
||||
print("Done generating main PNGs")
|
||||
|
||||
if __name__ == '__main__':
|
||||
process_images(sys.argv[1])
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run ${executable}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runNpmScript(script) {
|
||||
if (process.env.npm_execpath) {
|
||||
run(process.execPath, [process.env.npm_execpath, 'run', script]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
run('cmd.exe', ['/d', '/s', '/c', 'npm', 'run', script], {
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
run('npm', ['run', script]);
|
||||
}
|
||||
|
||||
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,251 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const userAgent = 'firelink-update-check';
|
||||
|
||||
function parseJsonFile(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
|
||||
}
|
||||
|
||||
function normalizeVersion(value) {
|
||||
return String(value || '')
|
||||
.replace(/^v/, '')
|
||||
.replace(/^release-/, '');
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const a = normalizeVersion(left).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
|
||||
const b = normalizeVersion(right).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
|
||||
const length = Math.max(a.length, b.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const av = a[index] ?? 0;
|
||||
const bv = b[index] ?? 0;
|
||||
if (av === bv) continue;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return av > bv ? 1 : -1;
|
||||
return String(av).localeCompare(String(bv));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
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'] });
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function githubLatest(repo) {
|
||||
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function latestMartinRiedlMacArm64Release() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const releaseSection = html.split('Download Release Build')[1] || '';
|
||||
const match =
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(assets.map(asset => asset.version))];
|
||||
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
|
||||
if (unique.length === 1 && byTarget.windows && byTarget.linux) {
|
||||
return {
|
||||
version: unique[0],
|
||||
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function printNpmReport(label, outdated) {
|
||||
const entries = Object.entries(outdated);
|
||||
if (!entries.length) {
|
||||
console.log(`${label}: current`);
|
||||
return 0;
|
||||
}
|
||||
console.log(`${label}: ${entries.length} outdated package(s)`);
|
||||
for (const [name, info] of entries) {
|
||||
console.log(` ${name}: ${info.current} -> ${info.latest} (wanted ${info.wanted})`);
|
||||
}
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function packagedEngineVersions(engineLock) {
|
||||
const rows = [];
|
||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}, latestUrlsByTargetEngine = {}) {
|
||||
let outdated = 0;
|
||||
for (const row of rows) {
|
||||
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
|
||||
if (!latest) continue;
|
||||
const current = normalizeVersion(row.version);
|
||||
const wanted = normalizeVersion(latest);
|
||||
const latestUrl = latestUrlsByTargetEngine[`${row.target}:${row.engine}`];
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
const status = versionOutdated ? 'outdated' : sourceOutdated ? 'source-outdated' : 'current';
|
||||
if (status !== 'current') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let outdatedCount = 0;
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [
|
||||
ytDlp,
|
||||
deno,
|
||||
aria2,
|
||||
ffmpeg,
|
||||
martinRiedlMacArm64Ffmpeg,
|
||||
martinRiedlMacArm64Snapshot,
|
||||
btbnFfmpegN81Build,
|
||||
] = await Promise.all([
|
||||
githubLatest('yt-dlp/yt-dlp'),
|
||||
githubLatest('denoland/deno'),
|
||||
githubLatest('aria2/aria2'),
|
||||
latestFfmpegStable(),
|
||||
latestMartinRiedlMacArm64Release(),
|
||||
latestMartinRiedlMacArm64Snapshot(),
|
||||
latestBtbnFfmpegN81Build(),
|
||||
]);
|
||||
const latestByEngine = {
|
||||
'yt-dlp': ytDlp.tag_name,
|
||||
deno: deno.tag_name,
|
||||
aria2c: aria2.tag_name,
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
const latestUrlsByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.urls.windows,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.urls.linux,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.url,
|
||||
};
|
||||
|
||||
console.log('\nlatest engines:');
|
||||
for (const [engine, version] of Object.entries(latestByEngine)) {
|
||||
console.log(` ${engine}: ${normalizeVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build?.version || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
console.error(`\n${outdatedCount} outdated item(s) found.`);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
export function collectRegularFiles(root, options = {}) {
|
||||
const ignoredNames = new Set(options.ignoredNames || []);
|
||||
const files = [];
|
||||
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
if (ignoredNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Unsupported symlink in engine payload: ${relative}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walk(file);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(file);
|
||||
} else {
|
||||
throw new Error(`Unsupported filesystem entry in engine payload: ${relative}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return files.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function treeDigest(root) {
|
||||
const files = collectRegularFiles(root);
|
||||
const digest = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
digest.update(`${relative}\0${sha256(file)}\n`);
|
||||
}
|
||||
return { files: files.length, sha256: digest.digest('hex') };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { collectRegularFiles, treeDigest } from './engine-payload-integrity.js';
|
||||
|
||||
test('collectRegularFiles returns regular files and honors ignored names', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'nested'));
|
||||
fs.writeFileSync(path.join(root, 'engine'), 'binary');
|
||||
fs.writeFileSync(path.join(root, 'nested', 'runtime.dat'), 'runtime');
|
||||
fs.writeFileSync(path.join(root, 'payload-manifest.json'), '{}');
|
||||
|
||||
const files = collectRegularFiles(root, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(root, file).split(path.sep).join('/'));
|
||||
|
||||
assert.deepEqual(files, ['engine', 'nested/runtime.dat']);
|
||||
const digest = treeDigest(path.join(root, 'nested'));
|
||||
assert.equal(digest.files, 1);
|
||||
assert.match(digest.sha256, /^[a-f0-9]{64}$/);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collectRegularFiles rejects symlinks in engine payloads', { skip: process.platform === 'win32' }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'target'), 'target');
|
||||
fs.symlinkSync('target', path.join(root, 'link'));
|
||||
|
||||
assert.throws(
|
||||
() => collectRegularFiles(root),
|
||||
/Unsupported symlink in engine payload: link/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/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 { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const target = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| process.env.TAURI_ENV_TARGET_TRIPLE;
|
||||
if (!target) {
|
||||
console.error('Pass --target <Rust target triple>.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetSources = sourceLock.targets?.[target];
|
||||
if (!targetSources) {
|
||||
console.error(`No source lock exists for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
|
||||
async function download(name, source) {
|
||||
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}`);
|
||||
}
|
||||
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' });
|
||||
} else {
|
||||
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
|
||||
}
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function findFile(root, names) {
|
||||
const wanted = new Set(names.map(name => name.toLowerCase()));
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && wanted.has(entry.name.toLowerCase())) matches.push(file);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected one of [${names.join(', ')}] under ${root}, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function copyExecutable(source, engine) {
|
||||
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
|
||||
fs.copyFileSync(source, output);
|
||||
if (!isWindows) fs.chmodSync(output, 0o755);
|
||||
}
|
||||
|
||||
function writePayloadManifest() {
|
||||
const files = collectRegularFiles(destination, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
});
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
generatedFrom: Object.fromEntries(
|
||||
Object.entries(targetSources).map(([name, source]) => [
|
||||
name,
|
||||
{
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256
|
||||
}
|
||||
])
|
||||
),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(destination, file).split(path.sep).join('/'),
|
||||
sha256(file)
|
||||
])
|
||||
)
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(destination, 'payload-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { 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'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true
|
||||
});
|
||||
|
||||
const deno = await download('deno', targetSources.deno);
|
||||
copyExecutable(findFile(deno, isWindows ? ['deno.exe'] : ['deno']), 'deno');
|
||||
|
||||
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/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 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(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mustBeDirectory(directory, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} does not exist or is not a directory: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mustBeFile(file, label) {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
fail(`${label} does not exist or is not a file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function findSingleAppImage(directory) {
|
||||
mustBeDirectory(directory, 'AppImage bundle directory');
|
||||
const appImages = fs
|
||||
.readdirSync(directory)
|
||||
.filter(name => name.endsWith('.AppImage'))
|
||||
.map(name => path.join(directory, name));
|
||||
|
||||
if (appImages.length !== 1) {
|
||||
fail(`Expected exactly one AppImage in ${directory}, found ${appImages.length}.`);
|
||||
}
|
||||
|
||||
return appImages[0];
|
||||
}
|
||||
|
||||
function validatePayloadManifest(root, target, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
mustBeFile(manifestPath, `${label} payload manifest`);
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is not valid JSON: ${error.message}`);
|
||||
}
|
||||
|
||||
if (manifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${manifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
const actualFiles = collectRegularFiles(root, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(root, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files do not match payload-manifest.json.`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const file = path.join(root, relative);
|
||||
if (sha256(file) !== manifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE || process.env.TAURI_ENV_TARGET_TRIPLE;
|
||||
if (!target) {
|
||||
fail('Pass --target <triple>.');
|
||||
}
|
||||
|
||||
const bundleDirectory = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle', 'appimage');
|
||||
const appDir = path.resolve(argValue('--appdir') || path.join(bundleDirectory, 'Firelink.AppDir'));
|
||||
const appImage = path.resolve(argValue('--appimage') || findSingleAppImage(bundleDirectory));
|
||||
const appImageTool = path.resolve(argValue('--appimagetool') || process.env.APPIMAGETOOL || 'appimagetool');
|
||||
const source = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const destination = path.join(appDir, 'usr', 'lib', 'Firelink', 'engine-dist', target);
|
||||
|
||||
mustBeDirectory(appDir, 'Firelink.AppDir');
|
||||
mustBeDirectory(source, 'Provisioned engine payload');
|
||||
mustBeFile(appImage, 'AppImage');
|
||||
mustBeFile(appImageTool, 'appimagetool');
|
||||
|
||||
validatePayloadManifest(source, target, 'Provisioned engine');
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.cpSync(source, destination, {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
validatePayloadManifest(destination, target, 'AppDir engine');
|
||||
|
||||
fs.chmodSync(appImageTool, 0o755);
|
||||
run(appImageTool, [appDir, appImage], {
|
||||
env: {
|
||||
APPIMAGE_EXTRACT_AND_RUN: '1',
|
||||
ARCH: 'x86_64',
|
||||
},
|
||||
});
|
||||
|
||||
const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-appimage-'));
|
||||
try {
|
||||
fs.chmodSync(appImage, 0o755);
|
||||
run(appImage, ['--appimage-extract'], {
|
||||
cwd: extractRoot,
|
||||
env: { APPIMAGE_EXTRACT_AND_RUN: '1' },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const extractedPayload = path.join(extractRoot, 'squashfs-root', 'usr', 'lib', 'Firelink', 'engine-dist', target);
|
||||
mustBeDirectory(extractedPayload, 'Extracted AppImage engine payload');
|
||||
validatePayloadManifest(extractedPayload, target, 'Extracted AppImage engine');
|
||||
} finally {
|
||||
fs.rmSync(extractRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`Repacked and verified AppImage engine payload for ${target}.`);
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const executableArg = argValue('--executable');
|
||||
if (!executableArg) {
|
||||
console.error('Pass --executable <path>.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
FIRELINK_SMOKE_TEST: '1',
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
||||
GDK_BACKEND: 'x11',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function findReadyPort() {
|
||||
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
|
||||
if (spawnError || childExit) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (
|
||||
response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid)
|
||||
) {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (readyPort === null) {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoVisibleWindows(rootPid) {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = `
|
||||
$root = ${rootPid}
|
||||
$all = Get-CimInstance Win32_Process
|
||||
$pending = @($root)
|
||||
$descendants = @()
|
||||
while ($pending.Count -gt 0) {
|
||||
$parent = $pending[0]
|
||||
if ($pending.Count -eq 1) {
|
||||
$pending = @()
|
||||
} else {
|
||||
$pending = $pending[1..($pending.Count - 1)]
|
||||
}
|
||||
|
||||
$children = @($all | Where-Object { $_.ParentProcessId -eq $parent })
|
||||
foreach ($child in $children) {
|
||||
$descendants += $child.ProcessId
|
||||
$pending += $child.ProcessId
|
||||
}
|
||||
}
|
||||
|
||||
$visible = foreach ($childPid in $descendants) {
|
||||
$process = Get-Process -Id $childPid -ErrorAction SilentlyContinue
|
||||
if ($process -and $process.MainWindowHandle -ne 0) {
|
||||
"$($process.ProcessName)($childPid)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($visible.Count -gt 0) {
|
||||
Write-Error "Visible child process windows detected: $($visible -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
execFileSync('powershell', ['-NoProfile', '-Command', script], {
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim();
|
||||
throw new Error(detail || 'Visible child process window check failed.');
|
||||
}
|
||||
}
|
||||
|
||||
function waitForChildExit(timeoutMs) {
|
||||
if (childExit) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid || childExit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
return waitForChildExit(10000);
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
if (await waitForChildExit(5000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
return waitForChildExit(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) {
|
||||
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 {
|
||||
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
if (assertPortableData) {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
|
||||
const target = argValue('--target')
|
||||
|| process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| hostTarget;
|
||||
const isWindowsTarget = target.includes('windows');
|
||||
const suffix = isWindowsTarget ? '.exe' : '';
|
||||
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
const expectedNames = engines.map(engine => `${engine}-${target}${suffix}`);
|
||||
const targetLock = lock.targets?.[target];
|
||||
|
||||
const configuredSource = process.env.FIRELINK_ENGINE_SOURCE_DIR
|
||||
? path.resolve(process.env.FIRELINK_ENGINE_SOURCE_DIR)
|
||||
: null;
|
||||
const canonicalSource = path.join(binariesRoot, target);
|
||||
const provisionedSource = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const legacyMacSource = target.endsWith('apple-darwin') ? binariesRoot : null;
|
||||
const source = [configuredSource, canonicalSource, provisionedSource, legacyMacSource]
|
||||
.filter(Boolean)
|
||||
.find(candidate => expectedNames.every(name => fs.existsSync(path.join(candidate, name))));
|
||||
|
||||
if (!source) {
|
||||
console.error(`No complete engine payload found for ${target}.`);
|
||||
console.error(`Expected source directory: ${canonicalSource}`);
|
||||
console.error(`Expected files: ${expectedNames.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetLock) {
|
||||
for (const engine of engines) {
|
||||
const name = `${engine}-${target}${suffix}`;
|
||||
const expected = targetLock.engines?.[engine]?.sha256;
|
||||
const actual = sha256(path.join(source, name));
|
||||
if (!expected || actual !== expected) {
|
||||
console.error(`Checksum mismatch for ${name}. Expected ${expected || 'missing lock'}, got ${actual}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [runtimeDir, expected] of Object.entries(targetLock.runtimeTrees || {})) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
console.error(`Missing locked runtime directory ${runtimeDir} for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const actual = treeDigest(sourceDir);
|
||||
if (actual.files !== expected.files || actual.sha256 !== expected.sha256) {
|
||||
console.error(`Runtime checksum mismatch for ${runtimeDir}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const manifestPath = path.join(source, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`No committed lock or payload manifest exists for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.target !== target) {
|
||||
console.error(`Payload manifest target mismatch: ${manifest.target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [relative, expected] of Object.entries(manifest.files || {})) {
|
||||
const file = path.join(source, relative);
|
||||
if (!fs.existsSync(file) || sha256(file) !== expected) {
|
||||
console.error(`Payload manifest mismatch: ${relative}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const actualFiles = collectRegularFiles(source, {
|
||||
ignoredNames: ['payload-manifest.json'],
|
||||
}).map(file => path.relative(source, file).split(path.sep).join('/'));
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
console.error(`Payload contains files not covered by manifest for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const destination = path.join(outputRoot, target);
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
for (const name of expectedNames) {
|
||||
fs.copyFileSync(path.join(source, name), path.join(destination, name));
|
||||
if (!isWindowsTarget) {
|
||||
fs.chmodSync(path.join(destination, name), 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||
if (fs.existsSync(payloadManifest)) {
|
||||
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
|
||||
}
|
||||
|
||||
console.log(`Staged Firelink engines for ${target} from ${source}`);
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env node
|
||||
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';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
const currentArch = archMap[os.arch()];
|
||||
const currentPlatform = platformMap[os.platform()];
|
||||
|
||||
if (!currentArch || !currentPlatform) {
|
||||
console.error(`Unsupported architecture or platform: ${os.arch()} / ${os.platform()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetTriple = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| `${currentArch}-${currentPlatform}`;
|
||||
const hostTriple = `${currentArch}-${currentPlatform}`;
|
||||
const canExecuteTarget = targetTriple === hostTriple;
|
||||
const isWindows = targetTriple.includes('windows');
|
||||
const isMacOS = targetTriple.includes('apple-darwin');
|
||||
const isLinux = targetTriple.includes('linux');
|
||||
const ext = isWindows ? '.exe' : '';
|
||||
const suffix = `-${targetTriple}${ext}`;
|
||||
|
||||
const scriptsDir = __dirname;
|
||||
const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
||||
walk(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.resolve(root));
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
const configuredRoot = argValue('--root')
|
||||
|| (process.argv.includes('--staged')
|
||||
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
||||
: searchRoot
|
||||
? findEngineRoot(searchRoot)
|
||||
: null);
|
||||
const binariesDir = configuredRoot
|
||||
? path.resolve(configuredRoot)
|
||||
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
|
||||
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
||||
const FORBIDDEN_STDERR = [
|
||||
'Failed to load Python shared library',
|
||||
'Library not loaded',
|
||||
'image not found',
|
||||
'Connection refused',
|
||||
];
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`[FAIL] ${msg}`);
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
console.log(`[OK] ${msg}`);
|
||||
}
|
||||
|
||||
function rejectSymlinks(root, label) {
|
||||
if (!fs.existsSync(root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let symlinkCount = 0;
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
if (entry.isSymbolicLink()) {
|
||||
fail(`Unsupported symlink in ${label}: ${relative}`);
|
||||
symlinkCount += 1;
|
||||
} else if (entry.isDirectory()) {
|
||||
walk(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
if (symlinkCount === 0) {
|
||||
ok(`${label} contains no symlinks`);
|
||||
}
|
||||
}
|
||||
|
||||
function binName(engine) {
|
||||
return `${engine}${suffix}`;
|
||||
}
|
||||
|
||||
function binPath(engine) {
|
||||
return path.join(binariesDir, binName(engine));
|
||||
}
|
||||
|
||||
function engineEnv(engine) {
|
||||
if (engine !== 'aria2c') {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
const modulesDir = path.join(binariesDir, 'aria2-libs');
|
||||
if (!fs.existsSync(modulesDir)) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: modulesDir,
|
||||
};
|
||||
}
|
||||
|
||||
// ───── Check 1: Sidecar existence ─────
|
||||
console.log(`\n─── 1. Sidecar existence (${targetTriple}) ───`);
|
||||
for (const eng of requiredEngines) {
|
||||
const p = binPath(eng);
|
||||
if (fs.existsSync(p)) {
|
||||
ok(`Found ${binName(eng)}`);
|
||||
} else {
|
||||
fail(`Missing ${binName(eng)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error('\nAborting: missing required sidecars.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ───── Check 2: Executable permission ─────
|
||||
console.log('\n─── 2. Executable permission ───');
|
||||
for (const eng of requiredEngines) {
|
||||
if (isWindows) {
|
||||
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.accessSync(binPath(eng), fs.constants.X_OK);
|
||||
ok(`Executable ${binName(eng)}`);
|
||||
} catch {
|
||||
fail(`Not executable ${binName(eng)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 3: file(1) identification ─────
|
||||
console.log('\n─── 3. file(1) identification ───');
|
||||
for (const eng of requiredEngines) {
|
||||
if (os.platform() === 'win32') {
|
||||
try {
|
||||
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
|
||||
if (header === 'MZ') {
|
||||
ok(`${binName(eng)}: Windows PE executable`);
|
||||
} else {
|
||||
fail(`${binName(eng)}: missing Windows PE MZ header`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`identify ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const out = execFileSync('file', ['--brief', binPath(eng)], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
ok(`${binName(eng)}: ${out}`);
|
||||
} catch (e) {
|
||||
fail(`file ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 4 & 5: otool -L linkage (macOS only) ─────
|
||||
if (isMacOS) {
|
||||
console.log('\n─── 4 & 5. otool -L linkage check ───');
|
||||
for (const eng of requiredEngines) {
|
||||
const p = binPath(eng);
|
||||
try {
|
||||
const out = execFileSync('otool', ['-L', p], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
const lines = out
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
let hasBad = false;
|
||||
for (const fp of FORBIDDEN_OTOOL_PATHS) {
|
||||
for (const line of lines) {
|
||||
if (line.includes(fp)) {
|
||||
fail(`${binName(eng)} links to '${fp}': ${line}`);
|
||||
hasBad = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasBad) {
|
||||
ok(`${binName(eng)}: no local-only dylib paths`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`otool -L ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 6: yt-dlp packaging ─────
|
||||
console.log('\n─── 6. yt-dlp packaging ───');
|
||||
{
|
||||
const yt = binPath('yt-dlp');
|
||||
if (!fs.existsSync(yt)) {
|
||||
fail('yt-dlp binary not found, cannot verify packaging');
|
||||
} else {
|
||||
const internalDir = path.join(binariesDir, '_internal');
|
||||
const hasInternal = fs.existsSync(internalDir) && fs.statSync(internalDir).isDirectory();
|
||||
|
||||
if (hasInternal) {
|
||||
console.log(' Detected PyInstaller onedir layout (_internal/ present)');
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(internalDir);
|
||||
ok(`_internal/ directory exists with ${entries.length} entries`);
|
||||
} catch (e) {
|
||||
fail(`Cannot read _internal/: ${e.message}`);
|
||||
}
|
||||
rejectSymlinks(internalDir, '_internal/');
|
||||
|
||||
const requiredRuntimeFiles = [
|
||||
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
|
||||
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
|
||||
];
|
||||
for (const required of requiredRuntimeFiles) {
|
||||
if (fs.existsSync(required)) {
|
||||
ok(`yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
|
||||
} else {
|
||||
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
|
||||
}
|
||||
}
|
||||
const runtimeCandidates = isWindows
|
||||
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
|
||||
: isLinux
|
||||
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
|
||||
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
|
||||
if (runtimeCandidates.length > 0) {
|
||||
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
|
||||
} else {
|
||||
fail(`Missing embedded Python runtime in ${internalDir}`);
|
||||
}
|
||||
} else {
|
||||
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aria2LibsDir = path.join(binariesDir, 'aria2-libs');
|
||||
if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) {
|
||||
rejectSymlinks(aria2LibsDir, 'aria2-libs/');
|
||||
}
|
||||
|
||||
// ───── Check 7, 8 & 9: Engine version self-tests ─────
|
||||
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
|
||||
|
||||
function runEngine(label, engine, args, timeout = 30000) {
|
||||
const p = binPath(engine);
|
||||
if (!fs.existsSync(p)) {
|
||||
fail(`${label} binary not found at ${p}`);
|
||||
return;
|
||||
}
|
||||
let stderr = '';
|
||||
try {
|
||||
const stdout = execFileSync(p, args, {
|
||||
encoding: 'utf-8',
|
||||
env: engineEnv(engine),
|
||||
timeout,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const firstLine = stdout.trim().split('\n')[0];
|
||||
ok(`${label} version: ${firstLine}`);
|
||||
} catch (e) {
|
||||
stderr = e.stderr || '';
|
||||
const stdout = e.stdout || '';
|
||||
const detail = stdout.trim().split('\n')[0] || e.message;
|
||||
fail(`${label} execution failed: ${detail}`);
|
||||
}
|
||||
if (stderr) {
|
||||
for (const pattern of FORBIDDEN_STDERR) {
|
||||
if (stderr.includes(pattern)) {
|
||||
fail(`${label} stderr contains '${pattern}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
|
||||
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
|
||||
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
|
||||
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
|
||||
if (isMacOS) {
|
||||
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
|
||||
// Warm checks enforce engine startup performance after that OS validation.
|
||||
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
|
||||
runEngine('deno warm start', 'deno', ['--version'], 8000);
|
||||
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
|
||||
}
|
||||
} else {
|
||||
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
|
||||
}
|
||||
|
||||
// ───── aria2 RPC smoke test (native target only) ─────
|
||||
if (canExecuteTarget) {
|
||||
console.log('\n─── aria2 RPC smoke test ───');
|
||||
await (async function testAria2Rpc() {
|
||||
const p = binPath('aria2c');
|
||||
if (!fs.existsSync(p)) {
|
||||
fail('aria2c binary not found, cannot run RPC test');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
method: 'aria2.getVersion',
|
||||
params: [],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 {
|
||||
const resp = JSON.parse(result.data);
|
||||
if (resp?.result?.version) {
|
||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||
} else {
|
||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`aria2 RPC parse error: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
fail(result.error);
|
||||
}
|
||||
|
||||
if (rpcStderr) {
|
||||
for (const pattern of FORBIDDEN_STDERR) {
|
||||
if (rpcStderr.includes(pattern)) {
|
||||
fail(`aria2 RPC stderr contains '${pattern}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ───── Result ─────
|
||||
console.log('');
|
||||
if (exitCode !== 0) {
|
||||
console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('[PASS] All engine verification checks passed.');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -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,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`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,71 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.1.0"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/nimbold/Firelink"
|
||||
homepage = "https://github.com/nimbold/Firelink"
|
||||
keywords = ["download-manager", "tauri", "aria2", "yt-dlp", "ffmpeg"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "firelink_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
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-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.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.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.18"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
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"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||