Compare commits
397 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a2be2012f8 | |||
| 7e185d06d1 | |||
| 683eb45d0e | |||
| 4b3c80cda9 | |||
| db2b1f6516 | |||
| 336a50ed6c | |||
| f887c62195 | |||
| 109059e10c | |||
| 81b3e0877b | |||
| 6b2901bd50 | |||
| 9261385d59 | |||
| ef0ad42df3 | |||
| b71a58ae06 | |||
| 6dcb0e33e8 | |||
| 34847b6234 | |||
| ac7963e353 | |||
| a4936fa141 | |||
| 9af9edbfd4 | |||
| 9f8e01839f | |||
| c1d97f31fb | |||
| 02abef1443 | |||
| b58df6b660 | |||
| 5ed9b42c62 | |||
| 72ef99d211 | |||
| baddf0da6d | |||
| 34ca209e09 | |||
| 410059e3de | |||
| 4e48f1e42c | |||
| 2e5b4ae7c3 | |||
| 2d0c05e4e4 | |||
| 8d5eb74fba | |||
| 01ba9c4c9b | |||
| bd6aa867c7 | |||
| fbae92aae9 | |||
| 49003665ef | |||
| 1a2c59d243 | |||
| c139ac50f2 | |||
| a900d97a5c | |||
| 8831318839 | |||
| 94180ddff9 | |||
| e91414c478 | |||
| eaea5bd908 | |||
| af05180ee6 | |||
| 0799b08000 | |||
| bf645898a9 | |||
| 6552bde261 | |||
| 125a8b9e6d | |||
| 2589d878ea | |||
| 2f7a1f52e7 | |||
| 30d4d741af | |||
| f98e6b8246 | |||
| 387ada1afc | |||
| 37be7b14f0 | |||
| 1e064502db | |||
| 09cea67041 | |||
| 6a20250d22 | |||
| 99b8f66bcf | |||
| 890b6935b9 | |||
| b1802c718a | |||
| d0021e16f5 | |||
| a42c7d9f74 |
@@ -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,99 +2,179 @@ 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}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$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: 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 --strict 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'
|
||||
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 \
|
||||
appstream \
|
||||
curl \
|
||||
patchelf \
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '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 }}
|
||||
$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
|
||||
|
||||
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
|
||||
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
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"
|
||||
}
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
- 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,8 +1,83 @@
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
.build/
|
||||
build/
|
||||
dist/
|
||||
DerivedData/
|
||||
skills-lock.json
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.sw?
|
||||
*.xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
*.der
|
||||
*.mobileprovision
|
||||
sparkle_private_key*
|
||||
SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
|
||||
# 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,15 +1,271 @@
|
||||
# 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.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
|
||||
- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
|
||||
|
||||
### Improvements
|
||||
- Polish the browser extension pairing UI with a secure masked token field and improved styling.
|
||||
|
||||
### Changes
|
||||
- Remove stale references to the legacy static token from the Firelink Companion extension.
|
||||
|
||||
### Fixes
|
||||
- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
|
||||
- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
|
||||
|
||||
## [0.6.3] - 2026-06-09
|
||||
|
||||
### Improvements
|
||||
- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
|
||||
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
|
||||
- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
|
||||
- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
|
||||
|
||||
### Fixes
|
||||
- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
|
||||
|
||||
## [0.6.2] - 2026-06-08
|
||||
|
||||
### Fixes
|
||||
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
|
||||
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
|
||||
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
|
||||
- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
|
||||
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
|
||||
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
|
||||
|
||||
## [0.6.1] - 2026-06-08
|
||||
|
||||
### New Features
|
||||
- No new user-facing features in this patch release.
|
||||
|
||||
### Improvements
|
||||
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
|
||||
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
|
||||
|
||||
### Changes
|
||||
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
|
||||
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
|
||||
- Remove stale media add-on update language now that media engines are bundled with the app.
|
||||
- Update Firelink Companion to `1.0.8`.
|
||||
|
||||
### Fixes
|
||||
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
|
||||
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
|
||||
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
|
||||
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
|
||||
|
||||
## [0.6.0] - 2026-06-08
|
||||
|
||||
### New features
|
||||
- Enhance mixed media support and add duplicate resolution.
|
||||
- Redesign settings panes and enhance update flows.
|
||||
- Improve yt-dlp fetching speed and redesign media detection UI.
|
||||
- Enhance media engine settings with cookie extraction and update checks.
|
||||
- Modernize Integration settings UI and add official install button.
|
||||
- Integrate yt-dlp to DownloadController and add global queue support.
|
||||
- Implement smart progressive disclosure UI and media extraction engine.
|
||||
- Implement gatekeeper architecture for on-demand media engine binaries.
|
||||
- Inline update checks to avoid unnecessary modals.
|
||||
|
||||
### Changes
|
||||
- Add backward compatibility support for extension tokens.
|
||||
- Update Firelink-Extension submodule to latest.
|
||||
- Update app icons and icon generation scripts.
|
||||
- Tone down icon gradient to 1.9x for modern subtle look.
|
||||
- Increase gradient contrast for stronger lighting effect.
|
||||
- Switch to lighter gradient (+1 to 0).
|
||||
- Revert to plain mode without gradient.
|
||||
- Apply premium gradient to the correct new icon and app icon.
|
||||
- Remove redundant version string from up-to-date message.
|
||||
- Update release metadata for the framework-embedded dmg.
|
||||
|
||||
### Fixes
|
||||
- Cap max height of download links text editor.
|
||||
- Harden media download flow.
|
||||
- Pass extractor arguments to yt-dlp download process.
|
||||
- Restore single click selection by removing simultaneousGesture.
|
||||
- Restore Download Properties routing and gestures.
|
||||
- Pass UUID as String for download properties WindowGroup to prevent routing failures.
|
||||
- Size column fallback and table row interactions.
|
||||
- Media download UX and table row selection.
|
||||
- Media downloads connections, progress parsing, file size, and selection highlight.
|
||||
- Stabilize yt-dlp metadata and add-on updates.
|
||||
- Block automatic metadata fetch for private IP addresses (security).
|
||||
- Actually update extension icons with the 1.9x gradient icon.
|
||||
- Correctly remove black padding and mask corners.
|
||||
- Harden release metadata.
|
||||
- Correct no-update handling to prevent false error messages.
|
||||
|
||||
## [0.5.7] - 2026-06-06
|
||||
|
||||
### New features
|
||||
- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
|
||||
- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
|
||||
- Replaced the basic in-app update checker with an integrated release-checking flow.
|
||||
- Added secure update metadata checks before presenting new releases in the app.
|
||||
|
||||
## [0.5.6] - 2026-06-05
|
||||
|
||||
@@ -137,7 +393,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,20 +0,0 @@
|
||||
.PHONY: build app dmg run verify clean
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
|
||||
app:
|
||||
Scripts/create_app_bundle.sh
|
||||
|
||||
dmg: app
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
run:
|
||||
swift run Firelink
|
||||
|
||||
verify:
|
||||
Scripts/verify.sh
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf build dist
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
|
||||
"version" : "2.9.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Firelink",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "Firelink", targets: ["Firelink"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
dependencies: [
|
||||
.product(name: "Sparkle", package: "Sparkle")
|
||||
],
|
||||
path: "Sources/Firelink",
|
||||
resources: [
|
||||
.process("Assets.xcassets")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,100 +1,174 @@
|
||||
<div align="center">
|
||||
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
|
||||
<h1>Firelink</h1>
|
||||
<p><strong>A clean, native SwiftUI download manager 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="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** brings the efficiency of multi-segmented download managers (like IDM or FDM) to macOS with a modern, native SwiftUI interface. Designed specifically for Apple Silicon, it delivers high-speed concurrent transfers, drag-and-drop queue control, automated file organization, and Keychain-secured authentication—all in a lightweight native package.
|
||||
|
||||
---
|
||||
|
||||
### 📸 Screenshots
|
||||
|
||||
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
|
||||
<br/>
|
||||
<sub>Main window, batch link intake, and Firefox integration setup</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>☀️ View Light Theme Screenshots</b></summary>
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
|
||||
<br/>
|
||||
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
|
||||
<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
|
||||
|
||||
## ✨ Features
|
||||
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
|
||||
- ⚡ **High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
|
||||
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
|
||||
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
|
||||
- 🧾 **Download Properties:** Inspect progress and tune per-download transfer settings.
|
||||
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
||||
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
|
||||
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
||||
- 🔄 **Sparkle Updates:** Secure, automatic in-app updates using EdDSA cryptographic signatures.
|
||||
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
|
||||
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
|
||||
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.
|
||||
|
||||
---
|
||||
## Features
|
||||
|
||||
## 🛠️ Quick Start
|
||||
- **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, and captured links.
|
||||
- **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, 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.
|
||||
|
||||
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
|
||||
## Installation
|
||||
|
||||
Run the application directly:
|
||||
```bash
|
||||
swift run Firelink
|
||||
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. |
|
||||
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
|
||||
|
||||
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
|
||||
|
||||
## 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.2 is the matching extension release for Firelink 1.0.3.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` 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, engine checks, xvfb launch smoke test, 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:
|
||||
|
||||
## 🧩 Browser Extension
|
||||
```sh
|
||||
node --test scripts/*.node-test.js
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Find the companion browser extension (Firefox) at:
|
||||
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
|
||||
Create a production bundle:
|
||||
|
||||
---
|
||||
```sh
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## 🗺️ Roadmap
|
||||
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
|
||||
|
||||
- [x] Zero-Config `aria2c` bundling.
|
||||
- [x] Global & per-download Speed Limiter.
|
||||
- [x] Browser Extensions support.
|
||||
- [x] In-app integrated Settings UI.
|
||||
- [ ] Notarized `.app` releases and Homebrew formulae.
|
||||
```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).
|
||||
|
||||
## 🏆 Credits
|
||||
## Repository Structure
|
||||
|
||||
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
|
||||
```text
|
||||
.
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Firefox/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
---
|
||||
## Help and Project Status
|
||||
|
||||
## 📄 License
|
||||
- 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.
|
||||
|
||||
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
|
||||
## 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,75 @@
|
||||
# Firelink Release Process
|
||||
|
||||
Targets:
|
||||
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Linux x64 AppImage
|
||||
|
||||
## Distribution policy
|
||||
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
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.
|
||||
|
||||
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 is intentionally manual. Tag pushes build and upload
|
||||
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
|
||||
tag when both release-certification inputs are checked after Windows, Linux,
|
||||
and macOS clean-machine QA.
|
||||
|
||||
## 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
|
||||
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
|
||||
run on the same tag after clean-machine QA is complete.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
Before Width: | Height: | Size: 735 KiB |
|
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: 883 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,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
CONFIGURATION="${CONFIGURATION:-release}"
|
||||
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-1}"
|
||||
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"
|
||||
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"
|
||||
|
||||
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"
|
||||
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
|
||||
|
||||
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>SUPublicEDKey</key>
|
||||
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if command -v codesign &> /dev/null; then
|
||||
codesign --force --deep --sign - "$APP_DIR"
|
||||
fi
|
||||
|
||||
echo "Created $APP_DIR"
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
VERSION="${VERSION:-0.1.0}"
|
||||
ARCH="${ARCH:-arm64}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
|
||||
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.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
|
||||
|
||||
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"
|
||||
|
||||
echo "Created $DMG_PATH"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
swift build
|
||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||
@@ -1,663 +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
|
||||
|
||||
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: 470, idealHeight: 500)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
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(minHeight: 72)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
refreshMetadata(for: linkText)
|
||||
} 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(destinationDirectory(for: item).path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
TableColumn("Status") { item in
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
.width(110)
|
||||
}
|
||||
.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(.cancelAction)
|
||||
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
}
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button("Start Downloads") {
|
||||
addDownloads(start: true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
let urls = DownloadURLParser.parse(text)
|
||||
metadataTask?.cancel()
|
||||
|
||||
pendingDownloads = urls.map { url in
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
|
||||
useAuthorization = true
|
||||
authUsername = creds.username
|
||||
authPassword = creds.password
|
||||
saveLogin = false
|
||||
}
|
||||
|
||||
metadataTask = Task {
|
||||
var loaded: [PendingDownload] = []
|
||||
for url in urls {
|
||||
guard !Task.isCancelled else { return }
|
||||
let item = await DownloadMetadataFetcher.fetch(
|
||||
for: url,
|
||||
settings: settings,
|
||||
credentials: metadataCredentials(for: url),
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
loaded.append(item)
|
||||
await MainActor.run {
|
||||
for loadedItem in loaded {
|
||||
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 explicitCredentials: DownloadCredentials? = nil
|
||||
if useAuthorization {
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !cleanUsername.isEmpty {
|
||||
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
if saveLogin {
|
||||
var savedHosts = Set<String>()
|
||||
for item in pendingDownloads {
|
||||
if let host = item.url.host, !savedHosts.contains(host) {
|
||||
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
|
||||
savedHosts.insert(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.addPendingDownloads(
|
||||
pendingDownloads,
|
||||
connectionsPerServer: Int(connectionsPerServer),
|
||||
overrideDirectory: overrideDirectory,
|
||||
startImmediately: start,
|
||||
queueID: targetQueueID,
|
||||
credentials: explicitCredentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
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,361 +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 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 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
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
listRowDensity = .standard
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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 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]
|
||||
}
|
||||
@@ -1,613 +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"
|
||||
]
|
||||
|
||||
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
|
||||
return URL(fileURLWithPath: found)
|
||||
}
|
||||
|
||||
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
for folder in path.split(separator: ":") {
|
||||
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
|
||||
if FileManager.default.isExecutableFile(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) -> 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: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didComplete = false
|
||||
private let completion: @Sendable (Result<Void, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func complete(_ result: Result<Void, 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -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,229 +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)
|
||||
}
|
||||
.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 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,943 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class DownloadController: ObservableObject {
|
||||
@Published var downloads: [DownloadItem] = []
|
||||
@Published var queues: [DownloadQueue] = [.main]
|
||||
@Published var engineMessage = ""
|
||||
@Published var pendingPasteboardText: String?
|
||||
@Published var pendingReferer: String?
|
||||
@Published var extensionServerPort: UInt16?
|
||||
var pendingAddQueueID: UUID?
|
||||
|
||||
private let settings: AppSettings
|
||||
private let engine = Aria2DownloadEngine()
|
||||
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
|
||||
private var automaticRetryCounts: [UUID: Int] = [:]
|
||||
private var restrictQueueToAutoResume = false
|
||||
private var queuePumpScope: QueuePumpScope = .idle
|
||||
private var sleepActivity: SleepActivityHandle?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let maxAutomaticRetries = 3
|
||||
private lazy var storageURL: URL = {
|
||||
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
|
||||
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
|
||||
}()
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
|
||||
let shouldResumeRecoveredDownloads = loadDownloads()
|
||||
|
||||
settings.$preventsSleepWhileDownloading
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.updateSleepActivity()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$globalSpeedLimitKiBPerSecond
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$maxConcurrentDownloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
self?.pumpQueue()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
$downloads
|
||||
.dropFirst()
|
||||
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.saveDownloads()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.saveDownloads()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
if shouldResumeRecoveredDownloads {
|
||||
Task { @MainActor in
|
||||
self.engineMessage = "Recovered downloads from the previous session."
|
||||
self.restrictQueueToAutoResume = true
|
||||
self.queuePumpScope = .all
|
||||
self.pumpQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
sleepActivity?.end()
|
||||
}
|
||||
|
||||
var activeCount: Int {
|
||||
downloads.filter { $0.status == .downloading }.count
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
downloads.filter { $0.status == .queued }.count
|
||||
}
|
||||
|
||||
var completedCount: Int {
|
||||
downloads.filter { $0.status == .completed }.count
|
||||
}
|
||||
|
||||
var unfinishedCount: Int {
|
||||
downloads.filter { $0.status != .completed }.count
|
||||
}
|
||||
|
||||
var hasAria2: Bool {
|
||||
Aria2DownloadEngine.findExecutable() != nil
|
||||
}
|
||||
|
||||
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
|
||||
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
engineMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
|
||||
return
|
||||
}
|
||||
|
||||
let fileName = FileClassifier.fileName(from: url)
|
||||
let category = FileClassifier.category(forFileName: fileName)
|
||||
let item = DownloadItem(
|
||||
url: url,
|
||||
fileName: fileName,
|
||||
category: category,
|
||||
destinationDirectory: settings.destinationDirectory(for: category),
|
||||
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
|
||||
credentials: settings.credentials(for: url),
|
||||
queueID: normalizedQueueID(queueID)
|
||||
)
|
||||
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
|
||||
downloads.append(item)
|
||||
engineMessage = "Added \(fileName) to \(category.rawValue)."
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
func addPendingDownloads(
|
||||
_ pendingDownloads: [PendingDownload],
|
||||
connectionsPerServer: Int,
|
||||
overrideDirectory: URL?,
|
||||
startImmediately: Bool,
|
||||
queueID: UUID = DownloadQueue.mainQueueID,
|
||||
credentials: DownloadCredentials? = nil,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
||||
speedLimitKiBPerSecond: Int? = nil
|
||||
) {
|
||||
let clampedConnections = min(max(connectionsPerServer, 1), 16)
|
||||
let targetQueueID = normalizedQueueID(queueID)
|
||||
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
|
||||
let items = pendingDownloads.map { pending in
|
||||
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
|
||||
return DownloadItem(
|
||||
url: pending.url,
|
||||
fileName: fileName,
|
||||
category: FileClassifier.category(forFileName: fileName),
|
||||
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
|
||||
connectionsPerServer: clampedConnections,
|
||||
credentials: credentials ?? settings.credentials(for: pending.url),
|
||||
checksum: transferOptions.checksum,
|
||||
requestHeaders: transferOptions.requestHeaders,
|
||||
cookieHeader: transferOptions.cookieHeader,
|
||||
mirrorURLs: transferOptions.mirrorURLs,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
sizeBytes: pending.sizeBytes,
|
||||
bytesText: ByteFormatter.string(pending.sizeBytes),
|
||||
message: startImmediately ? "Queued to start" : "Added to queue",
|
||||
queueID: targetQueueID
|
||||
)
|
||||
}
|
||||
|
||||
for item in items {
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
downloads.append(contentsOf: items)
|
||||
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
|
||||
if startImmediately {
|
||||
startQueue(queueID: targetQueueID)
|
||||
}
|
||||
}
|
||||
|
||||
func startQueue(queueID: UUID? = nil) {
|
||||
engineMessage = ""
|
||||
restrictQueueToAutoResume = false
|
||||
if let queueID {
|
||||
let queueID = normalizedQueueID(queueID)
|
||||
switch queuePumpScope {
|
||||
case .all:
|
||||
break
|
||||
case .idle:
|
||||
queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: [])
|
||||
case .scoped(var queueIDs, let itemIDs):
|
||||
queueIDs.insert(queueID)
|
||||
queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
|
||||
}
|
||||
} else {
|
||||
queuePumpScope = .all
|
||||
}
|
||||
markQueuedDownloadsForAutoResume(queueID: queueID)
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func pause(_ item: DownloadItem) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
update(item.id) {
|
||||
$0.status = .paused
|
||||
$0.message = "Paused. Resume will continue from the partial file."
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func pauseActiveDownloads(queueID: UUID? = nil) {
|
||||
let targetQueueID = queueID.map(normalizedQueueID)
|
||||
let activeItems = downloads.filter { item in
|
||||
item.status == .downloading && (targetQueueID == nil || item.queueID == targetQueueID)
|
||||
}
|
||||
|
||||
guard !activeItems.isEmpty else { return }
|
||||
|
||||
for item in activeItems {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
update(item.id) {
|
||||
$0.status = .paused
|
||||
$0.message = "Paused. Resume will continue from the partial file."
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
}
|
||||
|
||||
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func queue(_ item: DownloadItem) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
update(item.id) {
|
||||
$0.status = .queued
|
||||
if item.status != .paused {
|
||||
$0.progress = 0
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
}
|
||||
$0.message = "Added to queue"
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
|
||||
let queueID = normalizedQueueID(queueID)
|
||||
var changed = false
|
||||
|
||||
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
|
||||
guard downloads[index].status != .completed,
|
||||
downloads[index].status != .downloading else {
|
||||
continue
|
||||
}
|
||||
|
||||
downloads[index].status = .queued
|
||||
downloads[index].queueID = queueID
|
||||
downloads[index].message = "Added to \(queueName(for: queueID))"
|
||||
downloads[index].autoResumeOnLaunch = false
|
||||
automaticRetryCounts[downloads[index].id] = nil
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
saveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
}
|
||||
|
||||
func resume(_ item: DownloadItem) {
|
||||
restrictQueueToAutoResume = false
|
||||
update(item.id) {
|
||||
$0.status = .queued
|
||||
$0.message = ""
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func redownload(_ item: DownloadItem) {
|
||||
trashFiles(for: item)
|
||||
restrictQueueToAutoResume = false
|
||||
update(item.id) {
|
||||
$0.status = .queued
|
||||
$0.progress = 0
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = "Redownloading"
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func cancel(_ item: DownloadItem) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
update(item.id) {
|
||||
$0.status = .canceled
|
||||
$0.message = "Canceled"
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
|
||||
for index in offsets {
|
||||
let item = downloads[index]
|
||||
delete(item, deleteFiles: deleteFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
if deleteFiles {
|
||||
trashFiles(for: item)
|
||||
} else if item.status != .completed {
|
||||
removeCacheFiles(for: item)
|
||||
}
|
||||
KeychainCredentialStore.deletePassword(for: item.id)
|
||||
downloads.removeAll { $0.id == item.id }
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
func move(from source: IndexSet, to destination: Int) {
|
||||
downloads.move(fromOffsets: source, toOffset: destination)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
func queueName(for id: UUID) -> String {
|
||||
queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name
|
||||
}
|
||||
|
||||
func queueItems(for id: UUID) -> [DownloadItem] {
|
||||
let id = normalizedQueueID(id)
|
||||
return downloads.filter { validQueueID($0.queueID) == id }
|
||||
}
|
||||
|
||||
func queueCount(for id: UUID) -> Int {
|
||||
queueItems(for: id).count
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addQueue() -> DownloadQueue {
|
||||
var index = 2
|
||||
var name = "Queue \(index)"
|
||||
let existingNames = Set(queues.map(\.name))
|
||||
while existingNames.contains(name) {
|
||||
index += 1
|
||||
name = "Queue \(index)"
|
||||
}
|
||||
|
||||
let queue = DownloadQueue(name: name)
|
||||
queues.append(queue)
|
||||
saveDownloads()
|
||||
return queue
|
||||
}
|
||||
|
||||
func renameQueue(id: UUID, name: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty,
|
||||
id != DownloadQueue.mainQueueID,
|
||||
let index = queues.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
queues[index].name = cleanName
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
func removeQueue(id: UUID) {
|
||||
guard id != DownloadQueue.mainQueueID,
|
||||
queues.contains(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
|
||||
downloads[index].queueID = nil
|
||||
}
|
||||
queues.removeAll { $0.id == id }
|
||||
engineMessage = "Removed queue. Downloads remain in Unfinished."
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
|
||||
let queueID = normalizedQueueID(queueID)
|
||||
guard itemID != targetID,
|
||||
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
|
||||
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let item = downloads.remove(at: source)
|
||||
let insertionIndex = source < target ? target - 1 : target
|
||||
downloads.insert(item, at: insertionIndex)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func pumpQueue() {
|
||||
guard hasAria2 else {
|
||||
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
|
||||
return
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
|
||||
while activeCount < settings.maxConcurrentDownloads,
|
||||
let next = downloads.first(where: { item in
|
||||
item.status == .queued &&
|
||||
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
|
||||
isAllowedToStart(item)
|
||||
}) {
|
||||
start(next)
|
||||
}
|
||||
|
||||
if restrictQueueToAutoResume &&
|
||||
activeCount == 0 &&
|
||||
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
|
||||
restrictQueueToAutoResume = false
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
update(item.id) {
|
||||
$0.status = .downloading
|
||||
$0.lastTryAt = Date()
|
||||
$0.message = "Starting"
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
saveDownloads()
|
||||
|
||||
do {
|
||||
let handle = try engine.start(
|
||||
item: item,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
$0.etaText = progress.etaText
|
||||
$0.connectionCount = progress.connectionCount
|
||||
$0.message = "Downloading"
|
||||
}
|
||||
}
|
||||
},
|
||||
completion: { [weak self] result in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.activeHandles[item.id] = nil
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
self.automaticRetryCounts[item.id] = nil
|
||||
self.update(item.id) {
|
||||
$0.status = .completed
|
||||
$0.progress = 1
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.message = "Saved to \($0.destinationPath)"
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
self.saveDownloads()
|
||||
self.showNotification(title: "Download Completed", body: item.fileName)
|
||||
case .failure(let error):
|
||||
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
|
||||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
|
||||
return
|
||||
}
|
||||
self.handleDownloadFailure(itemID: item.id, error: error)
|
||||
}
|
||||
|
||||
self.pumpQueue()
|
||||
self.applySpeedLimitsToActiveDownloads()
|
||||
self.updateSleepActivity()
|
||||
}
|
||||
}
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
}
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
} catch {
|
||||
handleDownloadFailure(itemID: item.id, error: error)
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
|
||||
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
|
||||
mutate(&downloads[index])
|
||||
}
|
||||
|
||||
func updateDownload(
|
||||
id: UUID,
|
||||
url: URL,
|
||||
fileName: String,
|
||||
destinationDirectory: URL,
|
||||
connectionsPerServer: Int,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) {
|
||||
update(id) {
|
||||
$0.url = url
|
||||
$0.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
$0.category = FileClassifier.category(forFileName: $0.fileName)
|
||||
$0.destinationDirectory = destinationDirectory
|
||||
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
||||
$0.credentials = credentials
|
||||
$0.checksum = transferOptions.checksum
|
||||
$0.requestHeaders = transferOptions.requestHeaders
|
||||
$0.cookieHeader = transferOptions.cookieHeader
|
||||
$0.mirrorURLs = transferOptions.mirrorURLs
|
||||
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
$0.message = "Properties updated"
|
||||
}
|
||||
if let password = credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: id)
|
||||
} else if credentials == nil {
|
||||
KeychainCredentialStore.deletePassword(for: id)
|
||||
}
|
||||
applySpeedLimitToActiveDownload(id: id)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
||||
SpeedLimitPolicy.normalized(value)
|
||||
}
|
||||
|
||||
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
|
||||
SpeedLimitPolicy.effectiveLimit(
|
||||
itemLimit: item.speedLimitKiBPerSecond,
|
||||
globalLimit: settings.globalSpeedLimitKiBPerSecond,
|
||||
activeDownloadCount: activeCount
|
||||
)
|
||||
}
|
||||
|
||||
private func applySpeedLimitsToActiveDownloads() {
|
||||
for item in downloads where item.status == .downloading {
|
||||
applySpeedLimitToActiveDownload(id: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func applySpeedLimitToActiveDownload(id: UUID) {
|
||||
guard let handle = activeHandles[id],
|
||||
let item = downloads.first(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
|
||||
Task {
|
||||
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
||||
if let queueID {
|
||||
let normalizedID = normalizedQueueID(queueID)
|
||||
for index in downloads.indices where downloads[index].status == .queued &&
|
||||
validQueueID(downloads[index].queueID) == normalizedID {
|
||||
downloads[index].autoResumeOnLaunch = true
|
||||
}
|
||||
} else {
|
||||
for index in downloads.indices where downloads[index].status == .queued {
|
||||
downloads[index].autoResumeOnLaunch = true
|
||||
}
|
||||
}
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func handleDownloadFailure(itemID: UUID, error: Error) {
|
||||
let retryCount = automaticRetryCounts[itemID] ?? 0
|
||||
|
||||
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
|
||||
automaticRetryCounts[itemID] = nil
|
||||
update(itemID) {
|
||||
$0.status = .failed
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = error.localizedDescription
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
saveDownloads()
|
||||
if let item = downloads.first(where: { $0.id == itemID }) {
|
||||
showNotification(title: "Download Failed", body: item.fileName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
automaticRetryCounts[itemID] = retryCount + 1
|
||||
update(itemID) {
|
||||
$0.status = .queued
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
|
||||
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
|
||||
return true
|
||||
}
|
||||
|
||||
switch engineError {
|
||||
case .executableNotFound, .unsupportedProxy:
|
||||
return false
|
||||
case .launchFailed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return false
|
||||
case .all:
|
||||
return true
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
if itemIDs.contains(item.id) {
|
||||
return true
|
||||
}
|
||||
guard let queueID = validQueueID(item.queueID) else { return false }
|
||||
return queueIDs.contains(queueID)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneActiveQueueScopes() {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return
|
||||
case .all:
|
||||
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
|
||||
queuePumpScope = .idle
|
||||
}
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
let activeQueueIDs = queueIDs.filter { queueID in
|
||||
downloads.contains { item in
|
||||
validQueueID(item.queueID) == queueID &&
|
||||
(item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
let activeItemIDs = itemIDs.filter { itemID in
|
||||
downloads.contains { item in
|
||||
item.id == itemID && (item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
|
||||
? .idle
|
||||
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueuePumpScope {
|
||||
case idle
|
||||
case all
|
||||
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
|
||||
|
||||
func includingItem(_ itemID: UUID) -> QueuePumpScope {
|
||||
switch self {
|
||||
case .idle:
|
||||
return .scoped(queueIDs: [], itemIDs: [itemID])
|
||||
case .all:
|
||||
return .all
|
||||
case .scoped(let queueIDs, var itemIDs):
|
||||
itemIDs.insert(itemID)
|
||||
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSleepActivity() {
|
||||
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
|
||||
|
||||
if shouldPreventSleep, sleepActivity == nil {
|
||||
sleepActivity = SleepActivityHandle(activity: ProcessInfo.processInfo.beginActivity(
|
||||
options: [.idleSystemSleepDisabled],
|
||||
reason: "Firelink is downloading files."
|
||||
))
|
||||
} else if !shouldPreventSleep, let activity = sleepActivity {
|
||||
activity.end()
|
||||
sleepActivity = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func removeCacheFiles(for item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
let candidates = [URL(fileURLWithPath: fileURL.path + ".aria2")]
|
||||
|
||||
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
|
||||
try? FileManager.default.removeItem(at: candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func trashFiles(for item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
let candidates = [
|
||||
fileURL,
|
||||
URL(fileURLWithPath: fileURL.path + ".aria2")
|
||||
]
|
||||
|
||||
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
|
||||
try? FileManager.default.trashItem(at: candidate, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDownloads() {
|
||||
let queuesCopy = queues
|
||||
let downloadsCopy = downloads.map(\.redactedForPersistence)
|
||||
let storageURL = self.storageURL
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task.detached(priority: .background) {
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDownloads() -> Bool {
|
||||
do {
|
||||
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
|
||||
let data = try Data(contentsOf: storageURL)
|
||||
let state: StoredDownloadState
|
||||
let isLegacyDownloadList: Bool
|
||||
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
|
||||
state = storedState
|
||||
isLegacyDownloadList = false
|
||||
} else {
|
||||
state = StoredDownloadState(
|
||||
queues: [.main],
|
||||
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
|
||||
)
|
||||
isLegacyDownloadList = true
|
||||
}
|
||||
|
||||
var shouldResumeRecoveredDownloads = false
|
||||
var shouldRewriteStoredDownloads = isLegacyDownloadList
|
||||
self.queues = normalizedQueues(state.queues)
|
||||
self.downloads = state.downloads.map { item in
|
||||
var adjusted = item
|
||||
let redacted = adjusted.redactedForPersistence
|
||||
if redacted != adjusted {
|
||||
adjusted = redacted
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
adjusted.queueID = validQueueID(adjusted.queueID)
|
||||
if isLegacyDownloadList, item.queueID == nil {
|
||||
adjusted.queueID = DownloadQueue.mainQueueID
|
||||
}
|
||||
|
||||
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
|
||||
adjusted.credentials?.password = storedPassword
|
||||
}
|
||||
|
||||
if adjusted.status == .completed && adjusted.progress != 1 {
|
||||
adjusted.progress = 1
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .completed &&
|
||||
(adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
|
||||
adjusted.speedText = "-"
|
||||
adjusted.etaText = "-"
|
||||
adjusted.connectionCount = 0
|
||||
adjusted.autoResumeOnLaunch = false
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .downloading {
|
||||
adjusted.status = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
adjusted.speedText = "-"
|
||||
adjusted.etaText = "-"
|
||||
adjusted.connectionCount = 0
|
||||
adjusted.autoResumeOnLaunch = true
|
||||
shouldResumeRecoveredDownloads = true
|
||||
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
|
||||
adjusted.message = "Recovered queued download."
|
||||
shouldResumeRecoveredDownloads = true
|
||||
}
|
||||
return adjusted
|
||||
}
|
||||
|
||||
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
|
||||
saveDownloads()
|
||||
}
|
||||
return shouldResumeRecoveredDownloads
|
||||
} catch {
|
||||
print("Failed to load downloads: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedQueueID(_ id: UUID?) -> UUID {
|
||||
validQueueID(id) ?? DownloadQueue.mainQueueID
|
||||
}
|
||||
|
||||
private func validQueueID(_ id: UUID?) -> UUID? {
|
||||
guard let id, queues.contains(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] {
|
||||
var normalized = queues
|
||||
if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) {
|
||||
normalized.insert(.main, at: 0)
|
||||
}
|
||||
|
||||
if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 {
|
||||
let main = normalized.remove(at: mainIndex)
|
||||
normalized.insert(main, at: 0)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func showNotification(title: String, body: String) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
guard granted else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredDownloadState: Codable {
|
||||
var queues: [DownloadQueue]
|
||||
var downloads: [DownloadItem]
|
||||
}
|
||||
|
||||
enum SpeedLimitPolicy {
|
||||
static let maximumKiBPerSecond = 10_485_760
|
||||
|
||||
static func normalized(_ value: Int?) -> Int? {
|
||||
guard let value, value > 0 else { return nil }
|
||||
return min(value, maximumKiBPerSecond)
|
||||
}
|
||||
|
||||
static func effectiveLimit(
|
||||
itemLimit: Int?,
|
||||
globalLimit: Int?,
|
||||
activeDownloadCount: Int
|
||||
) -> Int? {
|
||||
let itemLimit = normalized(itemLimit)
|
||||
let globalLimit = normalized(globalLimit)
|
||||
.map { max(1, $0 / max(activeDownloadCount, 1)) }
|
||||
|
||||
switch (itemLimit, globalLimit) {
|
||||
case let (.some(itemLimit), .some(globalLimit)):
|
||||
return min(itemLimit, globalLimit)
|
||||
case let (.some(itemLimit), .none):
|
||||
return itemLimit
|
||||
case let (.none, .some(globalLimit)):
|
||||
return globalLimit
|
||||
case (.none, .none):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class SleepActivityHandle: @unchecked Sendable {
|
||||
private let activity: NSObjectProtocol
|
||||
|
||||
init(activity: NSObjectProtocol) {
|
||||
self.activity = activity
|
||||
}
|
||||
|
||||
func end() {
|
||||
ProcessInfo.processInfo.endActivity(activity)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +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()
|
||||
) 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
doubleClickableCell(for: item) {
|
||||
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)
|
||||
}
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
}
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
TableColumn("Size", value: \.sortableSize) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.status.rawValue)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 105)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.displayETAText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
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)
|
||||
}
|
||||
.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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleClickableCell<Content: View>(
|
||||
for item: DownloadItem,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
performPrimaryAction(for: item)
|
||||
})
|
||||
}
|
||||
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@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(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,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,137 +0,0 @@
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
final class SparkleUpdater: ObservableObject {
|
||||
let controller: SPUStandardUpdaterController
|
||||
init(controller: SPUStandardUpdaterController) {
|
||||
self.controller = controller
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct FirelinkApp: App {
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
@StateObject private var sparkleUpdater: SparkleUpdater
|
||||
|
||||
@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() {
|
||||
// Initialize Sparkle updater
|
||||
let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
||||
self.updaterController = updaterController
|
||||
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater(controller: updaterController))
|
||||
|
||||
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)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: "main") {
|
||||
ContentView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(sparkleUpdater)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.onOpenURL { url in
|
||||
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)
|
||||
|
||||
WindowGroup("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", 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)
|
||||
.environmentObject(sparkleUpdater)
|
||||
} 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,50 +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)
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,263 +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 extensionRequestToken = "firelink-extension-v1"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
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: 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 {
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.method == "OPTIONS" {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
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,274 +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 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 destinationPath: String {
|
||||
defaultDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
}
|
||||
@@ -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,99 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettingsPane: View {
|
||||
@EnvironmentObject var sparkleUpdater: SparkleUpdater
|
||||
|
||||
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
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)")
|
||||
.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: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
sparkleUpdater.controller.checkForUpdates(nil)
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
|
||||
} label: {
|
||||
Label("Open Releases", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
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")
|
||||
Link("aria2", destination: aria2URL)
|
||||
Spacer()
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
|
||||
Text("Copyright © 2026 NimBold. All rights reserved.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -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,46 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct EngineSettingsPane: View {
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status") {
|
||||
Label(
|
||||
executableURL == nil ? "Missing" : "Ready",
|
||||
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
|
||||
)
|
||||
.foregroundStyle(executableURL == nil ? .orange : .green)
|
||||
}
|
||||
|
||||
LabeledContent("Binary") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if executableURL == nil {
|
||||
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationSettingsPane: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@State private var copiedExtensionURL: URL?
|
||||
@State private var installMessage = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
Image(systemName: "puzzlepiece.extension")
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firefox Extension")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Capture downloads directly from your browser.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Installation") {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
copyExtensionToDownloads()
|
||||
} label: {
|
||||
Label("Copy to Downloads", systemImage: "folder.badge.plus")
|
||||
}
|
||||
|
||||
Button {
|
||||
showCopiedExtensionInFinder()
|
||||
} label: {
|
||||
Label("Show Copied Folder", systemImage: "folder.fill")
|
||||
}
|
||||
.disabled(copiedExtensionURL == nil)
|
||||
|
||||
Button {
|
||||
openFirefoxDebugging()
|
||||
} label: {
|
||||
Label("Open Firefox Debugging", systemImage: "link")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if !installMessage.isEmpty {
|
||||
Text(installMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the approved add-on.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Diagnostics") {
|
||||
LabeledContent("Local receiver") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Copied folder") {
|
||||
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
|
||||
Label("Ready", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not copied", systemImage: "folder.badge.questionmark")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Permissions & Privacy") {
|
||||
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
|
||||
copiedExtensionURL = downloadsExtensionURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var downloadsExtensionURL: URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
|
||||
}
|
||||
|
||||
private func copyExtensionToDownloads() {
|
||||
guard let sourceURL = bundledFirefoxExtensionURL() else {
|
||||
installMessage = "The bundled Firefox extension folder was not found."
|
||||
return
|
||||
}
|
||||
|
||||
let destinationURL = downloadsExtensionURL
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
|
||||
copiedExtensionURL = destinationURL
|
||||
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
|
||||
showCopiedExtensionInFinder()
|
||||
} catch {
|
||||
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func bundledFirefoxExtensionURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
|
||||
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
|
||||
? sourceTreeExtension
|
||||
: nil
|
||||
}
|
||||
|
||||
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
|
||||
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
|
||||
try fileManager.copyItem(
|
||||
at: sourceURL.appendingPathComponent(component),
|
||||
to: destinationURL.appendingPathComponent(component)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func showCopiedExtensionInFinder() {
|
||||
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
|
||||
let manifestURL = folderURL.appendingPathComponent("manifest.json")
|
||||
if FileManager.default.fileExists(atPath: manifestURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
|
||||
} else if FileManager.default.fileExists(atPath: folderURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
|
||||
}
|
||||
}
|
||||
|
||||
private func openFirefoxDebugging() {
|
||||
let bundleIDs = [
|
||||
"org.mozilla.firefoxdeveloperedition",
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.nightly"
|
||||
]
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
for id in bundleIDs {
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
process.arguments = ["-a", appURL.path, "about:debugging"]
|
||||
try? process.run()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if let fallbackURL = URL(string: "about:debugging") {
|
||||
workspace.open(fallbackURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
|
||||
},
|
||||
"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-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
|
||||
},
|
||||
"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,43 @@
|
||||
{
|
||||
"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-125450-gfad2e0bc50",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
|
||||
},
|
||||
"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.0.3",
|
||||
"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.9.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.8.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def process_images(src_path):
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
width, height = img.size
|
||||
|
||||
# Apply a standard macOS rounded rectangle mask
|
||||
# macOS standard radius is approx 22.5% of the width
|
||||
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)
|
||||
|
||||
# Apply mask
|
||||
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,217 @@
|
||||
#!/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) {
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return {};
|
||||
} catch (error) {
|
||||
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 match =
|
||||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
||||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([A-Za-z0-9.-]+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
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 versions = (release.assets || [])
|
||||
.map(asset => asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(?:win64-gpl-8\.1\.zip|linux64-gpl-8\.1\.tar\.xz)$/)?.[1])
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(versions)];
|
||||
if (unique.length === 1 && versions.length >= 2) {
|
||||
return unique[0];
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
|
||||
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 status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
|
||||
if (status === 'outdated') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let outdatedCount = 0;
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Firefox extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
|
||||
);
|
||||
|
||||
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 || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
|
||||
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 || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
);
|
||||
|
||||
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,162 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
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 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;
|
||||
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (readyPort === null) {
|
||||
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
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) {
|
||||
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') {
|
||||
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 terminateChild() {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
await findReadyPort();
|
||||
|
||||
try {
|
||||
if (readyPort === null) {
|
||||
if (spawnError) {
|
||||
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
} else {
|
||||
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
} finally {
|
||||
terminateChild();
|
||||
}
|
||||
@@ -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,441 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
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}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
method: 'aria2.getVersion',
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
|
||||
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,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,68 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.3"
|
||||
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.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", 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.29.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"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
|
||||