Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56b4c9f511 | |||
| c1fa87b953 | |||
| c7ec8cd666 | |||
| 9133e3b05b | |||
| 5bbee12602 | |||
| 7894c05bba | |||
| 33375df2ff | |||
| 1922db8ea0 | |||
| ba70662165 | |||
| 629a34d1e8 | |||
| 248f3869ad | |||
| 4f4c655de6 | |||
| 3fbd0742be | |||
| c5025fd5a0 | |||
| b1c84a0fb9 | |||
| fbb89cde8e | |||
| cd8ab5c12b | |||
| ed7c47cb49 | |||
| 8c035167c8 | |||
| 6d9ef68bb0 | |||
| 51258b25bc | |||
| 1baadf6ea6 | |||
| 60dad5703a | |||
| b8ef712981 | |||
| 3b9faa789c | |||
| 161b0028f9 | |||
| f84726a403 | |||
| e52e885193 | |||
| 5fc3c9f965 | |||
| 800b94f9c0 | |||
| 6b5384f261 | |||
| 607d193f8e | |||
| f711c6b7a4 | |||
| e30eff4d2e | |||
| 85ad9d18e8 | |||
| 036ff02dac | |||
| 99f73eaae2 | |||
| 5ea46024e9 | |||
| a2a3b08d4e | |||
| 5ef0b3b5f6 | |||
| 515ba611ce | |||
| 579174c5b5 | |||
| c13f993150 | |||
| 017d9cd879 | |||
| dabd252ab7 | |||
| 3da73c623f | |||
| 757e313f71 | |||
| 3aaf32f9ee | |||
| 58bbf95761 | |||
| 5189146369 | |||
| a92d5689d7 | |||
| e5348d3213 | |||
| f726b058f7 | |||
| 4a322196de | |||
| 84a4ff4bfc | |||
| ef877463da | |||
| 4dc607b964 | |||
| c1f9407adb | |||
| 4026e5bc86 | |||
| 63abf23622 | |||
| ae9f14ad6e | |||
| 98be029203 | |||
| cfb5a5b6e0 | |||
| d274e3af17 | |||
| 7cde15b4c1 | |||
| 3458df01d1 | |||
| 53982eaef1 | |||
| 276446a4dd | |||
| 1e61d05873 | |||
| 6b7eaa1f66 | |||
| fdca17910a | |||
| 21fc115d98 | |||
| 2042f5c251 | |||
| e412b7c72b | |||
| b6e82491f3 | |||
| fcb7fc91f6 | |||
| 74275013dc | |||
| 35ccd5659e | |||
| 1d9b898e9c | |||
| 84e8b325c0 | |||
| b33d7d9ac5 | |||
| 78ce0ed006 | |||
| fc4eb6a3fb | |||
| 9f88c27b83 | |||
| df311374fd | |||
| 29885aa8fd | |||
| 5b4fe6cf82 | |||
| f2f9da1489 | |||
| d1863a76d8 | |||
| e957dd0b85 | |||
| dbbfd5dc7c | |||
| 20cbacd785 | |||
| 7e6706b3cf | |||
| 220290f6f0 | |||
| cf0af00887 | |||
| 0a77e15f86 | |||
| 4448895a6b | |||
| 2ff7a1be44 | |||
| 01ca6e604c | |||
| e79d32af74 | |||
| d5e0caac33 | |||
| 672565e274 | |||
| 97b3489004 | |||
| 34f4174213 | |||
| 2783460d50 | |||
| 4688c941e8 | |||
| f666b2e8f2 | |||
| 9edf0a570b | |||
| 19b4ab8a84 | |||
| 64b98282d8 | |||
| f7487c8ef6 | |||
| 1fd4a18d17 | |||
| 4a81945f9f | |||
| 73f894c154 | |||
| 191a08e817 | |||
| 77b940df60 | |||
| 1cba5ee556 | |||
| 8206d1d5e4 | |||
| b2b370161a | |||
| a22a26f4ce | |||
| e1e1061511 | |||
| eb7be82e71 | |||
| e1507a6c2c | |||
| d406cd327f | |||
| 9fdb6f1647 | |||
| 069c1b6788 | |||
| b4ff14805b | |||
| 8eb1a55e72 | |||
| 09f103ea04 | |||
| 0a647cd612 | |||
| 47373b4565 | |||
| e03a3b5dbb | |||
| b3f4074d67 | |||
| 68607d6909 | |||
| 8a258981ce | |||
| 187d70f348 | |||
| 00e84d6935 | |||
| b147ec5f78 | |||
| 1a687d0081 | |||
| fec20db57c | |||
| e1f62c949d | |||
| 7213606182 | |||
| 2fe1683f4b | |||
| 6f426c1a18 | |||
| af2c101ca8 | |||
| 8d23f0752f | |||
| 29193cca74 | |||
| d720308f30 | |||
| d05f4fb251 | |||
| 9043ebe429 | |||
| 941d05112b | |||
| b726d3a310 | |||
| 7ed7bceacb | |||
| 66252473d5 | |||
| 61bd79969c | |||
| 82ed38d55a | |||
| f603b74a99 | |||
| 0e3118ec2c | |||
| eae3cf6180 | |||
| ddd4662898 | |||
| 6301bf9fd0 | |||
| a802c947ec | |||
| 34f25ff3ea | |||
| 58fb0155b7 | |||
| 25a58c8356 | |||
| c5ee3ec3cd | |||
| 7c835b022f | |||
| 6b3753949b | |||
| f469c5d7eb | |||
| db4adc7f89 | |||
| f1ee3812ed | |||
| 25595d7ebd | |||
| 06b14df307 | |||
| d535bdac8f | |||
| 3660ceb47a | |||
| de1318deb6 | |||
| bf28029d2e | |||
| 9e8c4aacf7 | |||
| cd0397ea00 | |||
| 226c7913f5 | |||
| 340ef09efa | |||
| a8b792074f | |||
| 7ec855efd4 | |||
| 262633f441 | |||
| c59db4e032 | |||
| 15ab6520a2 | |||
| bb7387dbd1 | |||
| 52a7ce11eb | |||
| a7cb84d4b6 | |||
| 070c210d85 | |||
| 2cc00e433e | |||
| 038f31b988 | |||
| 79b579790d | |||
| 487e1fea43 | |||
| 894c238bb7 | |||
| 1660dd112d | |||
| d921876b72 | |||
| 6db21b7f14 | |||
| 3ca126e5db | |||
| 85df857678 | |||
| a2d7b689f3 | |||
| 70581e8524 | |||
| 28df7ca809 | |||
| 8a1619f056 | |||
| 1ae2fe7703 | |||
| de4d81ecd0 | |||
| 62945a5516 | |||
| 645d566540 | |||
| 34fcdcc04a | |||
| 94628b865f | |||
| 91d3b2cd67 | |||
| c90ae8bcc3 | |||
| c548cb4fe0 | |||
| 43e21bfc1e | |||
| 2130412e84 | |||
| 3a76c6f5d7 | |||
| e2dd387a8c | |||
| 5200c1e453 | |||
| 97802adb70 | |||
| 605992439d | |||
| 18de275d42 | |||
| db99e10238 | |||
| 2d27b14513 | |||
| a300e440e0 | |||
| 0a1d367a99 | |||
| 292240cb71 | |||
| 7d1d3e1f9b | |||
| b8b4f9e437 | |||
| 18063dc488 | |||
| 0b4dace54c | |||
| e638926598 | |||
| cb39113117 | |||
| 69a7192fda | |||
| 7252e43e68 | |||
| 1f150d78b1 | |||
| 6521457cfe | |||
| 374b861246 | |||
| 58f4a8a14d | |||
| 7c7317adc9 | |||
| f6851682dd | |||
| bb618aef7d | |||
| 1ec4d2aa17 | |||
| 4b7c00dae3 | |||
| 2727d07d62 | |||
| e41e761b33 | |||
| 4027ac39ab | |||
| 85cdabd72b | |||
| 275eb6e5c4 | |||
| b6678493d3 | |||
| 113f5d3943 | |||
| a40e6cfef8 | |||
| fde5eaacba | |||
| b4b3104c67 | |||
| ea6ede8a7f | |||
| e2744526d8 | |||
| f4b74898fd | |||
| 44c089fadf | |||
| 12761caf10 | |||
| d15e537916 | |||
| 601b04c4cb | |||
| 58f0f0dfb1 | |||
| 1b04860c94 | |||
| 789a8161b2 | |||
| 285ec0a81d | |||
| 22d59e3c11 | |||
| 503e69a499 | |||
| 9e26be5b2c | |||
| d046c95c28 | |||
| 6cf360bce0 | |||
| 2ddcd2d99a | |||
| e7608dc8b4 | |||
| 6593f9e76a | |||
| ab7550d39e | |||
| 20dcb69ae2 | |||
| f806fd2cbf | |||
| 77fdc30b80 | |||
| b1b17b7ad8 | |||
| 4cfd59b45b | |||
| 34f51884bb | |||
| 58cbd0567d | |||
| 0e588fa360 | |||
| c82757fc2e | |||
| 7ab592b3d4 | |||
| 796d00d527 | |||
| 4ec1c4fdf6 | |||
| ae19f69e7b | |||
| d6fa8b6865 | |||
| a2b64aa6d1 | |||
| 240f62bc6e | |||
| 79cad4213b | |||
| 6284a0a8d6 | |||
| f0859cb4cd | |||
| 19e366089e | |||
| ba37d78cba | |||
| f6c7a0b8d2 | |||
| 88b97862c3 | |||
| c79dcfa74d | |||
| f8c89e2f88 | |||
| 9119cafdcd | |||
| 6de0cd268c | |||
| dc9289fece | |||
| 610eda203e | |||
| 8cd6c1972d | |||
| f378fc0d8d | |||
| 4c21a36083 | |||
| a5022fbf8a | |||
| 3074438147 | |||
| c3ad5c3c75 | |||
| 90b5397a46 | |||
| 59a1fa5cd5 | |||
| d6690c7769 | |||
| e6ab997b00 | |||
| 32c6dcc2d6 | |||
| 79f4c8f0e9 | |||
| 2ab3325f30 | |||
| 4856b3f3b2 | |||
| 99500254a1 | |||
| aa9b3aad2a |
@@ -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,127 +2,218 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version, for example 0.1.0"
|
||||
required: true
|
||||
default: "0.1.0"
|
||||
publish_release:
|
||||
description: 'Publish the GitHub release after all platform build and verification jobs pass'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos-arm64-dmg:
|
||||
name: Build macOS ARM64 DMG
|
||||
runs-on: macos-26
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.label }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: macOS-arm64
|
||||
os: macos-26
|
||||
target: aarch64-apple-darwin
|
||||
bundles: app,dmg
|
||||
artifact: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
- label: Windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundles: nsis
|
||||
artifact: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
- label: Linux-x64-AppImage
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
bundles: appimage
|
||||
artifact: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
TAG_NAME="v${VERSION}"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Show build environment
|
||||
run: |
|
||||
uname -a
|
||||
swift --version
|
||||
xcodebuild -version
|
||||
xcode-select -p
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
|
||||
- name: Verify macOS 26+ SDK
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
|
||||
SDK_MAJOR="${SDK_VERSION%%.*}"
|
||||
|
||||
if [[ "$SDK_MAJOR" -lt 26 ]]; then
|
||||
echo "Expected at least macOS 26 SDK, got macOS $SDK_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch media engines
|
||||
run: Scripts/fetch_media_engines.sh
|
||||
|
||||
- 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
|
||||
|
||||
RESOURCES="build/Firelink.app/Contents/Resources"
|
||||
test -d "$RESOURCES/_internal"
|
||||
test -x "$RESOURCES/yt-dlp"
|
||||
test -x "$RESOURCES/deno"
|
||||
test -x "$RESOURCES/ffmpeg"
|
||||
test -x "$RESOURCES/aria2c"
|
||||
test -f "$RESOURCES/aria2-cacert.pem"
|
||||
test -d "$RESOURCES/aria2-libs"
|
||||
test "$(tr -d '[:space:]' < "$RESOURCES/aria2-version.txt")" = "1.37.0-2-arm64-sonoma"
|
||||
test "$("$RESOURCES/yt-dlp" --version)" = "$(tr -d '[:space:]' < "$RESOURCES/yt-dlp-version.txt")"
|
||||
"$RESOURCES/deno" --version
|
||||
"$RESOURCES/ffmpeg" -version
|
||||
"$RESOURCES/aria2c" --version | grep -q '^aria2 version 1.37.0$'
|
||||
for library in "$RESOURCES"/aria2-libs/*.dylib; do
|
||||
lipo -archs "$library" | grep -qx arm64
|
||||
done
|
||||
! otool -L "$RESOURCES/aria2c" "$RESOURCES"/aria2-libs/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'
|
||||
|
||||
- name: Create DMG
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
ARCH: arm64
|
||||
run: Scripts/create_dmg.sh
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
|
||||
path: dist/*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG_NAME="${{ steps.version.outputs.tag_name }}"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
appstream \
|
||||
curl \
|
||||
patchelf \
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5 \
|
||||
rpm \
|
||||
cpio \
|
||||
libarchive-tools \
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0
|
||||
run: |
|
||||
curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool"
|
||||
echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c -
|
||||
chmod +x "$RUNNER_TEMP/appimagetool"
|
||||
- name: Repack AppImage with engines (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool"
|
||||
- name: Verify macOS packaged engines and launch
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
||||
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
- name: Verify Windows installer payload and launch
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1
|
||||
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
||||
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
||||
7z x $installer.FullName "-o$extractRoot" -y
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
|
||||
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
|
||||
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
|
||||
if ($install.ExitCode -ne 0) { throw "Silent NSIS install failed with exit code $($install.ExitCode)." }
|
||||
$exe = $null
|
||||
foreach ($attempt in 1..60) {
|
||||
$exe = Get-ChildItem $installRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1
|
||||
if ($exe) { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." }
|
||||
node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows
|
||||
- name: Verify Linux AppImage payload and launch
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
APPIMAGE="$(find src-tauri/target/${{ matrix.target }}/release/bundle/appimage -name '*.AppImage' -print -quit)"
|
||||
chmod +x "$APPIMAGE"
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
|
||||
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
|
||||
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
|
||||
path: ${{ matrix.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: >-
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
(
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
)
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
- name: Extract changelog release notes
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
test -s release_notes.md
|
||||
|
||||
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
- 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
|
||||
fi
|
||||
mv "$source" "release-assets/$destination"
|
||||
}
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
|
||||
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-assets/**
|
||||
body_path: release_notes.md
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
.build/
|
||||
build/
|
||||
dist/
|
||||
*.dmg
|
||||
DerivedData/
|
||||
skills-lock.json
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.sw?
|
||||
*.xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Local secrets and exported signing keys
|
||||
# Local agent and planning notes
|
||||
AGENT.md
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
|
||||
# Frontend output and logs
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.local
|
||||
|
||||
# Rust and Tauri output
|
||||
target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/engine-dist/
|
||||
src-tauri/provisioned-engines/
|
||||
|
||||
# Locally provisioned native engines
|
||||
src-tauri/binaries/aria2c
|
||||
src-tauri/binaries/deno
|
||||
src-tauri/binaries/ffmpeg
|
||||
src-tauri/binaries/yt-dlp
|
||||
|
||||
# Packaged artifacts
|
||||
build/
|
||||
*.dmg
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.msi
|
||||
*.exe
|
||||
|
||||
# Local secrets and signing material
|
||||
.env
|
||||
.env.*
|
||||
*.key
|
||||
*.pem
|
||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||
*.p8
|
||||
*.p12
|
||||
*.cer
|
||||
@@ -22,15 +68,17 @@ sparkle_private_key*
|
||||
SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
deno
|
||||
ffmpeg
|
||||
aria2c
|
||||
Sources/Firelink/*-version.txt
|
||||
Sources/Firelink/_internal/
|
||||
Sources/Firelink/aria2-libs/
|
||||
Sources/Firelink/aria2-licenses/
|
||||
Sources/Firelink/aria2-cacert.pem
|
||||
!Sources/Firelink/_internal/
|
||||
Sources/Firelink/_internal/*
|
||||
!Sources/Firelink/_internal/.gitkeep
|
||||
|
||||
# Legacy Swift build output and downloaded engines
|
||||
legacy/swift/.build/
|
||||
legacy/swift/DerivedData/
|
||||
legacy/swift/Sources/Firelink/*-version.txt
|
||||
legacy/swift/Sources/Firelink/yt-dlp
|
||||
legacy/swift/Sources/Firelink/deno
|
||||
legacy/swift/Sources/Firelink/ffmpeg
|
||||
legacy/swift/Sources/Firelink/aria2c
|
||||
legacy/swift/Sources/Firelink/_internal/*
|
||||
legacy/swift/Sources/Firelink/aria2-libs/
|
||||
legacy/swift/Sources/Firelink/aria2-licenses/
|
||||
legacy/swift/Sources/Firelink/aria2-cacert.pem
|
||||
!legacy/swift/Sources/Firelink/_internal/.gitkeep
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
@@ -1,11 +1,111 @@
|
||||
# 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).
|
||||
|
||||
## [Unreleased]
|
||||
## [1.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
|
||||
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
|
||||
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
|
||||
|
||||
### Improved
|
||||
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
|
||||
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
|
||||
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
|
||||
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
|
||||
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
|
||||
|
||||
### Fixed
|
||||
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
|
||||
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
|
||||
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
|
||||
|
||||
## [1.0.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
|
||||
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
|
||||
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
### New
|
||||
- Add explicit **Fetch media** actions for Firelink Companion so video and audio pages can be sent to Firelink from the extension popup or page context menu.
|
||||
- Add a Chromium extension install path in Firelink's Integrations settings and release documentation, addressing the Chromium support request in [#4](https://github.com/nimbold/Firelink/issues/4).
|
||||
|
||||
### Improved
|
||||
- Improve YouTube and social-media download handling with better proxy support, cleaner diagnostics, safer log redaction, and more reliable metadata fetching based on reports in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Let category subfolders be disabled so files can save directly into a base download folder, addressing [#6](https://github.com/nimbold/Firelink/issues/6).
|
||||
- Refresh bundled media engines and release tooling for the current cross-platform desktop builds.
|
||||
- Strengthen release packaging checks for macOS, Windows, and Linux, including macOS ad-hoc signing verification and Windows installer icon handling.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube downloads that failed when Chrome's cookie database could not be copied by retrying public media without browser cookies, addressing [#7](https://github.com/nimbold/Firelink/issues/7).
|
||||
- Fix the hidden-sidebar trap on Settings and other pages so the sidebar can always be shown again, addressing [#9](https://github.com/nimbold/Firelink/issues/9).
|
||||
- Fix Windows proxy detection so system proxy schemes are preserved for metadata and media requests, addressing [#5](https://github.com/nimbold/Firelink/issues/5).
|
||||
- Fix captured and auto-captured browser links so they always route through Firelink's Add window before reaching the download list.
|
||||
- Fix media downloads after a metadata fallback so pages without selectable preview formats can still let yt-dlp choose the best downloadable file instead of crashing.
|
||||
- Fix media cleanup and retry paths so failed, canceled, or retried media downloads leave fewer stale temporary files behind.
|
||||
|
||||
## [1.0.1] - 2026-07-04
|
||||
|
||||
### Fixed
|
||||
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
|
||||
|
||||
## [1.0.0] - 2026-07-04
|
||||
|
||||
### Highlights
|
||||
- Firelink is now a cross-platform desktop download manager built with Rust, Tauri, React, and TypeScript.
|
||||
- macOS, Windows, and Linux release builds are prepared through native GitHub Actions runners.
|
||||
- The older macOS-only Swift app has been removed from the active product and replaced by the new desktop app.
|
||||
|
||||
### New
|
||||
- Add full browser-to-desktop integration through Firelink Companion, including signed local requests, desktop identity checks, and automatic-capture protection.
|
||||
- Add a complete Add window for links captured from the browser, pasted manually, or extracted from media pages.
|
||||
- Add segmented direct downloads, media downloads, queue controls, scheduling, speed limits, and duplicate handling.
|
||||
- Add persistent downloads, categories, save-location settings, site logins, and safe redownload behavior.
|
||||
- Add native tray controls, notifications, completion sounds, sleep prevention, file reveal/open/trash actions, and OS keychain support where available.
|
||||
- Add built-in diagnostics for media engines, app logs, packaged-engine validation, and release smoke checks.
|
||||
|
||||
### Improved
|
||||
- Rework the main window, settings, logs, Add window, download list, toasts, context menus, and table interactions for the new desktop interface.
|
||||
- Improve media extraction with better YouTube metadata loading, format filtering, size estimates, output names, progress, retries, and cleanup.
|
||||
- Improve download reliability on rate-limited servers, servers with broken range support, interrupted app sessions, and queue-concurrency changes.
|
||||
- Improve extension captures so browser downloads are resumed unless Firelink confirms the handoff.
|
||||
- Improve cross-platform packaging by bundling target-specific aria2, yt-dlp, FFmpeg, and Deno payloads.
|
||||
- Improve privacy by avoiding startup keychain prompts, redacting secrets from local persistence, and narrowing when browser cookies are forwarded.
|
||||
|
||||
### Fixed
|
||||
- Fix stuck queued/downloading/completed states, stale aria2 dispatches, queue shrink behavior, retry caps, scheduler actions, and post-queue system actions.
|
||||
- Fix many Windows, Linux, and macOS packaging issues, including hidden helper consoles, AppImage engine handling, installer validation, and smoke-test timing.
|
||||
- Fix media child-process cleanup so paused, canceled, failed, or removed downloads do not leave stray processes or partial files behind.
|
||||
- Fix UI hangs, overflow, sorting, selection, animation, and contrast issues across the main list, Add window, settings, and log viewer.
|
||||
- Fix security issues around local server authentication, request signing, replay protection, private IP metadata checks, path ownership, header sanitization, and command execution boundaries.
|
||||
|
||||
### Notes
|
||||
- This is a major release from `0.7.3`. Older Firelink Companion builds are not compatible with every 1.0 capture path; update the extension together with the desktop app.
|
||||
- macOS builds are unsigned and not notarized. Windows builds are unsigned. See [RELEASE.md](RELEASE.md) for distribution details.
|
||||
|
||||
## [0.7.3] - 2026-06-11
|
||||
|
||||
### New Features & Improvements
|
||||
- Add Deno to about credits and engines list.
|
||||
- Enhance speed and ETA display logic during pause and drop.
|
||||
- Accumulate track sizes to present a unified overall progress bar.
|
||||
|
||||
### Fixes
|
||||
- Resolve unknown speed flickering and ultra-wide high-resolution detection.
|
||||
- Emit distinct status messages for individual tracks during download.
|
||||
- Pad overall progress total for first track to prevent 100% snapback.
|
||||
|
||||
## [0.7.2] - 2026-06-11
|
||||
|
||||
@@ -313,7 +413,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,27 +0,0 @@
|
||||
.PHONY: engines build app dmg release run verify clean
|
||||
|
||||
engines:
|
||||
Scripts/fetch_media_engines.sh
|
||||
|
||||
build: engines
|
||||
swift build -c release
|
||||
|
||||
app:
|
||||
Scripts/create_app_bundle.sh
|
||||
|
||||
dmg: app
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
release:
|
||||
Scripts/create_app_bundle.sh
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
run: engines
|
||||
swift run Firelink
|
||||
|
||||
verify:
|
||||
Scripts/verify.sh
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf build dist
|
||||
@@ -1,38 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Firelink",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "Firelink", targets: ["Firelink"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
dependencies: [],
|
||||
path: "Sources/Firelink",
|
||||
exclude: [
|
||||
"deno-version.txt",
|
||||
"ffmpeg-version.txt"
|
||||
],
|
||||
resources: [
|
||||
.process("Assets.xcassets"),
|
||||
.copy("yt-dlp"),
|
||||
.copy("yt-dlp-version.txt"),
|
||||
.copy("_internal"),
|
||||
.copy("deno"),
|
||||
.copy("ffmpeg"),
|
||||
.copy("aria2c"),
|
||||
.copy("aria2-libs"),
|
||||
.copy("aria2-cacert.pem"),
|
||||
.copy("aria2-version.txt"),
|
||||
.copy("aria2-licenses")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,95 +1,178 @@
|
||||
<div align="center">
|
||||
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
|
||||
<h1>Firelink</h1>
|
||||
<p><strong>The modern, blazing-fast download manager built natively for Apple Silicon macOS.</strong></p>
|
||||
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
|
||||
|
||||
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
|
||||
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
|
||||
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
|
||||
<a href="https://github.com/yt-dlp/yt-dlp"><img src="https://img.shields.io/badge/Engine-yt--dlp-red?logo=youtube&logoColor=white" alt="yt-dlp Engine" /></a>
|
||||
<a href="https://ffmpeg.org/"><img src="https://img.shields.io/badge/Engine-ffmpeg-red?logo=ffmpeg&logoColor=white" alt="ffmpeg Engine" /></a>
|
||||
<a href="https://deno.com/"><img src="https://img.shields.io/badge/Engine-deno-blue?logo=deno&logoColor=white" alt="Deno Engine" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
|
||||
# Firelink
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](LICENSE)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
|
||||
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**Firelink** reimagines macOS file downloading by wrapping legendary open-source engines (`aria2c`, `yt-dlp`, and `ffmpeg`) in a beautifully crafted, highly polished SwiftUI interface. Enjoy high-speed segmented downloads, native media extraction, seamless browser integration, and intelligent file organization without compromising on the aesthetics of your Mac.
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/Settings.png" alt="Settings" width="32%" />
|
||||
<br/>
|
||||
<sub>A premium native experience, from batch linking to advanced settings.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>Light Theme Screenshots</b></summary>
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window light theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window light theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Light/Settings.png" alt="Settings light theme" width="32%" />
|
||||
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
|
||||
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
|
||||
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
|
||||
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
|
||||
|
||||
<details>
|
||||
<summary><b>View more screenshots</b></summary>
|
||||
<br/>
|
||||
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
|
||||
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
## Why Firelink
|
||||
|
||||
---
|
||||
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
|
||||
## ✨ Features
|
||||
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
|
||||
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits and a built-in download scheduler.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
|
||||
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface strictly adhering to Apple Human Interface Guidelines, featuring a visual chunk map and dynamic progress tracking.
|
||||
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
|
||||
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration and secure HMAC-SHA256 authenticated local API endpoints ensure your system remains strictly isolated and protected from malicious scripts.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
|
||||
The current desktop release is **1.0.4**, paired with Firelink Companion **2.0.3**.
|
||||
|
||||
---
|
||||
## Features
|
||||
|
||||
## 🧩 Browser Integration
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
||||
|
||||
We are live! Send downloads directly from your browser to the Firelink app with zero friction.
|
||||
## Installation
|
||||
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20on%20Firefox-FF7139?logo=firefox-browser&logoColor=white&style=for-the-badge" alt="Install on Firefox" /></a>
|
||||
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
|
||||
|
||||
*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
|
||||
| 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** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the portable fallback. AppImage may need executable permission. |
|
||||
|
||||
---
|
||||
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
|
||||
|
||||
## 🛠️ Quick Start
|
||||
The native packages use the distribution's normal desktop runtime dependencies, while AppImage remains the portable installation option.
|
||||
|
||||
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
|
||||
## Browser Extension
|
||||
|
||||
Run the application directly:
|
||||
```bash
|
||||
swift run Firelink
|
||||
<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.3 is the matching extension release for Firelink 1.0.4.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
|
||||
|
||||
## Platforms
|
||||
|
||||
| Target | Status |
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
|
||||
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
|
||||
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 22 or newer
|
||||
- npm
|
||||
- Rust and Cargo
|
||||
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
Clone the repository with its browser-extension submodule:
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
|
||||
cd Firelink
|
||||
```
|
||||
|
||||
Or build a production `.app` bundle:
|
||||
```bash
|
||||
make app && open build/Firelink.app
|
||||
Install dependencies and launch the desktop app:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
---
|
||||
Run the core checks:
|
||||
|
||||
## 🏆 Credits
|
||||
```sh
|
||||
node --test scripts/*.node-test.js
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Firelink stands on the shoulders of giants. A massive thank you to the contributors of these phenomenal open-source projects:
|
||||
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
|
||||
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
|
||||
- **[Deno](https://deno.com/)** - The secure runtime for JavaScript and TypeScript solving complex media extraction challenges.
|
||||
Create a production bundle:
|
||||
|
||||
---
|
||||
```sh
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
|
||||
|
||||
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
|
||||
```sh
|
||||
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
|
||||
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Browser/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
|
||||
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
|
||||
|
||||
## Technology & Credits
|
||||
|
||||
Firelink is made possible by these open-source projects:
|
||||
|
||||
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
|
||||
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
|
||||
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
|
||||
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
|
||||
- **[SQLite](https://www.sqlite.org/)** for persistent local data
|
||||
- **[aria2](https://aria2.github.io/)** for segmented downloading
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
|
||||
|
||||
## License
|
||||
|
||||
Firelink is available under the [MIT License](LICENSE).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Firelink Release Process
|
||||
|
||||
Targets:
|
||||
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Linux x64 AppImage
|
||||
- Linux x64 Debian package
|
||||
- Linux x64 RPM package
|
||||
|
||||
## Distribution policy
|
||||
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
|
||||
|
||||
## Engine supply chain
|
||||
|
||||
Firelink never falls back to system-installed media tools.
|
||||
|
||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||
- `scripts/provision-engines.js` downloads and verifies target archives.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
## Version update
|
||||
|
||||
Keep versions aligned:
|
||||
|
||||
- `package.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
|
||||
## Local macOS build
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri && cargo test --all-targets
|
||||
cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
|
||||
```bash
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
GitHub release publication 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: 5.4 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 328 KiB |
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
CONFIGURATION="${CONFIGURATION:-release}"
|
||||
DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
|
||||
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
|
||||
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
|
||||
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
ICON_NAME="AppIcon"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
"$ROOT_DIR/Scripts/fetch_media_engines.sh"
|
||||
|
||||
swift build -c "$CONFIGURATION"
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
||||
|
||||
for media_engine in yt-dlp deno ffmpeg aria2c; do
|
||||
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
|
||||
if [[ -x "$media_engine_path" ]]; then
|
||||
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
||||
chmod +x "$RESOURCES_DIR/$media_engine"
|
||||
else
|
||||
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
|
||||
fi
|
||||
done
|
||||
|
||||
for resource_directory in _internal aria2-libs aria2-licenses; do
|
||||
source_path="$ROOT_DIR/Sources/Firelink/$resource_directory"
|
||||
if [[ ! -d "$source_path" ]]; then
|
||||
echo "Required runtime directory is missing: $source_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -R "$source_path" "$RESOURCES_DIR/$resource_directory"
|
||||
done
|
||||
|
||||
for resource_file in yt-dlp-version.txt aria2-version.txt aria2-cacert.pem; do
|
||||
source_path="$ROOT_DIR/Sources/Firelink/$resource_file"
|
||||
if [[ ! -f "$source_path" ]]; then
|
||||
echo "Required runtime file is missing: $source_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$source_path" "$RESOURCES_DIR/$resource_file"
|
||||
done
|
||||
|
||||
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"
|
||||
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
|
||||
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>local.firelink.swiftui</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>$ICON_NAME</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$MARKETING_VERSION</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$BUILD_NUMBER</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>local.firelink.swiftui</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>firelink</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if command -v codesign &> /dev/null; then
|
||||
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
|
||||
echo "Signing app bundle with identity: $SIGNING_IDENTITY"
|
||||
else
|
||||
CODESIGN_ARGS=(--force --sign -)
|
||||
echo "Ad-hoc signing app bundle for local use."
|
||||
fi
|
||||
|
||||
sign_path() {
|
||||
local path="$1"
|
||||
codesign "${CODESIGN_ARGS[@]}" "$path"
|
||||
}
|
||||
|
||||
sign_mach_o_file() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
*/Python.framework/Python|*/Python.framework/Versions/Current/*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
if file "$path" | grep -q 'Mach-O'; then
|
||||
if ! sign_path "$path"; then
|
||||
echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -d "$FRAMEWORKS_DIR" ]]; then
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" -type f -print0)
|
||||
|
||||
while IFS= read -r -d '' bundle_path; do
|
||||
sign_path "$bundle_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$RESOURCES_DIR" -type f -print0)
|
||||
|
||||
sign_path "$MACOS_DIR/$APP_NAME"
|
||||
sign_path "$APP_DIR"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_DIR"
|
||||
fi
|
||||
|
||||
echo "Created $APP_DIR"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="Firelink"
|
||||
VERSION="${VERSION:-}"
|
||||
ARCH="${ARCH:-arm64}"
|
||||
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
|
||||
|
||||
if [[ ! -d "$APP_DIR" ]]; then
|
||||
echo "Missing app bundle: $APP_DIR" >&2
|
||||
echo "Run Scripts/create_app_bundle.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DIR/Contents/Info.plist")"
|
||||
fi
|
||||
|
||||
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
|
||||
|
||||
rm -rf "$DMG_STAGING_DIR"
|
||||
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
|
||||
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
|
||||
ln -s /Applications "$DMG_STAGING_DIR/Applications"
|
||||
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME $VERSION" \
|
||||
-srcfolder "$DMG_STAGING_DIR" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$DMG_PATH"
|
||||
|
||||
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"
|
||||
fi
|
||||
|
||||
echo "Created $DMG_PATH"
|
||||
@@ -1,337 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_DIR="$ROOT_DIR/Sources/Firelink"
|
||||
|
||||
YTDLP_VERSION="2026.06.09"
|
||||
YTDLP_MACOS_ZIP_SHA256="62a3108d7c37090107f0bb9a2369b953b35e43f4bc76ab0ea87e4ab593c23ec7"
|
||||
|
||||
DENO_VERSION="2.8.2"
|
||||
DENO_ARM64_ZIP_SHA256="02e5eb795c9f763772dfd081429cead9029e0a4a6aaff6d4e5f3ed6d2e94d361"
|
||||
DENO_X86_64_ZIP_SHA256="77cf27f835f1921e49434449675c57432c6314d54edc725e2474cc825546e206"
|
||||
|
||||
FFMPEG_VERSION="8.1.1"
|
||||
FFMPEG_ARM64_URL="https://ffmpeg.martin-riedl.de/download/macos/arm64/1778761665_8.1.1/ffmpeg.zip"
|
||||
FFMPEG_ARM64_ZIP_SHA256="a05b1a47bb3ac89a95a55eec713f8bbb347051bb07015f3b7d08fb62ed81a21e"
|
||||
|
||||
ARIA2_VERSION="1.37.0"
|
||||
ARIA2_BOTTLE_REVISION="2"
|
||||
ARIA2_RUNTIME_ID="$ARIA2_VERSION-$ARIA2_BOTTLE_REVISION-arm64-sonoma"
|
||||
ARIA2_BOTTLE_SHA256="8815b6b79395235863349628dc0d753bbee9069e99d94257b7646ffd85615623"
|
||||
CARES_VERSION="1.34.6"
|
||||
CARES_BOTTLE_SHA256="17f44048d8003b88231d69bac0408cf22be2f712ef8588d4933ff0811b92342c"
|
||||
LIBSSH2_VERSION="1.11.1_1"
|
||||
LIBSSH2_BOTTLE_SHA256="34927ad08cd265d32f1390a92d84451f85ab5b2f28101ca951da3d3e9df12047"
|
||||
OPENSSL_VERSION="3.6.2"
|
||||
OPENSSL_BOTTLE_SHA256="aaa5f4f3d87868ecd5f5fd6967da0c305eb335a58171faba193e9c7e39fbf35c"
|
||||
SQLITE_VERSION="3.53.0"
|
||||
SQLITE_BOTTLE_SHA256="36080e3273614fe3d606ff0bd5bb090ad33c19f186ba44c35807b8f97afa15be"
|
||||
GETTEXT_VERSION="1.0"
|
||||
GETTEXT_BOTTLE_SHA256="f9ea4eed738746ea4150a4f83e8dd11ca21ca3de5bb113995c25eec409bb5749"
|
||||
|
||||
ARIA2_RUNTIME_SHA256="111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
CARES_RUNTIME_SHA256="86ceec6264753bfffb1562df22e81cbbed72d370105936ee083f3152c9dc1673"
|
||||
LIBCRYPTO_RUNTIME_SHA256="a13e280563c6eb85058f590f6f558fb20f54e171024f3b9b3637df140add1714"
|
||||
LIBINTL_RUNTIME_SHA256="7e6628118b26b58b57346f3f088b1f87b263c677736ade678f0aced5579ea357"
|
||||
LIBSQLITE_RUNTIME_SHA256="27da39c4cc96e7f43c8ed0134d2de6dd2fd36008dcb044c03aeee3eec9edc545"
|
||||
LIBSSH2_RUNTIME_SHA256="67cbce90dca26590a8a7627af8f4abccfd94f41cae48f7fa67a5f0cb98efc85b"
|
||||
LIBSSL_RUNTIME_SHA256="f5676ffe68757ea2629898c29bcee5f15982e06fe878bec4f70d159dd1b70452"
|
||||
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
|
||||
sha256() {
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
verify_sha256() {
|
||||
local path="$1"
|
||||
local expected="$2"
|
||||
local actual
|
||||
actual="$(sha256 "$path")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "Checksum mismatch for $path" >&2
|
||||
echo "Expected: $expected" >&2
|
||||
echo "Actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
version_matches() {
|
||||
local marker_path="$1"
|
||||
local expected="$2"
|
||||
[[ -f "$marker_path" ]] && [[ "$(tr -d '[:space:]' < "$marker_path")" == "$expected" ]]
|
||||
}
|
||||
|
||||
fetch_homebrew_bottle() {
|
||||
local repository="$1"
|
||||
local digest="$2"
|
||||
local output_path="$3"
|
||||
local token
|
||||
|
||||
token="$(
|
||||
curl -fsSL "https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/$repository:pull" |
|
||||
python3 -c 'import json, sys; print(json.load(sys.stdin)["token"])'
|
||||
)"
|
||||
curl -fsSL \
|
||||
-H "Authorization: Bearer $token" \
|
||||
"https://ghcr.io/v2/homebrew/core/$repository/blobs/sha256:$digest" \
|
||||
-o "$output_path"
|
||||
verify_sha256 "$output_path" "$digest"
|
||||
}
|
||||
|
||||
fetch_ytdlp() {
|
||||
local executable="$SOURCE_DIR/yt-dlp"
|
||||
local runtime="$SOURCE_DIR/_internal"
|
||||
local marker="$SOURCE_DIR/yt-dlp-version.txt"
|
||||
|
||||
if [[ -x "$executable" ]] && [[ -d "$runtime" ]] && version_matches "$marker" "$YTDLP_VERSION"; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Fetching yt-dlp $YTDLP_VERSION one-folder runtime..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL \
|
||||
"https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/yt-dlp_macos.zip" \
|
||||
-o "$temp_dir/yt-dlp.zip"
|
||||
verify_sha256 "$temp_dir/yt-dlp.zip" "$YTDLP_MACOS_ZIP_SHA256"
|
||||
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir/runtime"
|
||||
|
||||
rm -rf "$runtime"
|
||||
cp "$temp_dir/runtime/yt-dlp_macos" "$executable"
|
||||
cp -R "$temp_dir/runtime/_internal" "$runtime"
|
||||
touch "$runtime/.gitkeep"
|
||||
chmod +x "$executable"
|
||||
printf '%s\n' "$YTDLP_VERSION" > "$marker"
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
fetch_deno() {
|
||||
local machine_arch
|
||||
machine_arch="$(uname -m)"
|
||||
|
||||
local asset_arch
|
||||
local expected_sha
|
||||
case "$machine_arch" in
|
||||
arm64)
|
||||
asset_arch="aarch64"
|
||||
expected_sha="$DENO_ARM64_ZIP_SHA256"
|
||||
;;
|
||||
x86_64)
|
||||
asset_arch="x86_64"
|
||||
expected_sha="$DENO_X86_64_ZIP_SHA256"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture for bundled Deno: $machine_arch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local executable="$SOURCE_DIR/deno"
|
||||
local marker="$SOURCE_DIR/deno-version.txt"
|
||||
if [[ -x "$executable" ]] && version_matches "$marker" "$DENO_VERSION-$machine_arch"; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Fetching Deno $DENO_VERSION for $machine_arch..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL \
|
||||
"https://github.com/denoland/deno/releases/download/v$DENO_VERSION/deno-$asset_arch-apple-darwin.zip" \
|
||||
-o "$temp_dir/deno.zip"
|
||||
verify_sha256 "$temp_dir/deno.zip" "$expected_sha"
|
||||
unzip -q -o "$temp_dir/deno.zip" -d "$temp_dir/runtime"
|
||||
cp "$temp_dir/runtime/deno" "$executable"
|
||||
chmod +x "$executable"
|
||||
printf '%s\n' "$DENO_VERSION-$machine_arch" > "$marker"
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
fetch_ffmpeg() {
|
||||
local executable="$SOURCE_DIR/ffmpeg"
|
||||
local marker="$SOURCE_DIR/ffmpeg-version.txt"
|
||||
|
||||
if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
if [[ ! -x "$executable" ]]; then
|
||||
echo "A local ffmpeg executable is required on non-ARM64 development hosts." >&2
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -x "$executable" ]] && version_matches "$marker" "$FFMPEG_VERSION-arm64"; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Fetching FFmpeg $FFMPEG_VERSION for arm64..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL "$FFMPEG_ARM64_URL" -o "$temp_dir/ffmpeg.zip"
|
||||
verify_sha256 "$temp_dir/ffmpeg.zip" "$FFMPEG_ARM64_ZIP_SHA256"
|
||||
unzip -q -o "$temp_dir/ffmpeg.zip" -d "$temp_dir/runtime"
|
||||
cp "$temp_dir/runtime/ffmpeg" "$executable"
|
||||
chmod +x "$executable"
|
||||
printf '%s\n' "$FFMPEG_VERSION-arm64" > "$marker"
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
aria2_runtime_is_ready() {
|
||||
local required_paths=(
|
||||
"$SOURCE_DIR/aria2c"
|
||||
"$SOURCE_DIR/aria2-cacert.pem"
|
||||
"$SOURCE_DIR/aria2-libs/libcares.2.dylib"
|
||||
"$SOURCE_DIR/aria2-libs/libcrypto.3.dylib"
|
||||
"$SOURCE_DIR/aria2-libs/libintl.8.dylib"
|
||||
"$SOURCE_DIR/aria2-libs/libsqlite3.dylib"
|
||||
"$SOURCE_DIR/aria2-libs/libssh2.1.dylib"
|
||||
"$SOURCE_DIR/aria2-libs/libssl.3.dylib"
|
||||
)
|
||||
|
||||
version_matches "$SOURCE_DIR/aria2-version.txt" "$ARIA2_RUNTIME_ID" || return 1
|
||||
[[ -x "$SOURCE_DIR/aria2c" ]] || return 1
|
||||
[[ -d "$SOURCE_DIR/aria2-licenses" ]] || return 1
|
||||
|
||||
local path
|
||||
for path in "${required_paths[@]}"; do
|
||||
[[ -e "$path" ]] || return 1
|
||||
done
|
||||
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2c")" == "$ARIA2_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcares.2.dylib")" == "$CARES_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcrypto.3.dylib")" == "$LIBCRYPTO_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libintl.8.dylib")" == "$LIBINTL_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libsqlite3.dylib")" == "$LIBSQLITE_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssh2.1.dylib")" == "$LIBSSH2_RUNTIME_SHA256" ]] || return 1
|
||||
[[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssl.3.dylib")" == "$LIBSSL_RUNTIME_SHA256" ]] || return 1
|
||||
}
|
||||
|
||||
fetch_aria2() {
|
||||
if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
echo "The pinned aria2 runtime is currently available only for ARM64 macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if aria2_runtime_is_ready; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Fetching aria2 $ARIA2_VERSION Homebrew ARM64 Sonoma runtime..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
local bottles_dir="$temp_dir/bottles"
|
||||
local extracted_dir="$temp_dir/extracted"
|
||||
local runtime_dir="$temp_dir/runtime"
|
||||
local libraries_dir="$runtime_dir/aria2-libs"
|
||||
local licenses_dir="$runtime_dir/aria2-licenses"
|
||||
mkdir -p "$bottles_dir" "$extracted_dir" "$libraries_dir" "$licenses_dir"
|
||||
|
||||
fetch_homebrew_bottle "aria2" "$ARIA2_BOTTLE_SHA256" "$bottles_dir/aria2.tar.gz"
|
||||
fetch_homebrew_bottle "c-ares" "$CARES_BOTTLE_SHA256" "$bottles_dir/c-ares.tar.gz"
|
||||
fetch_homebrew_bottle "libssh2" "$LIBSSH2_BOTTLE_SHA256" "$bottles_dir/libssh2.tar.gz"
|
||||
fetch_homebrew_bottle "openssl/3" "$OPENSSL_BOTTLE_SHA256" "$bottles_dir/openssl.tar.gz"
|
||||
fetch_homebrew_bottle "sqlite" "$SQLITE_BOTTLE_SHA256" "$bottles_dir/sqlite.tar.gz"
|
||||
fetch_homebrew_bottle "gettext" "$GETTEXT_BOTTLE_SHA256" "$bottles_dir/gettext.tar.gz"
|
||||
|
||||
local bottle
|
||||
for bottle in "$bottles_dir"/*.tar.gz; do
|
||||
tar -xzf "$bottle" -C "$extracted_dir"
|
||||
done
|
||||
|
||||
cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/bin/aria2c" "$runtime_dir/aria2c"
|
||||
cp "$extracted_dir/c-ares/$CARES_VERSION/lib/libcares.2.19.5.dylib" "$libraries_dir/libcares.2.dylib"
|
||||
cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/lib/libssh2.1.dylib" "$libraries_dir/libssh2.1.dylib"
|
||||
cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libssl.3.dylib" "$libraries_dir/libssl.3.dylib"
|
||||
cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "$libraries_dir/libcrypto.3.dylib"
|
||||
cp "$extracted_dir/sqlite/$SQLITE_VERSION/lib/libsqlite3.3.53.0.dylib" "$libraries_dir/libsqlite3.dylib"
|
||||
cp "$extracted_dir/gettext/$GETTEXT_VERSION/lib/libintl.8.dylib" "$libraries_dir/libintl.8.dylib"
|
||||
cp "$SOURCE_DIR/_internal/certifi/cacert.pem" "$runtime_dir/aria2-cacert.pem"
|
||||
|
||||
cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/COPYING" "$licenses_dir/aria2-COPYING"
|
||||
cp "$extracted_dir/c-ares/$CARES_VERSION/LICENSE.md" "$licenses_dir/c-ares-LICENSE.md"
|
||||
cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/COPYING" "$licenses_dir/libssh2-COPYING"
|
||||
cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/LICENSE.txt" "$licenses_dir/openssl-LICENSE.txt"
|
||||
cp "$extracted_dir/gettext/$GETTEXT_VERSION/COPYING" "$licenses_dir/gettext-COPYING"
|
||||
cp "$extracted_dir/sqlite/$SQLITE_VERSION/sbom.spdx.json" "$licenses_dir/sqlite-sbom.spdx.json"
|
||||
|
||||
install_name_tool \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/sqlite/lib/libsqlite3.dylib" "@loader_path/aria2-libs/libsqlite3.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/aria2-libs/libssl.3.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/aria2-libs/libcrypto.3.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/libssh2/lib/libssh2.1.dylib" "@loader_path/aria2-libs/libssh2.1.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/c-ares/lib/libcares.2.dylib" "@loader_path/aria2-libs/libcares.2.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/gettext/lib/libintl.8.dylib" "@loader_path/aria2-libs/libintl.8.dylib" \
|
||||
"$runtime_dir/aria2c"
|
||||
|
||||
local library
|
||||
for library in "$libraries_dir"/*.dylib; do
|
||||
install_name_tool -id "@loader_path/$(basename "$library")" "$library"
|
||||
done
|
||||
|
||||
install_name_tool \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/libssl.3.dylib" \
|
||||
-change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \
|
||||
"$libraries_dir/libssh2.1.dylib"
|
||||
install_name_tool \
|
||||
-change "@@HOMEBREW_CELLAR@@/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \
|
||||
"$libraries_dir/libssl.3.dylib"
|
||||
|
||||
chmod +x "$runtime_dir/aria2c"
|
||||
for library in "$libraries_dir"/*.dylib; do
|
||||
codesign --force --sign - "$library"
|
||||
done
|
||||
codesign --force --sign - "$runtime_dir/aria2c"
|
||||
|
||||
verify_sha256 "$runtime_dir/aria2c" "$ARIA2_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libcares.2.dylib" "$CARES_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libcrypto.3.dylib" "$LIBCRYPTO_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libintl.8.dylib" "$LIBINTL_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libsqlite3.dylib" "$LIBSQLITE_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libssh2.1.dylib" "$LIBSSH2_RUNTIME_SHA256"
|
||||
verify_sha256 "$libraries_dir/libssl.3.dylib" "$LIBSSL_RUNTIME_SHA256"
|
||||
|
||||
if otool -L "$runtime_dir/aria2c" "$libraries_dir"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then
|
||||
echo "The prepared aria2 runtime still contains a Homebrew path." >&2
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
if ! vtool -show-build "$runtime_dir/aria2c" | grep -q 'minos 14.0'; then
|
||||
echo "The pinned aria2 runtime no longer supports the app's macOS 14 deployment target." >&2
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$("$runtime_dir/aria2c" --version | head -n 1)" != "aria2 version $ARIA2_VERSION" ]]; then
|
||||
echo "The prepared aria2 runtime failed its version check." >&2
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$SOURCE_DIR/aria2-libs" "$SOURCE_DIR/aria2-licenses"
|
||||
cp "$runtime_dir/aria2c" "$SOURCE_DIR/aria2c"
|
||||
cp "$runtime_dir/aria2-cacert.pem" "$SOURCE_DIR/aria2-cacert.pem"
|
||||
cp -R "$libraries_dir" "$SOURCE_DIR/aria2-libs"
|
||||
cp -R "$licenses_dir" "$SOURCE_DIR/aria2-licenses"
|
||||
printf '%s\n' "$ARIA2_RUNTIME_ID" > "$SOURCE_DIR/aria2-version.txt"
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
fetch_ytdlp
|
||||
fetch_deno
|
||||
fetch_ffmpeg
|
||||
fetch_aria2
|
||||
|
||||
if command -v xattr >/dev/null; then
|
||||
xattr -cr \
|
||||
"$SOURCE_DIR/yt-dlp" \
|
||||
"$SOURCE_DIR/_internal" \
|
||||
"$SOURCE_DIR/deno" \
|
||||
"$SOURCE_DIR/ffmpeg" \
|
||||
"$SOURCE_DIR/aria2c" \
|
||||
"$SOURCE_DIR/aria2-libs" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Media engines are ready:"
|
||||
echo " yt-dlp $YTDLP_VERSION"
|
||||
echo " Deno $DENO_VERSION"
|
||||
echo " FFmpeg $FFMPEG_VERSION"
|
||||
echo " aria2 $ARIA2_VERSION (Homebrew bottle revision $ARIA2_BOTTLE_REVISION)"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
"$ROOT_DIR/Scripts/fetch_media_engines.sh"
|
||||
|
||||
ARIA2C="$ROOT_DIR/Sources/Firelink/aria2c"
|
||||
ARIA2_LIBS="$ROOT_DIR/Sources/Firelink/aria2-libs"
|
||||
test -x "$ARIA2C"
|
||||
test -f "$ROOT_DIR/Sources/Firelink/aria2-cacert.pem"
|
||||
test -d "$ARIA2_LIBS"
|
||||
"$ARIA2C" --version | grep -q '^aria2 version 1.37.0$'
|
||||
test "$(tr -d '[:space:]' < "$ROOT_DIR/Sources/Firelink/aria2-version.txt")" = "1.37.0-2-arm64-sonoma"
|
||||
lipo -archs "$ARIA2C" | grep -qx arm64
|
||||
vtool -show-build "$ARIA2C" | grep -q 'minos 14.0'
|
||||
for library in "$ARIA2_LIBS"/*.dylib; do
|
||||
lipo -archs "$library" | grep -qx arm64
|
||||
done
|
||||
if otool -L "$ARIA2C" "$ARIA2_LIBS"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then
|
||||
echo "Bundled aria2 runtime contains a non-portable dependency path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
swift build
|
||||
git diff --check
|
||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||
@@ -1,897 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddDownloadsView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var linkText = ""
|
||||
@State private var pendingDownloads: [PendingDownload] = []
|
||||
@State private var connectionsPerServer = 16.0
|
||||
@State private var overrideDestination = false
|
||||
@State private var destinationPath = ""
|
||||
@State private var metadataTask: Task<Void, Never>?
|
||||
@State private var targetQueueID = DownloadQueue.mainQueueID
|
||||
@State private var speedLimitEnabled = false
|
||||
@State private var speedLimitKiBPerSecond = 1024
|
||||
@State private var showsAdvancedTransfer = false
|
||||
@State private var checksumEnabled = false
|
||||
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
|
||||
@State private var checksumValue = ""
|
||||
@State private var headerText = ""
|
||||
@State private var cookieText = ""
|
||||
@State private var mirrorText = ""
|
||||
@State private var useAuthorization = false
|
||||
@State private var authUsername = ""
|
||||
@State private var authPassword = ""
|
||||
@State private var saveLogin = false
|
||||
|
||||
@State private var conflictingDownloads: [DuplicateDownloadItem] = []
|
||||
@State private var showingDuplicates = false
|
||||
@State private var pendingStartFlag = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
// Left Column
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
linkSection
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right Column
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.frame(width: 320)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 720, idealWidth: 800, minHeight: 620, idealHeight: 680)
|
||||
.sheet(isPresented: $showingDuplicates) {
|
||||
DuplicateResolutionView(
|
||||
conflicts: $conflictingDownloads,
|
||||
onConfirm: {
|
||||
showingDuplicates = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
showingDuplicates = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: linkText) { _, newValue in
|
||||
scheduleMetadataRefresh(for: newValue)
|
||||
}
|
||||
.onChange(of: metadataRequestSignature) { _, _ in
|
||||
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
|
||||
scheduleMetadataRefresh(for: linkText)
|
||||
}
|
||||
.onAppear {
|
||||
connectionsPerServer = Double(settings.perServerConnections)
|
||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||
controller.pendingAddQueueID = nil
|
||||
if let text = controller.pendingPasteboardText {
|
||||
applyPendingReferer()
|
||||
linkText = text
|
||||
controller.pendingPasteboardText = nil
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
metadataTask?.cancel()
|
||||
linkText = ""
|
||||
pendingDownloads = []
|
||||
headerText = ""
|
||||
cookieText = ""
|
||||
mirrorText = ""
|
||||
useAuthorization = false
|
||||
authUsername = ""
|
||||
authPassword = ""
|
||||
checksumEnabled = false
|
||||
checksumValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var linkSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Download Links", systemImage: "link")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
TextEditor(text: $linkText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 72, idealHeight: 100, maxHeight: 160)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
refreshMetadata(for: linkText, isAutoFetch: false)
|
||||
} label: {
|
||||
Label("Refresh Metadata", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(DownloadURLParser.parse(linkText).isEmpty)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
Group {
|
||||
if let firstMedia = pendingDownloads.first(where: { $0.isMedia }) {
|
||||
mediaFormatSection(for: firstMedia)
|
||||
}
|
||||
|
||||
Section("Save Location") {
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
if overrideDestination {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Queue") {
|
||||
Picker("Target Queue", selection: $targetQueueID) {
|
||||
ForEach(controller.queues) { queue in
|
||||
Text(queue.name).tag(queue.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Transfer Settings") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connections per File")
|
||||
HStack {
|
||||
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
|
||||
Text("\(Int(connectionsPerServer))")
|
||||
.monospacedDigit()
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Limit speed per file", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Authorization") {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
if useAuthorization {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
CompactSummaryStrip(
|
||||
metrics: [
|
||||
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
|
||||
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
|
||||
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
|
||||
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Preview", systemImage: "list.bullet.rectangle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Table($pendingDownloads) {
|
||||
TableColumn("File") { $item in
|
||||
HStack {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.foregroundStyle(categoryColor(item.category))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.fileName)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TableColumn("Size") { $item in
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(86)
|
||||
|
||||
TableColumn("Save To") { $item in
|
||||
Text(item.destinationPath)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
TableColumn("Status") { $item in
|
||||
if item.isMedia, case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
}
|
||||
.width(min: 110, ideal: 140, max: 200)
|
||||
}
|
||||
.frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
HStack {
|
||||
Text(actionMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if metadataTask != nil {
|
||||
Button {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
|
||||
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
}
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button("Start Downloads") {
|
||||
addDownloads(start: true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMediaOption(for firstMedia: PendingDownload, newId: String) {
|
||||
for index in pendingDownloads.indices where pendingDownloads[index].isMedia {
|
||||
if let option = pendingDownloads[index].mediaOptions.first(where: { $0.id == newId }) {
|
||||
pendingDownloads[index].selectedMediaOption = option
|
||||
if let metadata = pendingDownloads[index].mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
pendingDownloads[index].fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
pendingDownloads[index].category = FileClassifier.category(forFileName: pendingDownloads[index].fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaFormatSection(for firstMedia: PendingDownload) -> some View {
|
||||
Section {
|
||||
if firstMedia.mediaOptions.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
if case .loading = firstMedia.state {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Fetching media options...")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case .failed(let errorMsg) = firstMedia.state {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
|
||||
Text("Failed to load options.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(errorMsg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(3)
|
||||
}
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for metadata...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
|
||||
let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
|
||||
let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
|
||||
let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
|
||||
let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
|
||||
|
||||
let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
|
||||
if $0 == "Best" { return true }
|
||||
if $1 == "Best" { return false }
|
||||
return $0 > $1
|
||||
})
|
||||
|
||||
Picker("Type", selection: Binding(
|
||||
get: { currentOption.mediaType },
|
||||
set: { newType in
|
||||
if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
|
||||
updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableTypes, id: \.self) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Picker("Format", selection: Binding(
|
||||
get: { currentOption.containerName },
|
||||
set: { newFormat in
|
||||
let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
|
||||
let fallback = optionsForType.first(where: { $0.containerName == newFormat })
|
||||
if let newOption = matching ?? fallback {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableFormats, id: \.self) { format in
|
||||
Text(format).tag(format)
|
||||
}
|
||||
}
|
||||
|
||||
if currentOption.mediaType == .video {
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { currentOption.qualityName },
|
||||
set: { newQuality in
|
||||
if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableQualities, id: \.self) { quality in
|
||||
Text(quality).tag(quality)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Quality", selection: .constant("Best")) {
|
||||
Text("Best").tag("Best")
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
} // End of else block
|
||||
} header: {
|
||||
Text("Media Format").foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Verify Checksum", isOn: $checksumEnabled)
|
||||
if checksumEnabled {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
.onChange(of: headerText) { _, newValue in
|
||||
if newValue.count > 65536 { headerText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cookies")
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.onChange(of: cookieText) { _, newValue in
|
||||
if newValue.count > 65536 { cookieText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
.onChange(of: mirrorText) { _, newValue in
|
||||
if newValue.count > 65536 { mirrorText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var requiredSpaceText: String {
|
||||
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
|
||||
guard knownBytes > 0 else { return "Unknown" }
|
||||
return ByteFormatter.string(knownBytes)
|
||||
}
|
||||
|
||||
private var unknownSizeCount: Int {
|
||||
pendingDownloads.filter { $0.sizeBytes == nil }.count
|
||||
}
|
||||
|
||||
private var freeSpaceText: String {
|
||||
guard let bytes = availableCapacity() else { return "Unknown" }
|
||||
return ByteFormatter.string(bytes)
|
||||
}
|
||||
|
||||
private var actionMessage: String {
|
||||
if pendingDownloads.isEmpty {
|
||||
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
return validationMessage
|
||||
}
|
||||
|
||||
if unknownSizeCount > 0 {
|
||||
return "Some servers did not report file size before download."
|
||||
}
|
||||
|
||||
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
|
||||
}
|
||||
|
||||
private var canAddDownloads: Bool {
|
||||
!pendingDownloads.isEmpty && validationMessage == nil
|
||||
}
|
||||
|
||||
private var validationMessage: String? {
|
||||
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Add the expected checksum digest, or turn checksum off."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
|
||||
return "Headers must use Name: Value lines."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
|
||||
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
|
||||
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
|
||||
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
|
||||
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
|
||||
)
|
||||
}
|
||||
|
||||
private var metadataRequestSignature: String {
|
||||
[
|
||||
headerText,
|
||||
cookieText,
|
||||
useAuthorization ? "auth" : "no-auth",
|
||||
authUsername,
|
||||
authPassword
|
||||
].joined(separator: "\u{1f}")
|
||||
}
|
||||
|
||||
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
|
||||
if useAuthorization {
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanUsername.isEmpty else { return nil }
|
||||
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
}
|
||||
|
||||
return settings.credentials(for: url)
|
||||
}
|
||||
|
||||
private func scheduleMetadataRefresh(for text: String) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
refreshMetadata(for: text, isAutoFetch: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingReferer() {
|
||||
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!referer.isEmpty,
|
||||
URL(string: referer) != nil else {
|
||||
controller.pendingReferer = nil
|
||||
return
|
||||
}
|
||||
|
||||
let refererHeader = "Referer: \(referer)"
|
||||
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
|
||||
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
|
||||
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? refererHeader
|
||||
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
}
|
||||
|
||||
private func refreshMetadata(for text: String, isAutoFetch: Bool) {
|
||||
let urls = DownloadURLParser.parse(text)
|
||||
metadataTask?.cancel()
|
||||
|
||||
pendingDownloads = urls.map { url in
|
||||
let isMedia = MediaDetector.isSupportedMedia(url: url)
|
||||
let fileName = FileClassifier.fileName(from: url)
|
||||
let category = FileClassifier.category(forFileName: fileName)
|
||||
return PendingDownload(
|
||||
url: url,
|
||||
fileName: fileName,
|
||||
category: category,
|
||||
defaultDirectory: settings.destinationDirectory(for: category),
|
||||
state: .loading,
|
||||
isMedia: isMedia
|
||||
)
|
||||
}
|
||||
|
||||
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
|
||||
useAuthorization = true
|
||||
authUsername = creds.username
|
||||
authPassword = creds.password
|
||||
saveLogin = false
|
||||
}
|
||||
|
||||
guard !urls.isEmpty else {
|
||||
metadataTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
metadataTask = Task {
|
||||
await withTaskGroup(of: PendingDownload.self) { group in
|
||||
for item in pendingDownloads {
|
||||
group.addTask {
|
||||
if item.isMedia {
|
||||
var fetchedItem = item
|
||||
do {
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: item.url,
|
||||
cookieSource: settings.mediaCookieSource,
|
||||
credentials: metadataCredentials(for: item.url),
|
||||
transferOptions: transferOptions,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration
|
||||
)
|
||||
fetchedItem.mediaMetadata = metadata
|
||||
fetchedItem.mediaOptions = options
|
||||
if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
|
||||
fetchedItem.selectedMediaOption = bestVideo
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
|
||||
fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
|
||||
}
|
||||
fetchedItem.state = .loaded
|
||||
} catch {
|
||||
fetchedItem.state = .failed(error.localizedDescription)
|
||||
}
|
||||
return fetchedItem
|
||||
} else {
|
||||
return await DownloadMetadataFetcher.fetch(
|
||||
for: item.url,
|
||||
settings: settings,
|
||||
credentials: metadataCredentials(for: item.url),
|
||||
transferOptions: transferOptions,
|
||||
isAutoFetch: isAutoFetch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await loadedItem in group {
|
||||
guard !Task.isCancelled else { break }
|
||||
await MainActor.run {
|
||||
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
|
||||
pendingDownloads[index] = loadedItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectDestination() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
if let currentURL = overrideDirectory {
|
||||
panel.directoryURL = currentURL
|
||||
}
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
destinationPath = url.path
|
||||
overrideDestination = true
|
||||
}
|
||||
}
|
||||
|
||||
private func addDownloads(start: Bool) {
|
||||
var conflicts: [DuplicateDownloadItem] = []
|
||||
for pending in pendingDownloads {
|
||||
let destURL = overrideDirectory ?? pending.defaultDirectory
|
||||
let destPath = destURL.appendingPathComponent(pending.fileName).path
|
||||
|
||||
if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
|
||||
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
|
||||
} else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
|
||||
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
|
||||
}
|
||||
}
|
||||
|
||||
if !conflicts.isEmpty {
|
||||
conflictingDownloads = conflicts
|
||||
pendingStartFlag = start
|
||||
showingDuplicates = true
|
||||
return
|
||||
}
|
||||
|
||||
executeAddDownloads(start: start)
|
||||
}
|
||||
|
||||
private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
|
||||
var finalDownloads = pendingDownloads
|
||||
|
||||
if let conflicts {
|
||||
for conflict in conflicts {
|
||||
guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
|
||||
switch conflict.resolution {
|
||||
case .skip:
|
||||
finalDownloads.remove(at: index)
|
||||
case .rename:
|
||||
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
|
||||
var newName = finalDownloads[index].fileName
|
||||
var count = 1
|
||||
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
|
||||
let ext = URL(fileURLWithPath: newName).pathExtension
|
||||
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) || finalDownloads.prefix(upTo: index).contains(where: { (overrideDirectory ?? $0.defaultDirectory) == destURL && $0.fileName == newName }) {
|
||||
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
|
||||
count += 1
|
||||
}
|
||||
finalDownloads[index].fileName = newName
|
||||
case .replace:
|
||||
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
|
||||
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
|
||||
guard URL(fileURLWithPath: path).standardized.path.hasPrefix(destURL.standardized.path) else { continue }
|
||||
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
|
||||
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
|
||||
} else if FileManager.default.fileExists(atPath: path) {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !finalDownloads.isEmpty else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
|
||||
|
||||
controller.addPendingDownloads(
|
||||
finalDownloads,
|
||||
connectionsPerServer: Int(connectionsPerServer),
|
||||
overrideDirectory: overrideDirectory,
|
||||
startImmediately: start,
|
||||
queueID: targetQueueID,
|
||||
credentials: explicitCredentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? {
|
||||
guard useAuthorization else { return nil }
|
||||
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanUsername.isEmpty else { return nil }
|
||||
|
||||
if saveLogin {
|
||||
var savedHosts = Set<String>()
|
||||
for url in urls {
|
||||
if let host = url.host, !savedHosts.contains(host) {
|
||||
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
|
||||
savedHosts.insert(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
}
|
||||
|
||||
private var overrideDirectory: URL? {
|
||||
guard overrideDestination else { return nil }
|
||||
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true)
|
||||
}
|
||||
|
||||
private func destinationDirectory(for item: PendingDownload) -> URL {
|
||||
overrideDirectory ?? item.defaultDirectory
|
||||
}
|
||||
|
||||
private func availableCapacity() -> Int64? {
|
||||
let urls = pendingDownloads.isEmpty
|
||||
? [settings.destinationDirectory(for: .other)]
|
||||
: pendingDownloads.map { destinationDirectory(for: $0) }
|
||||
|
||||
return urls.compactMap { url in
|
||||
let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [
|
||||
.volumeAvailableCapacityForImportantUsageKey,
|
||||
.volumeAvailableCapacityKey
|
||||
])
|
||||
if let important = values?.volumeAvailableCapacityForImportantUsage {
|
||||
return important
|
||||
}
|
||||
if let available = values?.volumeAvailableCapacity {
|
||||
return Int64(available)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
.min()
|
||||
}
|
||||
|
||||
private func existingVolumeURL(for url: URL) -> URL {
|
||||
var candidate = url
|
||||
while !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let parent = candidate.deletingLastPathComponent()
|
||||
if parent.path == candidate.path {
|
||||
return URL(fileURLWithPath: NSHomeDirectory())
|
||||
}
|
||||
candidate = parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func categoryColor(_ category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: .pink
|
||||
case .movies: .indigo
|
||||
case .compressed: .orange
|
||||
case .pictures: .teal
|
||||
case .documents: .blue
|
||||
case .other: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String
|
||||
let symbolName: String
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private struct CompactSummaryStrip: View {
|
||||
let metrics: [SummaryMetric]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(metrics) { metric in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: metric.symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.value)
|
||||
.font(.caption.weight(.semibold).monospacedDigit())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if metric.id != metrics.last?.id {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataStatusView: View {
|
||||
let state: PendingDownload.MetadataState
|
||||
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .pending:
|
||||
Label("Pending", systemImage: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
case .loading:
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking")
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
case .loaded:
|
||||
Label("Ready", systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.green)
|
||||
case .failed:
|
||||
Label("Unknown", systemImage: "exclamationmark.circle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
|
||||
var id = UUID()
|
||||
var urlPattern: String
|
||||
var username: String
|
||||
}
|
||||
|
||||
enum ProxyMode: String, Codable, CaseIterable, Sendable {
|
||||
case none
|
||||
case system
|
||||
case custom
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: "No proxy"
|
||||
case .system: "Use system proxy"
|
||||
case .custom: "Set proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
|
||||
case none = "None"
|
||||
case safari = "Safari"
|
||||
case chrome = "Chrome"
|
||||
case firefox = "Firefox"
|
||||
case edge = "Edge"
|
||||
case brave = "Brave"
|
||||
|
||||
var ytDlpBrowserName: String? {
|
||||
switch self {
|
||||
case .none:
|
||||
nil
|
||||
case .safari, .chrome, .firefox, .edge, .brave:
|
||||
rawValue.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
var statusTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
"Not using browser cookies"
|
||||
case .safari, .chrome, .firefox, .edge, .brave:
|
||||
"Using \(rawValue) cookies"
|
||||
}
|
||||
}
|
||||
|
||||
var statusDetail: String {
|
||||
switch self {
|
||||
case .none:
|
||||
"Restricted media may fail if the site requires login."
|
||||
case .safari:
|
||||
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
|
||||
case .chrome, .firefox, .edge, .brave:
|
||||
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProxyType: String, Codable, CaseIterable, Sendable {
|
||||
case http
|
||||
case https
|
||||
case ftp
|
||||
case socks5
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .http: "HTTP"
|
||||
case .https: "HTTPS (legacy)"
|
||||
case .ftp: "FTP (legacy)"
|
||||
case .socks5: "SOCKS5"
|
||||
}
|
||||
}
|
||||
|
||||
var uriScheme: String {
|
||||
switch self {
|
||||
case .http, .https, .ftp:
|
||||
"http"
|
||||
case .socks5:
|
||||
"socks5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProxySettings: Codable, Equatable, Sendable {
|
||||
var mode: ProxyMode = .none
|
||||
var type: ProxyType = .http
|
||||
var host = ""
|
||||
var port = 8080
|
||||
|
||||
var normalized: ProxySettings {
|
||||
var copy = self
|
||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
copy.port = min(max(copy.port, 1), 65_535)
|
||||
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 showNotifications: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var customUserAgent: String {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var playCompletionSound: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showDockBadge: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var maxAutomaticRetries: Int {
|
||||
didSet {
|
||||
let clamped = min(max(maxAutomaticRetries, 0), 10)
|
||||
if maxAutomaticRetries != clamped {
|
||||
maxAutomaticRetries = clamped
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var proxySettings: ProxySettings {
|
||||
didSet {
|
||||
let normalized = proxySettings.normalized
|
||||
if proxySettings != normalized {
|
||||
proxySettings = normalized
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var downloadDirectories: [DownloadCategory: String] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var siteLogins: [SiteLogin] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var mediaCookieSource: BrowserCookieSource {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var extensionPairingToken: String {
|
||||
didSet {
|
||||
if isKeychainAccessGranted {
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var isKeychainAccessGranted: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showKeychainPrimer = false
|
||||
|
||||
@Published var askWhereToSaveEachFile: Bool {
|
||||
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
|
||||
|
||||
let granted: Bool
|
||||
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
|
||||
showNotifications = stored.showNotifications ?? true
|
||||
playCompletionSound = stored.playCompletionSound ?? true
|
||||
showDockBadge = stored.showDockBadge ?? true
|
||||
customUserAgent = stored.customUserAgent ?? ""
|
||||
maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
|
||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||
siteLogins = stored.siteLogins
|
||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
granted = stored.isKeychainAccessGranted ?? false
|
||||
isKeychainAccessGranted = granted
|
||||
askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? false
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
listRowDensity = .standard
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
showNotifications = true
|
||||
playCompletionSound = true
|
||||
showDockBadge = true
|
||||
customUserAgent = ""
|
||||
maxAutomaticRetries = 3
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
mediaCookieSource = .none
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
granted = false
|
||||
isKeychainAccessGranted = granted
|
||||
askWhereToSaveEachFile = false
|
||||
}
|
||||
|
||||
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
var execHash = "unknown"
|
||||
if let execPath = Bundle.main.executablePath,
|
||||
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
|
||||
let modDate = attr[.modificationDate] as? Date {
|
||||
execHash = String(modDate.timeIntervalSince1970)
|
||||
}
|
||||
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
|
||||
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
|
||||
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
|
||||
|
||||
var needsPrimer = false
|
||||
if granted {
|
||||
if let lastVersion, lastVersion != fullVersion {
|
||||
needsPrimer = true
|
||||
}
|
||||
}
|
||||
|
||||
if granted {
|
||||
if needsPrimer {
|
||||
showKeychainPrimer = true
|
||||
extensionPairingToken = ""
|
||||
} else {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
extensionPairingToken = ""
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||
downloadDirectories[category] = Self.defaultDirectory(for: category).path
|
||||
}
|
||||
}
|
||||
|
||||
func destinationDirectory(for category: DownloadCategory) -> URL {
|
||||
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
|
||||
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
|
||||
}
|
||||
|
||||
func setDirectory(_ path: String, for category: DownloadCategory) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
|
||||
}
|
||||
|
||||
func resetDirectories() {
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
}
|
||||
|
||||
var downloadProxyConfiguration: DownloadProxyConfiguration {
|
||||
DownloadProxyConfiguration(
|
||||
mode: proxySettings.mode,
|
||||
customProxyURI: proxySettings.customProxyURI
|
||||
)
|
||||
}
|
||||
|
||||
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
||||
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
|
||||
}
|
||||
|
||||
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
|
||||
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !pattern.isEmpty, !cleanUsername.isEmpty else {
|
||||
message = "Add a URL pattern and username."
|
||||
return
|
||||
}
|
||||
|
||||
if let id,
|
||||
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
|
||||
message = "A login for \(pattern) already exists."
|
||||
return
|
||||
}
|
||||
|
||||
if let index = siteLogins.firstIndex(where: { login in
|
||||
if let id {
|
||||
return login.id == id
|
||||
}
|
||||
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
|
||||
}) {
|
||||
let loginID = siteLogins[index].id
|
||||
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins[index].urlPattern = pattern
|
||||
siteLogins[index].username = cleanUsername
|
||||
message = "Updated login for \(pattern)."
|
||||
return
|
||||
}
|
||||
|
||||
guard !password.isEmpty else {
|
||||
message = "Add a password."
|
||||
return
|
||||
}
|
||||
|
||||
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
||||
if !KeychainCredentialStore.setPassword(password, for: login.id) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins.append(login)
|
||||
message = "Added login for \(pattern)."
|
||||
}
|
||||
|
||||
func deleteSiteLogins(at offsets: IndexSet) {
|
||||
for offset in offsets {
|
||||
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
|
||||
}
|
||||
siteLogins.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
func credentials(for url: URL) -> DownloadCredentials? {
|
||||
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
|
||||
let password = KeychainCredentialStore.password(for: login.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func credentials(for login: SiteLogin) -> DownloadCredentials? {
|
||||
guard let password = KeychainCredentialStore.password(for: login.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func grantKeychainAccess() {
|
||||
isKeychainAccessGranted = true
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
|
||||
func revokeKeychainAccess() {
|
||||
KeychainCredentialStore.deleteExtensionToken()
|
||||
for login in siteLogins {
|
||||
KeychainCredentialStore.deletePassword(for: login.id)
|
||||
}
|
||||
siteLogins.removeAll()
|
||||
extensionPairingToken = ""
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
|
||||
func resolveKeychainPrimer(grantAccess: Bool) {
|
||||
showKeychainPrimer = false
|
||||
if grantAccess {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
} else {
|
||||
revokeKeychainAccess()
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredSettings(
|
||||
appTheme: appTheme,
|
||||
appFontSize: appFontSize,
|
||||
listRowDensity: listRowDensity,
|
||||
perServerConnections: perServerConnections,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||
showNotifications: showNotifications,
|
||||
playCompletionSound: playCompletionSound,
|
||||
showDockBadge: showDockBadge,
|
||||
customUserAgent: customUserAgent,
|
||||
maxAutomaticRetries: maxAutomaticRetries,
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins,
|
||||
mediaCookieSource: mediaCookieSource,
|
||||
isKeychainAccessGranted: isKeychainAccessGranted,
|
||||
askWhereToSaveEachFile: askWhereToSaveEachFile
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [defaults, storageKey] in
|
||||
let data = await Task.detached(priority: .background) {
|
||||
try? JSONEncoder().encode(stored)
|
||||
}.value
|
||||
|
||||
guard !Task.isCancelled, let encoded = data else { return }
|
||||
defaults.set(encoded, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
|
||||
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !pattern.isEmpty else { return false }
|
||||
|
||||
let host = (url.host(percentEncoded: false) ?? "").lowercased()
|
||||
let absolute = url.absoluteString.lowercased()
|
||||
let normalizedPattern = URL(string: pattern)?.host ?? pattern
|
||||
|
||||
if normalizedPattern.hasPrefix("*.") {
|
||||
let suffix = String(normalizedPattern.dropFirst(2))
|
||||
return host == suffix || host.hasSuffix(".\(suffix)")
|
||||
}
|
||||
|
||||
if normalizedPattern.contains("*") {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
|
||||
.replacingOccurrences(of: "\\*", with: ".*")
|
||||
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
if normalizedPattern.contains("/") {
|
||||
return absolute.contains(normalizedPattern)
|
||||
}
|
||||
|
||||
return host == normalizedPattern
|
||||
}
|
||||
|
||||
private static func generateSecureToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
return UUID().uuidString
|
||||
}
|
||||
return Data(bytes).base64EncodedString()
|
||||
}
|
||||
|
||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
||||
}
|
||||
|
||||
private static func defaultDirectory(for category: DownloadCategory) -> URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
|
||||
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
|
||||
guard let category = DownloadCategory(rawValue: key) else { return nil }
|
||||
return (category, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredSettings: Codable {
|
||||
var appTheme: AppTheme?
|
||||
var appFontSize: AppFontSize?
|
||||
var listRowDensity: ListRowDensity?
|
||||
var perServerConnections: Int
|
||||
var maxConcurrentDownloads: Int?
|
||||
var globalSpeedLimitKiBPerSecond: Int?
|
||||
var preventsSleepWhileDownloading: Bool
|
||||
var showNotifications: Bool?
|
||||
var playCompletionSound: Bool?
|
||||
var showDockBadge: Bool?
|
||||
var customUserAgent: String?
|
||||
var maxAutomaticRetries: Int?
|
||||
var proxySettings: ProxySettings?
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
var mediaCookieSource: BrowserCookieSource?
|
||||
var isKeychainAccessGranted: Bool?
|
||||
var askWhereToSaveEachFile: Bool?
|
||||
}
|
||||
@@ -1,713 +0,0 @@
|
||||
import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine: Sendable {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
let rpcSecret: String
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> (UInt16, Int32)? {
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
addr.sin_port = 0
|
||||
|
||||
let sock = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard sock >= 0 else { return nil }
|
||||
|
||||
var addrPtr = addr
|
||||
let bindResult = withUnsafePointer(to: &addrPtr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
bind(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
|
||||
guard bindResult == 0 else {
|
||||
close(sock)
|
||||
return nil
|
||||
}
|
||||
|
||||
var boundAddr = sockaddr_in()
|
||||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getsockname(sock, $0, &len)
|
||||
}
|
||||
}
|
||||
|
||||
guard getsocknameResult == 0 else {
|
||||
close(sock)
|
||||
return nil
|
||||
}
|
||||
|
||||
let port = UInt16(bigEndian: boundAddr.sin_port)
|
||||
return (port, sock)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case executableNotFound
|
||||
case launchFailed(String)
|
||||
case unsupportedProxy(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .executableNotFound:
|
||||
"The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines."
|
||||
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? {
|
||||
bundledResource(named: "aria2c", executable: true)
|
||||
}
|
||||
|
||||
static func certificateBundleURL() -> URL? {
|
||||
bundledResource(named: "aria2-cacert.pem", executable: false)
|
||||
}
|
||||
|
||||
private static func bundledResource(named name: String, executable: Bool) -> URL? {
|
||||
func validResource(in bundle: Bundle) -> URL? {
|
||||
guard let url = bundle.resourceURL?.appendingPathComponent(name) else { return nil }
|
||||
let isValid = executable
|
||||
? FileManager.default.isExecutableFile(atPath: url.path)
|
||||
: FileManager.default.fileExists(atPath: url.path)
|
||||
return isValid ? url : nil
|
||||
}
|
||||
|
||||
if let bundled = validResource(in: .main) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||
#if SWIFT_PACKAGE
|
||||
return validResource(in: .module)
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
) async throws -> Handle {
|
||||
guard let executableURL else {
|
||||
throw EngineError.executableNotFound
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: item.destinationDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
var lastError: Error?
|
||||
|
||||
for _ in 1...5 {
|
||||
guard let (rpcPortVal, portSocket) = Self.findFreePort() else {
|
||||
lastError = EngineError.launchFailed("Could not find free port")
|
||||
continue
|
||||
}
|
||||
let rpcPort = Int(rpcPortVal)
|
||||
let rpcSecret = UUID().uuidString
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
|
||||
|
||||
final class CleanupState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didCleanup = false
|
||||
func cleanup(tempDir: URL) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if !didCleanup {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
didCleanup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupState = CleanupState()
|
||||
let cleanupTempDir: @Sendable () -> Void = {
|
||||
cleanupState.cleanup(tempDir: tempDir)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
close(portSocket)
|
||||
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
|
||||
let confURL = tempDir.appendingPathComponent("aria2.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 {
|
||||
close(portSocket)
|
||||
lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
|
||||
do {
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
} catch {
|
||||
close(portSocket)
|
||||
lastError = error
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
cleanupTempDir()
|
||||
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)))
|
||||
}
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
close(portSocket)
|
||||
try process.run()
|
||||
if let input = inputFileContent(for: item).data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(input)
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
didThrow = true
|
||||
lastError = EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
if didThrow {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
cleanupTempDir()
|
||||
continue
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
if !process.isRunning {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
|
||||
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
|
||||
cleanupTempDir()
|
||||
continue
|
||||
}
|
||||
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
|
||||
// But the terminationHandler will hit completionGate, so we just return the handle.
|
||||
}
|
||||
|
||||
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 {
|
||||
ProcessTreeTerminator.terminate(process)
|
||||
}
|
||||
cleanupTempDir()
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
|
||||
}
|
||||
|
||||
private static func monitorCompletion(
|
||||
rpcPort: Int,
|
||||
rpcSecret: String,
|
||||
process: Process,
|
||||
completionGate: CompletionGate<Void>
|
||||
) -> Task<Void, Never> {
|
||||
Task.detached {
|
||||
while !Task.isCancelled && process.isRunning {
|
||||
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionGate.complete(.success(()))
|
||||
if process.isRunning {
|
||||
ProcessTreeTerminator.terminate(process)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
if let certificateBundleURL = Self.certificateBundleURL() {
|
||||
arguments.append("--ca-certificate=\(certificateBundleURL.path)")
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||
return arguments
|
||||
}
|
||||
|
||||
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
|
||||
switch configuration.mode {
|
||||
case .none:
|
||||
return clearedProxyArguments()
|
||||
case .system:
|
||||
switch systemProxyResolution(for: item.url) {
|
||||
case .direct:
|
||||
return clearedProxyArguments()
|
||||
case .proxy(let proxyURI):
|
||||
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
|
||||
case .unsupported(let message):
|
||||
throw EngineError.unsupportedProxy(message)
|
||||
}
|
||||
case .custom:
|
||||
guard let proxyURI = configuration.customProxyURI else { return [] }
|
||||
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
|
||||
}
|
||||
}
|
||||
|
||||
private func clearedProxyArguments() -> [String] {
|
||||
[
|
||||
"--all-proxy=",
|
||||
"--http-proxy=",
|
||||
"--https-proxy=",
|
||||
"--ftp-proxy="
|
||||
]
|
||||
}
|
||||
|
||||
private func proxyArgumentName(for urlScheme: String?) -> String {
|
||||
switch urlScheme?.lowercased() {
|
||||
case "http": "--http-proxy"
|
||||
case "https": "--https-proxy"
|
||||
case "ftp": "--ftp-proxy"
|
||||
default: "--all-proxy"
|
||||
}
|
||||
}
|
||||
|
||||
private enum SystemProxyResolution {
|
||||
case direct
|
||||
case proxy(String)
|
||||
case unsupported(String)
|
||||
}
|
||||
|
||||
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
|
||||
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
|
||||
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
|
||||
return .direct
|
||||
}
|
||||
|
||||
for proxy in proxies {
|
||||
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
|
||||
if type == kCFProxyTypeNone as String {
|
||||
return .direct
|
||||
}
|
||||
if type == kCFProxyTypeSOCKS as String {
|
||||
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
|
||||
}
|
||||
if type == kCFProxyTypeAutoConfigurationURL as String ||
|
||||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
|
||||
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
|
||||
}
|
||||
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
|
||||
return .proxy(uri)
|
||||
}
|
||||
}
|
||||
|
||||
return .direct
|
||||
}
|
||||
|
||||
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
|
||||
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
|
||||
!host.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
|
||||
let scheme: String
|
||||
if type == kCFProxyTypeHTTPS as String {
|
||||
scheme = "https"
|
||||
} else if type == kCFProxyTypeFTP as String {
|
||||
scheme = "ftp"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
guard let port else {
|
||||
return "\(scheme)://\(host)"
|
||||
}
|
||||
return "\(scheme)://\(host):\(port)"
|
||||
}
|
||||
|
||||
private func inputFileContent(for item: DownloadItem) -> String {
|
||||
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||
let urls = ([item.url] + (item.mirrorURLs ?? []))
|
||||
.map { sanitizedOptionValue($0.absoluteString) }
|
||||
.joined(separator: "\t")
|
||||
var lines = [
|
||||
urls,
|
||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||
" out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
|
||||
" split=\(connections)",
|
||||
" max-connection-per-server=\(connections)"
|
||||
]
|
||||
|
||||
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
|
||||
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
|
||||
}
|
||||
|
||||
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
|
||||
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
|
||||
}
|
||||
|
||||
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
|
||||
}
|
||||
|
||||
if let credentials = item.credentials, !credentials.isEmpty {
|
||||
let scheme = item.url.scheme?.lowercased()
|
||||
if scheme == "ftp" || scheme == "sftp" {
|
||||
lines.append(" ftp-user=\(sanitizedOptionValue(credentials.username))")
|
||||
lines.append(" ftp-passwd=\(sanitizedOptionValue(credentials.password))")
|
||||
} else {
|
||||
lines.append(" http-user=\(sanitizedOptionValue(credentials.username))")
|
||||
lines.append(" http-passwd=\(sanitizedOptionValue(credentials.password))")
|
||||
lines.append(" http-auth-challenge=true")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
private func sanitizedOptionValue(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = Data()
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxBytes: Int = 512 * 1024) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
lock.withLock { storage }
|
||||
}
|
||||
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionGate<Success>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didComplete = false
|
||||
private let completion: @Sendable (Result<Success, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func complete(_ result: Result<Success, Error>) {
|
||||
lock.lock()
|
||||
let shouldComplete = !didComplete
|
||||
if shouldComplete {
|
||||
didComplete = true
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard shouldComplete else { return }
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionMonitor: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func set(_ task: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
self.task = task
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
let task = self.task
|
||||
self.task = nil
|
||||
lock.unlock()
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
final class Aria2ProgressParser: @unchecked Sendable {
|
||||
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
|
||||
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
|
||||
private let speedRegex = try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)
|
||||
private let etaRegex = try? NSRegularExpression(pattern: #"ETA:([^\s\]]+)"#)
|
||||
private let bytesRegex = try? NSRegularExpression(pattern: #"\s([0-9.]+(?:KiB|MiB|GiB|TiB|B)?/[0-9.]+(?:KiB|MiB|GiB|TiB|B)?)\("#)
|
||||
|
||||
func parse(_ text: String) -> [DownloadProgress] {
|
||||
text
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.compactMap { parseLine(String($0)) }
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> DownloadProgress? {
|
||||
guard line.contains("%") else { return nil }
|
||||
|
||||
let percentage = firstCapture(in: line, regex: percentageRegex).flatMap(Double.init) ?? 0
|
||||
let connections = firstCapture(in: line, regex: connectionRegex).flatMap(Int.init) ?? 0
|
||||
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
|
||||
let bytes = firstCapture(in: line, regex: bytesRegex) ?? "-"
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(percentage / 100, 0), 1),
|
||||
bytesText: bytes,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: connections
|
||||
)
|
||||
}
|
||||
|
||||
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
|
||||
guard let regex else { return nil }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
guard let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "github-mark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "MenuBarIconTemplate.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChunkMapView: View {
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var bitfield: String = ""
|
||||
@State private var numPieces: Int = 0
|
||||
@State private var pollTask: Task<Void, Never>?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if numPieces > 0 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
|
||||
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await fetchStatus(port: port, secret: secret)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStatus(port: Int, secret: String) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.tellActive",
|
||||
"id": "1",
|
||||
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
request.httpBody = data
|
||||
|
||||
do {
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
let active = result.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedBitfield = active["bitfield"] as? String ?? ""
|
||||
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
|
||||
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
|
||||
|
||||
await MainActor.run {
|
||||
self.bitfield = fetchedBitfield
|
||||
self.numPieces = fetchedNumPieces
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
let boxSize: CGFloat = 10
|
||||
let spacing: CGFloat = 2
|
||||
let cornerSize = CGSize(width: 2, height: 2)
|
||||
let width = size.width
|
||||
|
||||
let x: CGFloat = 0
|
||||
let y: CGFloat = 0
|
||||
|
||||
let completedPath = Path { p in
|
||||
var cx = x
|
||||
var cy = y
|
||||
for piece in itemPieces {
|
||||
if piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPath = Path { p in
|
||||
var cx: CGFloat = 0
|
||||
var cy: CGFloat = 0
|
||||
for piece in itemPieces {
|
||||
if !piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
|
||||
context.fill(completedPath, with: .color(Color.accentColor))
|
||||
}
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
}
|
||||
@@ -1,336 +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")
|
||||
}
|
||||
.onChange(of: controller.activeCount) { _, newCount in
|
||||
updateDockBadge(count: newCount, show: settings.showDockBadge)
|
||||
}
|
||||
.onChange(of: settings.showDockBadge) { _, show in
|
||||
updateDockBadge(count: controller.activeCount, show: show)
|
||||
}
|
||||
.onAppear {
|
||||
updateDockBadge(count: controller.activeCount, show: settings.showDockBadge)
|
||||
}
|
||||
.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 {
|
||||
let newText = url.absoluteString
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + newText
|
||||
} else {
|
||||
controller.pendingPasteboardText = newText
|
||||
}
|
||||
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 {
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + text
|
||||
} else {
|
||||
controller.pendingPasteboardText = text
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sheet(isPresented: $settings.showKeychainPrimer) {
|
||||
KeychainPrimerView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
} label: {
|
||||
Label("Add Download", systemImage: "plus")
|
||||
}
|
||||
.help("Add a new download")
|
||||
|
||||
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 ? "Resume All" : "Resume", systemImage: "play.fill")
|
||||
}
|
||||
.help(selectedItems.isEmpty ? "Resume all downloads" : "Resume selected downloads")
|
||||
.disabled(!canStart)
|
||||
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading || $0.status == .queued })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading || item.status == .queued {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Pause All" : "Pause", systemImage: "pause.fill")
|
||||
}
|
||||
.help(selectedItems.isEmpty ? "Pause all active downloads" : "Pause selected downloads")
|
||||
.disabled(!canStop)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
Button("") {
|
||||
if !selection.isEmpty {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
handlePaste(queueID: queueID)
|
||||
}
|
||||
.keyboardShortcut("v", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
selectAll(items: items)
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
if let item = selectedItems.first {
|
||||
performPrimaryAction(for: item)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
}
|
||||
.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 func updateDockBadge(count: Int, show: Bool) {
|
||||
if show && count > 0 {
|
||||
NSApp.dockTile.badgeLabel = "\(count)"
|
||||
} else {
|
||||
NSApp.dockTile.badgeLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedItems: [DownloadItem] {
|
||||
controller.downloads.filter { selection.contains($0.id) }
|
||||
}
|
||||
|
||||
private func deleteSelected(deleteFiles: Bool) {
|
||||
for item in selectedItems {
|
||||
controller.delete(item, deleteFiles: deleteFiles)
|
||||
}
|
||||
selection.removeAll()
|
||||
}
|
||||
|
||||
private func handlePaste(queueID: UUID?) {
|
||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
|
||||
private func selectAll(items: [DownloadItem]) {
|
||||
selection = Set(items.map { $0.id })
|
||||
}
|
||||
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
if item.status == .completed {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
|
||||
} else {
|
||||
openWindow(id: "download-properties", value: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
|
||||
}
|
||||
|
||||
return controller.activeCount > 0
|
||||
}
|
||||
|
||||
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.queueItems(for: queueID).contains { $0.status == .queued }
|
||||
}
|
||||
|
||||
return controller.queuedCount > 0
|
||||
}
|
||||
|
||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||
switch filter {
|
||||
case .all:
|
||||
controller.downloads
|
||||
case .queued:
|
||||
controller.downloads.filter { $0.status == .queued }
|
||||
case .active:
|
||||
controller.downloads.filter { $0.status == .downloading }
|
||||
case .completed:
|
||||
controller.downloads.filter { $0.status == .completed }
|
||||
case .unfinished:
|
||||
controller.downloads.filter { $0.status != .completed }
|
||||
case .category(let category):
|
||||
controller.downloads.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainPrimerView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "lock.shield")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Security Update")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: true)
|
||||
} label: {
|
||||
Text("Grant Secure Access")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: false)
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadURLParser {
|
||||
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
|
||||
static func parse(_ text: String) -> [URL] {
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
||||
|
||||
let tokenized = text
|
||||
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")))
|
||||
.compactMap { token -> URL? in
|
||||
let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()"))
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return (detected + tokenized).filter { url in
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||
return false
|
||||
}
|
||||
return seen.insert(url.absoluteString).inserted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DownloadMetadataFetcher {
|
||||
static func fetch(
|
||||
for url: URL,
|
||||
settings: AppSettings,
|
||||
credentials: DownloadCredentials? = nil,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
||||
isAutoFetch: Bool = false
|
||||
) async -> PendingDownload {
|
||||
let initialName = FileClassifier.fileName(from: url)
|
||||
let initialCategory = FileClassifier.category(forFileName: initialName)
|
||||
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
|
||||
var pending = PendingDownload(
|
||||
url: url,
|
||||
fileName: initialName,
|
||||
category: initialCategory,
|
||||
defaultDirectory: initialDirectory,
|
||||
state: .loading
|
||||
)
|
||||
|
||||
guard url.scheme?.lowercased().hasPrefix("http") == true else {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
|
||||
if let host = url.host {
|
||||
let isPrivate = await Task.detached {
|
||||
isPrivateHost(host)
|
||||
}.value
|
||||
if isPrivate {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 12
|
||||
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
|
||||
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
|
||||
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
|
||||
let token = "\(credentials.username):\(credentials.password)"
|
||||
.data(using: .utf8)?
|
||||
.base64EncodedString()
|
||||
if let token {
|
||||
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
for header in normalizedHeaders {
|
||||
request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
|
||||
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
||||
let fileName = fileName(fromContentDisposition: disposition) {
|
||||
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
||||
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
||||
}
|
||||
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let bytes = Int64(contentLength) {
|
||||
pending.sizeBytes = bytes
|
||||
} else if response.expectedContentLength > 0 {
|
||||
pending.sizeBytes = response.expectedContentLength
|
||||
}
|
||||
|
||||
pending.mimeType = httpResponse.mimeType
|
||||
pending.state = .loaded
|
||||
} catch {
|
||||
pending.state = .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
private static func fileName(fromContentDisposition header: String) -> String? {
|
||||
let parts = header.components(separatedBy: ";")
|
||||
for part in parts {
|
||||
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("filename*="),
|
||||
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
|
||||
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
|
||||
if trimmed.lowercased().hasPrefix("filename=") {
|
||||
return String(trimmed.dropFirst("filename=".count))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isPrivateHost(_ host: String) -> Bool {
|
||||
let h = host.lowercased()
|
||||
if h == "localhost" || h.hasSuffix(".local") { return true }
|
||||
if !h.contains(".") && !h.contains(":") { return true }
|
||||
|
||||
var hints = addrinfo(
|
||||
ai_flags: 0,
|
||||
ai_family: AF_UNSPEC,
|
||||
ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_canonname: nil,
|
||||
ai_addr: nil,
|
||||
ai_next: nil
|
||||
)
|
||||
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
if getaddrinfo(host, nil, &hints, &res) == 0 {
|
||||
var current = res
|
||||
while let info = current {
|
||||
let family = info.pointee.ai_family
|
||||
if family == AF_INET {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
|
||||
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
|
||||
let first = (ip >> 24) & 0xFF
|
||||
let second = (ip >> 16) & 0xFF
|
||||
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
} else if family == AF_INET6 {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
|
||||
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
|
||||
|
||||
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
|
||||
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
|
||||
|
||||
let isULA = (bytes.0 & 0xFE) == 0xFC
|
||||
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
|
||||
|
||||
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
|
||||
|
||||
if isLoopback || isULA || isLinkLocal {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if isIPv4Mapped {
|
||||
let first = bytes.12
|
||||
let second = bytes.13
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
current = info.pointee.ai_next
|
||||
}
|
||||
freeaddrinfo(res)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum ByteFormatter {
|
||||
static func string(_ bytes: Int64?) -> String {
|
||||
guard let bytes else { return "Unknown" }
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.includesCount = true
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
@@ -1,491 +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)
|
||||
let storedPassword = KeychainCredentialStore.password(for: item.id) ?? credentials.password
|
||||
_password = State(initialValue: storedPassword)
|
||||
} 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,433 +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>?
|
||||
@State private var sortedItems: [DownloadItem] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("\(items.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(Capsule())
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
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.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
TableColumn("File Name", value: \.fileName) { item in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor(for: item.category))
|
||||
.frame(width: 22)
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
TableColumn("Size", value: \.sortableSize) { item in
|
||||
if let size = item.sizeBytes, size > 0 {
|
||||
Text(ByteFormatter.string(size))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
|
||||
Text(item.bytesText)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text("Unknown")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
combinedStatusCell(for: item)
|
||||
}
|
||||
.width(min: 160, ideal: 200)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
if item.status == .downloading {
|
||||
formattedSpeedCell(for: item.displaySpeedText)
|
||||
} else {
|
||||
Text("-")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
if item.status == .downloading {
|
||||
formattedETACell(for: item.displayETAText)
|
||||
} else {
|
||||
Text("-")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.animation(.default, value: sortedItems)
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
for target in targetItems {
|
||||
if target.status == .completed {
|
||||
openFile(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { sortedItems = items.sorted(using: sortOrder) }
|
||||
.onChange(of: items) { _, newItems in
|
||||
let existingIDs = Set(sortedItems.map(\.id))
|
||||
let newIDs = Set(newItems.map(\.id))
|
||||
if existingIDs != newIDs {
|
||||
sortedItems = newItems.sorted(using: sortOrder)
|
||||
} else {
|
||||
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
|
||||
sortedItems = sortedItems.compactMap { itemsDict[$0.id] }
|
||||
}
|
||||
}
|
||||
.onChange(of: sortOrder) { _, newOrder in
|
||||
sortedItems = items.sorted(using: newOrder)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Download",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteItems != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: pendingDeleteItems
|
||||
) { ids in
|
||||
Button("Remove from List") {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: false) }
|
||||
selection.subtract(ids)
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Move to Trash", role: .destructive) {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: true) }
|
||||
selection.subtract(ids)
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
} message: { ids in
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
if items.allSatisfy({ $0.status == .completed }) {
|
||||
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink, or also move the downloaded files to Trash.")
|
||||
} else {
|
||||
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial files there.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func combinedStatusCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: item.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(statusColor(for: item.status))
|
||||
|
||||
if item.status == .downloading {
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 35, alignment: .trailing)
|
||||
} else {
|
||||
Text(item.status.rawValue.capitalized)
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSpeed(_ text: String) -> [String] {
|
||||
var display = text
|
||||
|
||||
if let index = display.firstIndex(where: { $0.isLetter }) {
|
||||
if display.distance(from: display.startIndex, to: index) > 0 {
|
||||
let prevIndex = display.index(before: index)
|
||||
if display[prevIndex] != " " {
|
||||
display.insert(" ", at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return display.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formattedSpeedCell(for text: String) -> some View {
|
||||
let components = parseSpeed(text)
|
||||
if components.count == 2 {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 1) {
|
||||
Text(components[0])
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
Text(components[1])
|
||||
.font(.system(size: 10, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text(components.joined(separator: " "))
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formattedETACell(for text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
if !targetItems.isEmpty {
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "magnifyingglass")
|
||||
}
|
||||
|
||||
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("Resume", 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("Pause", systemImage: "pause.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")
|
||||
}
|
||||
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let paths = targetItems.map { $0.destinationPath }.joined(separator: "\n")
|
||||
if !paths.isEmpty {
|
||||
NSPasteboard.general.setString(paths, forType: .string)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy File Paths" : "Copy File Path", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = itemIDs
|
||||
} label: {
|
||||
Label("Remove from List", systemImage: "trash")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(id: "download-properties", value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
openWindow(id: "add-downloads")
|
||||
} label: {
|
||||
Label("Add Download", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private func showInFinder(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
|
||||
} else {
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
private func existingFolder(for url: URL) -> URL {
|
||||
var candidate = url
|
||||
while !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let parent = candidate.deletingLastPathComponent()
|
||||
if parent.path == candidate.path {
|
||||
return URL(fileURLWithPath: NSHomeDirectory())
|
||||
}
|
||||
candidate = parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func openFile(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.open(fileURL)
|
||||
} else {
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DuplicateDownloadItem: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var pendingItem: PendingDownload
|
||||
var resolution: DuplicateResolutionAction = .rename
|
||||
let reason: DuplicateReason
|
||||
|
||||
enum DuplicateReason: Equatable {
|
||||
case existingURL(String)
|
||||
case existingFile(String)
|
||||
}
|
||||
}
|
||||
|
||||
enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
|
||||
case rename = "Rename"
|
||||
case replace = "Replace"
|
||||
case skip = "Skip"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct DuplicateResolutionView: View {
|
||||
@Binding var conflicts: [DuplicateDownloadItem]
|
||||
let onConfirm: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Duplicate Downloads Detected")
|
||||
.font(.headline)
|
||||
Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Divider()
|
||||
|
||||
List($conflicts) { $conflict in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(conflict.pendingItem.fileName)
|
||||
.lineLimit(1)
|
||||
.font(.body.weight(.medium))
|
||||
Text(reasonText(for: conflict.reason))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("", selection: $conflict.resolution) {
|
||||
ForEach(DuplicateResolutionAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 100)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(minHeight: 200)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button("Cancel", action: onCancel)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Continue", action: onConfirm)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
|
||||
private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
|
||||
switch reason {
|
||||
case .existingURL(let msg): return msg
|
||||
case .existingFile(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum FileClassifier {
|
||||
private static let musicExtensions: Set<String> = [
|
||||
"aac", "aif", "aiff", "alac", "amr", "ape", "au", "caf", "flac", "m4a",
|
||||
"m4b", "mid", "midi", "mp3", "oga", "ogg", "opus", "ra", "wav", "weba",
|
||||
"wma"
|
||||
]
|
||||
|
||||
private static let movieExtensions: Set<String> = [
|
||||
"3g2", "3gp", "avi", "divx", "f4v", "flv", "m2ts", "m4v", "mkv", "mov",
|
||||
"mp4", "mpeg", "mpg", "mts", "ogm", "ogv", "rm", "rmvb", "ts", "vob",
|
||||
"webm", "wmv"
|
||||
]
|
||||
|
||||
private static let compressedExtensions: Set<String> = [
|
||||
"7z", "ace", "alz", "apk", "appx", "ar", "arc", "arj", "bz", "bz2",
|
||||
"cab", "cpio", "deb", "dmg", "gz", "gzip", "iso", "jar", "lha", "lzh",
|
||||
"lz", "lz4", "lzip", "lzma", "pak", "pkg", "rar", "rpm", "sit", "sitx",
|
||||
"tar", "tbz", "tbz2", "tgz", "tlz", "txz", "war", "whl", "xar", "xz",
|
||||
"z", "zip", "zipx", "zst"
|
||||
]
|
||||
|
||||
private static let pictureExtensions: Set<String> = [
|
||||
"ai", "apng", "avif", "bmp", "cr2", "cr3", "dng", "emf", "eps", "gif",
|
||||
"heic", "heif", "ico", "indd", "jfif", "jpeg", "jpg", "jxl", "nef",
|
||||
"orf", "pbm", "pgm", "png", "pnm", "ppm", "psd", "raw", "rw2",
|
||||
"svg", "tga", "tif", "tiff", "webp", "wmf"
|
||||
]
|
||||
|
||||
private static let documentExtensions: Set<String> = [
|
||||
"azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx",
|
||||
"epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf",
|
||||
"numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx",
|
||||
"ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm",
|
||||
"xlsx", "xml", "xps", "yaml", "yml"
|
||||
]
|
||||
|
||||
static func category(forFileName fileName: String) -> DownloadCategory {
|
||||
let ext = (fileName as NSString).pathExtension.lowercased()
|
||||
guard !ext.isEmpty else { return .other }
|
||||
|
||||
if musicExtensions.contains(ext) { return .musics }
|
||||
if movieExtensions.contains(ext) { return .movies }
|
||||
if compressedExtensions.contains(ext) { return .compressed }
|
||||
if pictureExtensions.contains(ext) { return .pictures }
|
||||
if documentExtensions.contains(ext) { return .documents }
|
||||
return .other
|
||||
}
|
||||
|
||||
static func destinationDirectory(for category: DownloadCategory) -> URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
static func fileName(from url: URL) -> String {
|
||||
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
||||
if !pathName.isEmpty, pathName != "/" {
|
||||
return sanitizedFileName(pathName)
|
||||
}
|
||||
|
||||
let host = url.host(percentEncoded: false) ?? "download"
|
||||
return sanitizedFileName("\(host)-download")
|
||||
}
|
||||
|
||||
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
|
||||
let separators = CharacterSet(charactersIn: "/\\")
|
||||
let invalidCharacters = separators
|
||||
.union(.newlines)
|
||||
.union(.controlCharacters)
|
||||
|
||||
let components = rawValue
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.filter { !$0.isEmpty }
|
||||
let clean = components.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !clean.isEmpty, clean != ".", clean != ".." else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return String(clean.prefix(255))
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FirelinkApp: App {
|
||||
@StateObject private var updateChecker: ReleaseUpdateChecker
|
||||
@StateObject private var settings: AppSettings
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
@State private var lastURLSchemeInvocation: Date = .distantPast
|
||||
@State private var urlSchemeInvocationCount: Int = 0
|
||||
|
||||
// Server must be retained to keep listening
|
||||
private let extensionServer: LocalExtensionServer?
|
||||
|
||||
init() {
|
||||
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
|
||||
|
||||
let settings = AppSettings()
|
||||
let controller = DownloadController(settings: settings)
|
||||
_settings = StateObject(wrappedValue: settings)
|
||||
_controller = StateObject(wrappedValue: controller)
|
||||
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
|
||||
|
||||
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: "main") {
|
||||
ContentView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(updateChecker)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.task {
|
||||
updateChecker.checkAutomaticallyIfNeeded()
|
||||
_ = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastURLSchemeInvocation) > 5 {
|
||||
urlSchemeInvocationCount = 0
|
||||
}
|
||||
guard urlSchemeInvocationCount < 3 else { return }
|
||||
urlSchemeInvocationCount += 1
|
||||
lastURLSchemeInvocation = now
|
||||
|
||||
if url.scheme == "firelink" {
|
||||
if url.host == "add",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let link = queryItems.first(where: { $0.name == "url" })?.value,
|
||||
link.count < 65536 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = link
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard url.absoluteString.count < 65536 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
Window("Add Downloads", id: "add-downloads") {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
|
||||
if let downloadID {
|
||||
DownloadPropertiesWindow(downloadID: downloadID)
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
} else {
|
||||
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Add Downloads...") {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Start Queue") {
|
||||
controller.startQueue()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
|
||||
Button("Stop Downloads") {
|
||||
controller.pauseActiveDownloads()
|
||||
}
|
||||
.disabled(controller.activeCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 23, height: 23)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
Image(nsImage: nsImage)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarIconURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeIcon = projectRoot
|
||||
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||
}
|
||||
}
|
||||
@@ -1,100 +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,
|
||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setPassword(_ password: String, for id: UUID) -> Bool {
|
||||
deletePassword(for: id)
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecValueData as String: Data(password.utf8),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
|
||||
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func deletePassword(for id: UUID) -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
private static let extensionTokenService = "local.firelink.extension-token"
|
||||
private static let extensionTokenAccount = "pairing-token"
|
||||
|
||||
static func extensionToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setExtensionToken(_ token: String) -> Bool {
|
||||
deleteExtensionToken()
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount,
|
||||
kSecValueData as String: Data(token.utf8),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
|
||||
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func deleteExtensionToken() -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let settings: AppSettings
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
private let tokenLock = NSLock()
|
||||
private var _pairingToken: String = ""
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@MainActor
|
||||
init?(downloadController: DownloadController, settings: AppSettings) {
|
||||
self.downloadController = downloadController
|
||||
self.settings = settings
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
var selectedPort: UInt16?
|
||||
for portValue in Constants.portRange {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||
do {
|
||||
createdListener = try NWListener(using: parameters)
|
||||
selectedPort = UInt16(portValue)
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let createdListener else {
|
||||
print("Failed to create listener on ports 6412-6422")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.listener = createdListener
|
||||
self.port = selectedPort ?? 6412
|
||||
|
||||
settings.$extensionPairingToken
|
||||
.sink { [weak self] token in
|
||||
self?.tokenLock.withLock { self?._pairingToken = token }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private var currentPairingToken: String {
|
||||
tokenLock.withLock { _pairingToken }
|
||||
}
|
||||
|
||||
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)
|
||||
let timeoutItem = DispatchWorkItem { [weak connection] in
|
||||
connection?.cancel()
|
||||
}
|
||||
queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
|
||||
receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem)
|
||||
}
|
||||
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
timeoutItem.cancel()
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
var requestData = accumulatedData
|
||||
if let data {
|
||||
requestData.append(data)
|
||||
}
|
||||
|
||||
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let request = HTTPRequest(data: requestData) {
|
||||
timeoutItem.cancel()
|
||||
let status = self.processRequest(request)
|
||||
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.badRequest, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
|
||||
var headers = [
|
||||
"HTTP/1.1 \(status.rawValue) \(status.reason)",
|
||||
"Content-Length: 0",
|
||||
"Connection: close"
|
||||
]
|
||||
|
||||
if let origin, isAllowedExtensionOrigin(origin) {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
|
||||
guard let url = URL(string: origin),
|
||||
let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == "moz-extension" || scheme == "chrome-extension"
|
||||
}
|
||||
|
||||
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.method == "OPTIONS" {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
let expectedToken = currentPairingToken
|
||||
guard !expectedToken.isEmpty else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
let bodyString = String(data: request.body, encoding: .utf8) ?? ""
|
||||
guard let signatureHex = request.header(named: "x-firelink-signature"),
|
||||
let timestampStr = request.header(named: "x-firelink-timestamp"),
|
||||
let timestamp = TimeInterval(timestampStr) else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
guard abs(now - timestamp) < 60000 else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
let keyData = expectedToken.data(using: .utf8) ?? Data()
|
||||
let key = SymmetricKey(data: keyData)
|
||||
let messageData = (timestampStr + bodyString).data(using: .utf8) ?? Data()
|
||||
let hmac = HMAC<SHA256>.authenticationCode(for: messageData, using: key)
|
||||
let expectedSignature = hmac.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
guard signatureHex.lowercased() == expectedSignature else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.path == "/ping" {
|
||||
return request.method == "GET" ? .ok : .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
return .unsupportedMediaType
|
||||
}
|
||||
|
||||
struct Payload: Decodable {
|
||||
let urls: [String]
|
||||
let referer: String?
|
||||
let silent: Bool?
|
||||
let filename: 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
|
||||
let shouldAsk = self.settings.askWhereToSaveEachFile || payload.silent != true
|
||||
if shouldAsk {
|
||||
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)
|
||||
} else {
|
||||
for validURL in validURLs {
|
||||
self.downloadController.add(urlText: validURL, filename: payload.filename)
|
||||
}
|
||||
self.downloadController.startQueue()
|
||||
}
|
||||
}
|
||||
|
||||
return .ok
|
||||
} catch {
|
||||
print("Failed to parse local request JSON: \(error)")
|
||||
return .badRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HTTPRequest {
|
||||
var method: String
|
||||
var path: String
|
||||
var headers: [String: String]
|
||||
var body: Data
|
||||
|
||||
init?(data: Data) {
|
||||
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let headerData = data[..<headerRange.lowerBound]
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
guard let requestLine = lines.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
|
||||
guard requestParts.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedHeaders: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else {
|
||||
continue
|
||||
}
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
parsedHeaders[name] = value
|
||||
}
|
||||
|
||||
let bodyStart = headerRange.upperBound
|
||||
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
|
||||
guard expectedBodyLength >= 0,
|
||||
data.count >= bodyStart + expectedBodyLength else {
|
||||
return nil
|
||||
}
|
||||
|
||||
method = String(requestParts[0]).uppercased()
|
||||
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
|
||||
headers = parsedHeaders
|
||||
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
|
||||
}
|
||||
|
||||
func header(named name: String) -> String? {
|
||||
headers[name.lowercased()]
|
||||
}
|
||||
}
|
||||
|
||||
private enum HTTPStatus: Int {
|
||||
case ok = 200
|
||||
case noContent = 204
|
||||
case badRequest = 400
|
||||
case forbidden = 403
|
||||
case notFound = 404
|
||||
case methodNotAllowed = 405
|
||||
case payloadTooLarge = 413
|
||||
case unsupportedMediaType = 415
|
||||
|
||||
var reason: String {
|
||||
switch self {
|
||||
case .ok: "OK"
|
||||
case .noContent: "No Content"
|
||||
case .badRequest: "Bad Request"
|
||||
case .forbidden: "Forbidden"
|
||||
case .notFound: "Not Found"
|
||||
case .methodNotAllowed: "Method Not Allowed"
|
||||
case .payloadTooLarge: "Payload Too Large"
|
||||
case .unsupportedMediaType: "Unsupported Media Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum MediaDetector {
|
||||
private static let supportedDomains: Set<String> = [
|
||||
"youtube.com", "youtu.be",
|
||||
"twitter.com", "x.com",
|
||||
"vimeo.com",
|
||||
"twitch.tv",
|
||||
"instagram.com",
|
||||
"tiktok.com",
|
||||
"facebook.com", "fb.watch",
|
||||
"reddit.com", "v.redd.it",
|
||||
"soundcloud.com"
|
||||
]
|
||||
|
||||
static func isSupportedMedia(url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
|
||||
for domain in supportedDomains {
|
||||
if host == domain || host.hasSuffix(".\(domain)") {
|
||||
// Ignore raw files that happen to be hosted on these domains, if any,
|
||||
// though usually these domains serve web pages for media.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class MediaDownloadEngine: @unchecked Sendable {
|
||||
struct Handle {
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case missingEngine(String)
|
||||
case launchFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingEngine(let msg): return msg
|
||||
case .launchFailed(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start(
|
||||
item: DownloadItem,
|
||||
cookieSource: BrowserCookieSource,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
messageUpdate: @escaping @Sendable (String) -> Void,
|
||||
completion: @escaping @Sendable (Result<URL, Error>) -> Void
|
||||
) async throws -> Handle {
|
||||
let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
|
||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||
|
||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = ytDlpURL
|
||||
|
||||
var arguments = [
|
||||
"--newline",
|
||||
"--ffmpeg-location", ffmpegURL.path,
|
||||
"--no-check-formats",
|
||||
"--socket-timeout", "20",
|
||||
"--retries", "3",
|
||||
"--extractor-retries", "3",
|
||||
"--fragment-retries", "10",
|
||||
"--retry-sleep", "0",
|
||||
"--skip-unavailable-fragments",
|
||||
"--compat-options", "no-youtube-unavailable-videos",
|
||||
"-o", item.destinationPath
|
||||
]
|
||||
|
||||
if let format = item.mediaFormatSelector {
|
||||
arguments.append("-f")
|
||||
arguments.append(format)
|
||||
|
||||
if item.isAudioOnlyMedia == true {
|
||||
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
|
||||
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
|
||||
} else {
|
||||
let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
|
||||
arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
|
||||
}
|
||||
}
|
||||
|
||||
let tempConfigDir = MediaExtractionEngine.appendCommonArguments(
|
||||
to: &arguments,
|
||||
cookieSource: cookieSource,
|
||||
credentials: item.credentials,
|
||||
transferOptions: item.transferOptions,
|
||||
preferredDenoURL: ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
|
||||
)
|
||||
|
||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||
arguments.append(contentsOf: ["--proxy", proxyURI])
|
||||
}
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
|
||||
}
|
||||
|
||||
appendParallelDownloadArguments(
|
||||
to: &arguments,
|
||||
item: item,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
)
|
||||
|
||||
arguments.append(item.url.absoluteString)
|
||||
process.arguments = arguments
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
let parser = YTDLPProgressParser()
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
let outputPathTracker = YTDLPOutputPathTracker()
|
||||
let completionGate = CompletionGate(completion)
|
||||
let outputHandler = YTDLPOutputHandler(
|
||||
parser: parser,
|
||||
outputPathTracker: outputPathTracker,
|
||||
progress: progress,
|
||||
messageUpdate: messageUpdate
|
||||
)
|
||||
|
||||
let readGroup = DispatchGroup()
|
||||
|
||||
readGroup.enter()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
|
||||
readGroup.enter()
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
if let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
let complete: @Sendable () -> Void = {
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||
} else {
|
||||
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
|
||||
}
|
||||
}
|
||||
readGroup.notify(queue: .global(), execute: complete)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
complete()
|
||||
}
|
||||
}
|
||||
|
||||
var didRun = false
|
||||
defer {
|
||||
if !didRun, let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
}
|
||||
try process.run()
|
||||
didRun = true
|
||||
messageUpdate("Fetching media data...")
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
errorPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
return Handle(cancel: {
|
||||
if process.isRunning {
|
||||
ProcessTreeTerminator.terminate(process)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
|
||||
let expectedURL = URL(fileURLWithPath: item.destinationPath)
|
||||
if FileManager.default.fileExists(atPath: expectedURL.path) {
|
||||
return expectedURL
|
||||
}
|
||||
|
||||
if let observedURL = tracker.lastExistingOutputURL {
|
||||
return observedURL
|
||||
}
|
||||
|
||||
let baseName = expectedURL.deletingPathExtension().lastPathComponent
|
||||
let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"]
|
||||
|
||||
var mostRecent: URL?
|
||||
var mostRecentDate: Date = .distantPast
|
||||
|
||||
for ext in commonExtensions {
|
||||
let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
if date >= mostRecentDate {
|
||||
mostRecentDate = date
|
||||
mostRecent = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mostRecent ?? expectedURL
|
||||
}
|
||||
|
||||
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
|
||||
guard !message.isEmpty else {
|
||||
return "Exit code \(status)"
|
||||
}
|
||||
|
||||
if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
|
||||
message.localizedCaseInsensitiveContains("not a bot") ||
|
||||
message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
|
||||
return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
|
||||
}
|
||||
|
||||
if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
|
||||
message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
|
||||
return "YouTube challenge solving failed. Install Deno or Node, then retry."
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
private func appendParallelDownloadArguments(
|
||||
to arguments: inout [String],
|
||||
item: DownloadItem,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) {
|
||||
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||
guard connections > 1 else { return }
|
||||
|
||||
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
|
||||
let largeDirectDownloadThreshold: Int64 = 128 * 1024 * 1024
|
||||
guard item.isAudioOnlyMedia != true,
|
||||
(item.sizeBytes ?? 0) >= largeDirectDownloadThreshold,
|
||||
speedLimitKiBPerSecond == nil,
|
||||
let aria2URL = Aria2DownloadEngine.findExecutable() else {
|
||||
return
|
||||
}
|
||||
|
||||
let aria2Connections = min(connections, 8)
|
||||
let certificateArgument = Aria2DownloadEngine.certificateBundleURL().map {
|
||||
" --ca-certificate=\(Self.shellQuoted($0.path))"
|
||||
} ?? ""
|
||||
arguments.append(contentsOf: [
|
||||
"--downloader", aria2URL.path,
|
||||
"--downloader", "dash,m3u8:native",
|
||||
"--downloader-args",
|
||||
"aria2c:-x\(aria2Connections) -s\(aria2Connections) -k1M --file-allocation=none --summary-interval=1\(certificateArgument)"
|
||||
])
|
||||
}
|
||||
|
||||
private static func shellQuoted(_ value: String) -> String {
|
||||
"'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPOutputHandler: @unchecked Sendable {
|
||||
private let parser: YTDLPProgressParser
|
||||
private let outputPathTracker: YTDLPOutputPathTracker
|
||||
private let progress: @Sendable (DownloadProgress) -> Void
|
||||
private let messageUpdate: @Sendable (String) -> Void
|
||||
|
||||
init(
|
||||
parser: YTDLPProgressParser,
|
||||
outputPathTracker: YTDLPOutputPathTracker,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
messageUpdate: @escaping @Sendable (String) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.outputPathTracker = outputPathTracker
|
||||
self.progress = progress
|
||||
self.messageUpdate = messageUpdate
|
||||
}
|
||||
|
||||
func handle(_ text: String) {
|
||||
for line in text.split(whereSeparator: \.isNewline) {
|
||||
let stringLine = String(line)
|
||||
outputPathTracker.observe(stringLine)
|
||||
if let update = parser.parse(stringLine) {
|
||||
progress(update)
|
||||
messageUpdate("Downloading Media")
|
||||
} else if let message = statusMessage(for: stringLine) {
|
||||
messageUpdate(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusMessage(for line: String) -> String? {
|
||||
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
|
||||
return "Processing Media..."
|
||||
}
|
||||
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
|
||||
return "Fetching YouTube data..."
|
||||
}
|
||||
if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
|
||||
return "Preparing media stream..."
|
||||
}
|
||||
if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
|
||||
line.localizedCaseInsensitiveContains("not a bot") ||
|
||||
line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
|
||||
return "YouTube requires browser cookies"
|
||||
}
|
||||
if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
|
||||
line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
|
||||
return "YouTube challenge solver unavailable"
|
||||
}
|
||||
if line.contains("Destination:") {
|
||||
return "Starting media download..."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPOutputPathTracker: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var observedPaths: [String] = []
|
||||
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
|
||||
|
||||
var lastExistingOutputURL: URL? {
|
||||
lock.withLock {
|
||||
observedPaths
|
||||
.reversed()
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
.first { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
}
|
||||
|
||||
func observe(_ line: String) {
|
||||
let candidates = pathCandidates(from: line)
|
||||
guard !candidates.isEmpty else { return }
|
||||
|
||||
lock.withLock {
|
||||
for candidate in candidates where !observedPaths.contains(candidate) {
|
||||
observedPaths.append(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pathCandidates(from line: String) -> [String] {
|
||||
var paths: [String] = []
|
||||
|
||||
if line.contains("Destination:"),
|
||||
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
destination.hasPrefix("/") {
|
||||
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
|
||||
}
|
||||
|
||||
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
|
||||
paths.append(quoted)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
private func quotedCaptures(in text: String) -> [String] {
|
||||
guard let quotedPathRegex else { return [] }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
|
||||
guard match.numberOfRanges > 1,
|
||||
let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func fileExtension(defaultValue: String) -> String {
|
||||
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return ext.isEmpty ? defaultValue : ext
|
||||
}
|
||||
}
|
||||
|
||||
final class YTDLPProgressParser: @unchecked Sendable {
|
||||
private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
|
||||
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
|
||||
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
|
||||
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
|
||||
|
||||
func parse(_ line: String) -> DownloadProgress? {
|
||||
if line.contains("[download]") && line.contains("%") {
|
||||
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
|
||||
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
|
||||
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: 1
|
||||
)
|
||||
} else if line.contains("[#") && line.contains("DL:") {
|
||||
let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
|
||||
let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
|
||||
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
|
||||
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
|
||||
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: cn
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
|
||||
guard let regex else { return nil }
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
guard let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import Darwin
|
||||
|
||||
enum AddonState: Equatable, Sendable {
|
||||
case notInstalled
|
||||
case installed(version: String)
|
||||
case failed(error: String)
|
||||
}
|
||||
|
||||
enum AddonType: String, CaseIterable, Sendable {
|
||||
case ytDlp = "yt-dlp"
|
||||
case ffmpeg
|
||||
case deno
|
||||
|
||||
var binaryName: String {
|
||||
switch self {
|
||||
case .ytDlp: return "yt-dlp"
|
||||
case .ffmpeg: return "ffmpeg"
|
||||
case .deno: return "deno"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MediaEngineManager: ObservableObject {
|
||||
static let shared = MediaEngineManager()
|
||||
|
||||
@Published var ytDlpState: AddonState = .notInstalled
|
||||
@Published var ffmpegState: AddonState = .notInstalled
|
||||
@Published var denoState: AddonState = .notInstalled
|
||||
private var ytDlpPreparationTask: Task<URL?, Never>?
|
||||
|
||||
private init() {
|
||||
checkLocalInstallation()
|
||||
Task { [weak self] in
|
||||
_ = await self?.preparedBinaryPath(for: .ytDlp)
|
||||
}
|
||||
}
|
||||
|
||||
func preparedBinaryPath(for addon: AddonType) async -> URL? {
|
||||
guard addon == .ytDlp else { return binaryPath(for: addon) }
|
||||
|
||||
if let ytDlpPreparationTask {
|
||||
return await ytDlpPreparationTask.value
|
||||
}
|
||||
|
||||
guard let bundledURL = binaryPath(for: .ytDlp) else { return nil }
|
||||
let runtimeVersion = bundledRuntimeVersion(near: bundledURL)
|
||||
let task = Task<URL?, Never>.detached(priority: .userInitiated) {
|
||||
let executableURL = Self.installStableYtDlpRuntime(
|
||||
bundledExecutableURL: bundledURL,
|
||||
version: runtimeVersion
|
||||
) ?? bundledURL
|
||||
Self.prewarm(executableURL)
|
||||
return executableURL
|
||||
}
|
||||
ytDlpPreparationTask = task
|
||||
return await task.value
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
|
||||
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||
#if SWIFT_PACKAGE
|
||||
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func bundledRuntimeVersion(near executableURL: URL) -> String {
|
||||
let versionURL = executableURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("yt-dlp-version.txt")
|
||||
if let version = try? String(contentsOf: versionURL, encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!version.isEmpty {
|
||||
return version
|
||||
}
|
||||
|
||||
return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
|
||||
}
|
||||
|
||||
nonisolated private static func installStableYtDlpRuntime(
|
||||
bundledExecutableURL: URL,
|
||||
version: String
|
||||
) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
let bundledDirectory = bundledExecutableURL.deletingLastPathComponent()
|
||||
let bundledInternalURL = bundledDirectory.appendingPathComponent("_internal", isDirectory: true)
|
||||
let bundledDenoURL = bundledDirectory.appendingPathComponent("deno")
|
||||
let hasBundledDeno = fileManager.isExecutableFile(atPath: bundledDenoURL.path)
|
||||
|
||||
guard fileManager.fileExists(atPath: bundledInternalURL.path) else {
|
||||
return bundledExecutableURL
|
||||
}
|
||||
|
||||
guard let applicationSupportURL = fileManager.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first else {
|
||||
return bundledExecutableURL
|
||||
}
|
||||
|
||||
let safeVersion = version.replacingOccurrences(
|
||||
of: #"[^A-Za-z0-9._-]"#,
|
||||
with: "_",
|
||||
options: .regularExpression
|
||||
)
|
||||
let enginesURL = applicationSupportURL
|
||||
.appendingPathComponent("Firelink", isDirectory: true)
|
||||
.appendingPathComponent("MediaEngines", isDirectory: true)
|
||||
.appendingPathComponent("yt-dlp", isDirectory: true)
|
||||
let runtimeURL = enginesURL.appendingPathComponent(safeVersion, isDirectory: true)
|
||||
let executableURL = runtimeURL.appendingPathComponent("yt-dlp")
|
||||
let internalURL = runtimeURL.appendingPathComponent("_internal", isDirectory: true)
|
||||
let denoURL = runtimeURL.appendingPathComponent("deno")
|
||||
|
||||
if fileManager.isExecutableFile(atPath: executableURL.path),
|
||||
fileManager.fileExists(atPath: internalURL.path),
|
||||
!hasBundledDeno || fileManager.isExecutableFile(atPath: denoURL.path) {
|
||||
return executableURL
|
||||
}
|
||||
|
||||
let temporaryURL = enginesURL.appendingPathComponent(
|
||||
".install-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(at: enginesURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true)
|
||||
try fileManager.copyItem(
|
||||
at: bundledExecutableURL,
|
||||
to: temporaryURL.appendingPathComponent("yt-dlp")
|
||||
)
|
||||
try fileManager.copyItem(
|
||||
at: bundledInternalURL,
|
||||
to: temporaryURL.appendingPathComponent("_internal", isDirectory: true)
|
||||
)
|
||||
if hasBundledDeno {
|
||||
try fileManager.copyItem(
|
||||
at: bundledDenoURL,
|
||||
to: temporaryURL.appendingPathComponent("deno")
|
||||
)
|
||||
}
|
||||
removeTransportAttributesRecursively(at: temporaryURL)
|
||||
|
||||
if fileManager.fileExists(atPath: runtimeURL.path) {
|
||||
try fileManager.removeItem(at: runtimeURL)
|
||||
}
|
||||
try fileManager.moveItem(at: temporaryURL, to: runtimeURL)
|
||||
return executableURL
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: temporaryURL)
|
||||
return bundledExecutableURL
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func removeTransportAttributesRecursively(at rootURL: URL) {
|
||||
removeTransportAttributes(at: rootURL)
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: rootURL,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [],
|
||||
errorHandler: nil
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let itemURL as URL in enumerator {
|
||||
removeTransportAttributes(at: itemURL)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func removeTransportAttributes(at url: URL) {
|
||||
url.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return }
|
||||
removexattr(path, "com.apple.quarantine", 0)
|
||||
removexattr(path, "com.apple.provenance", 0)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func prewarm(_ executableURL: URL) {
|
||||
runVersionCommand(executableURL)
|
||||
|
||||
let denoURL = executableURL.deletingLastPathComponent().appendingPathComponent("deno")
|
||||
if FileManager.default.isExecutableFile(atPath: denoURL.path) {
|
||||
runVersionCommand(denoURL)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func runVersionCommand(_ executableURL: URL) {
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = nil
|
||||
process.standardError = nil
|
||||
process.standardInput = nil
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func checkLocalInstallation() {
|
||||
for addon in AddonType.allCases {
|
||||
if binaryPath(for: addon) != nil {
|
||||
setState(for: addon, to: .installed(version: "Bundled"))
|
||||
} else {
|
||||
setState(for: addon, to: .notInstalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
|
||||
checkLocalInstallation()
|
||||
let missingAddons = requiredAddons.filter { addon in
|
||||
switch state(for: addon) {
|
||||
case .installed:
|
||||
return false
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !missingAddons.isEmpty else { return }
|
||||
|
||||
for missing in missingAddons {
|
||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||
}
|
||||
|
||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
||||
}
|
||||
|
||||
private func state(for addon: AddonType) -> AddonState {
|
||||
switch addon {
|
||||
case .ytDlp: return ytDlpState
|
||||
case .ffmpeg: return ffmpegState
|
||||
case .deno: return denoState
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(for addon: AddonType, to state: AddonState) {
|
||||
switch addon {
|
||||
case .ytDlp: ytDlpState = state
|
||||
case .ffmpeg: ffmpegState = state
|
||||
case .deno: denoState = state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RawMediaFormat: Decodable, Sendable, Equatable {
|
||||
let format_id: String?
|
||||
let ext: String?
|
||||
let resolution: String?
|
||||
let format_note: String?
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let height: Int?
|
||||
let filesize: Int64?
|
||||
let filesize_approx: Int64?
|
||||
}
|
||||
|
||||
struct MediaMetadata: Decodable, Sendable, Equatable {
|
||||
let id: String?
|
||||
let title: String?
|
||||
let uploader: String?
|
||||
let channel: String?
|
||||
let thumbnail: URL?
|
||||
let duration: Double?
|
||||
let formats: [RawMediaFormat]?
|
||||
|
||||
var displayUploader: String? {
|
||||
channel ?? uploader
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaType: String, Sendable, Equatable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
}
|
||||
|
||||
struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let formatSelector: String
|
||||
let isAudioOnly: Bool
|
||||
let symbol: String
|
||||
let outputExtension: String
|
||||
let detail: String
|
||||
let estimatedBytes: Int64?
|
||||
|
||||
let mediaType: MediaType
|
||||
let qualityName: String
|
||||
let containerName: String
|
||||
}
|
||||
|
||||
enum MediaExtractionEngine {
|
||||
private final class CacheEntry {
|
||||
let metadata: MediaMetadata
|
||||
let options: [CleanFormatOption]
|
||||
let date: Date
|
||||
init(metadata: MediaMetadata, options: [CleanFormatOption], date: Date) {
|
||||
self.metadata = metadata
|
||||
self.options = options
|
||||
self.date = date
|
||||
}
|
||||
}
|
||||
nonisolated(unsafe) private static let metadataCache = NSCache<NSURL, CacheEntry>()
|
||||
|
||||
private static let metadataTimeoutSeconds: UInt64 = 120
|
||||
|
||||
enum ExtractionError: Error, LocalizedError {
|
||||
case processFailed(String)
|
||||
case invalidOutput
|
||||
case parsingFailed(Error)
|
||||
case timedOut
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
||||
case .invalidOutput: return "Invalid output from media engine."
|
||||
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
||||
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchMetadata(
|
||||
for url: URL,
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions,
|
||||
proxyConfiguration: DownloadProxyConfiguration
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
|
||||
return (cached.metadata, cached.options)
|
||||
}
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
}
|
||||
let ytDlpPath = ytDlpURL.path
|
||||
|
||||
var args = [
|
||||
"-J",
|
||||
"--no-warnings",
|
||||
"--no-playlist",
|
||||
"--no-check-formats",
|
||||
"--socket-timeout", "20",
|
||||
"--retries", "3",
|
||||
"--extractor-retries", "3",
|
||||
"--compat-options", "no-youtube-unavailable-videos"
|
||||
]
|
||||
|
||||
if let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg),
|
||||
FileManager.default.isExecutableFile(atPath: ffmpegURL.path) {
|
||||
args.append(contentsOf: ["--ffmpeg-location", ffmpegURL.path])
|
||||
}
|
||||
|
||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||
args.append(contentsOf: ["--proxy", proxyURI])
|
||||
}
|
||||
|
||||
let tempConfigDir = appendCommonArguments(
|
||||
to: &args,
|
||||
cookieSource: cookieSource,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions,
|
||||
preferredDenoURL: cachedDenoURL(near: ytDlpURL)
|
||||
)
|
||||
defer {
|
||||
if let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
}
|
||||
args.append(url.absoluteString)
|
||||
|
||||
let data = try await YTDLPMetadataProcess(
|
||||
executableURL: URL(fileURLWithPath: ytDlpPath),
|
||||
arguments: args
|
||||
).run(timeoutSeconds: metadataTimeoutSeconds)
|
||||
|
||||
guard !data.isEmpty else {
|
||||
throw ExtractionError.invalidOutput
|
||||
}
|
||||
|
||||
do {
|
||||
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
|
||||
let options = extractOptions(from: metadata)
|
||||
metadataCache.setObject(CacheEntry(metadata: metadata, options: options, date: Date()), forKey: url as NSURL)
|
||||
return (metadata, options)
|
||||
} catch {
|
||||
throw ExtractionError.parsingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendCommonArguments(
|
||||
to args: inout [String],
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions,
|
||||
preferredDenoURL: URL? = nil
|
||||
) -> URL? {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
||||
}
|
||||
|
||||
appendJavaScriptRuntimeArguments(to: &args, preferredDenoURL: preferredDenoURL)
|
||||
|
||||
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||
args.append(contentsOf: ["--add-header", header.headerLine])
|
||||
}
|
||||
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!cookieHeader.isEmpty {
|
||||
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
|
||||
}
|
||||
|
||||
var tempConfigDir: URL?
|
||||
if let credentials, !credentials.isEmpty {
|
||||
let configContent = "--username \"\(credentials.username)\"\n--password \"\(credentials.password)\"\n"
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-yt-dlp-\(UUID().uuidString)")
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
let fileURL = tempDir.appendingPathComponent("yt-dlp.conf")
|
||||
try? configContent.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
||||
args.append(contentsOf: ["--config-locations", fileURL.path])
|
||||
tempConfigDir = tempDir
|
||||
}
|
||||
return tempConfigDir
|
||||
}
|
||||
|
||||
private static func appendJavaScriptRuntimeArguments(
|
||||
to args: inout [String],
|
||||
preferredDenoURL: URL?
|
||||
) {
|
||||
var runtimes: [String] = []
|
||||
if let denoPath = executablePath(at: preferredDenoURL) ??
|
||||
bundledExecutablePath(named: "deno") ??
|
||||
executablePath(named: "deno", candidates: [
|
||||
"/opt/homebrew/bin/deno",
|
||||
"/usr/local/bin/deno"
|
||||
]) {
|
||||
runtimes.append("deno:\(denoPath)")
|
||||
}
|
||||
|
||||
if let nodePath = executablePath(named: "node", candidates: [
|
||||
"/opt/homebrew/bin/node",
|
||||
"/usr/local/bin/node",
|
||||
"/usr/bin/node"
|
||||
]) {
|
||||
runtimes.append("node:\(nodePath)")
|
||||
}
|
||||
|
||||
if !runtimes.isEmpty {
|
||||
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
|
||||
}
|
||||
}
|
||||
|
||||
private static func cachedDenoURL(near ytDlpURL: URL) -> URL? {
|
||||
let denoURL = ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
|
||||
return FileManager.default.isExecutableFile(atPath: denoURL.path) ? denoURL : nil
|
||||
}
|
||||
|
||||
private static func executablePath(at url: URL?) -> String? {
|
||||
guard let url, FileManager.default.isExecutableFile(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
private static func bundledExecutablePath(named name: String) -> String? {
|
||||
if let bundled = Bundle.main.url(forResource: name, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled.path
|
||||
}
|
||||
|
||||
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||
#if SWIFT_PACKAGE
|
||||
if let bundled = Bundle.module.url(forResource: name, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled.path
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func executablePath(named name: String, candidates: [String]) -> String? {
|
||||
var safeCandidates = candidates
|
||||
safeCandidates.append(contentsOf: [
|
||||
"/opt/homebrew/bin/\(name)",
|
||||
"/usr/local/bin/\(name)",
|
||||
"/usr/bin/\(name)",
|
||||
"/opt/local/bin/\(name)"
|
||||
])
|
||||
|
||||
for candidate in safeCandidates {
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
|
||||
var options: [CleanFormatOption] = []
|
||||
let rawFormats = metadata.formats ?? []
|
||||
|
||||
let standardResolutions = [
|
||||
(2160, "4K"),
|
||||
(1440, "1440p"),
|
||||
(1080, "1080p"),
|
||||
(720, "720p"),
|
||||
(480, "480p"),
|
||||
(360, "360p")
|
||||
]
|
||||
|
||||
let availableResolutions = standardResolutions.filter { resolution, _ in
|
||||
rawFormats.contains { format in
|
||||
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
|
||||
}
|
||||
}
|
||||
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
|
||||
let videoContainers = [
|
||||
("mp4", "MP4"),
|
||||
("mkv", "MKV"),
|
||||
("webm", "WebM")
|
||||
]
|
||||
|
||||
for (height, qualityName) in videoQualities {
|
||||
for (container, containerName) in videoContainers {
|
||||
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
|
||||
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
|
||||
options.append(CleanFormatOption(
|
||||
name: "\(qualityName) \(containerName)",
|
||||
formatSelector: videoSelector(height: height, container: container),
|
||||
isAudioOnly: false,
|
||||
symbol: "play.tv.fill",
|
||||
outputExtension: container,
|
||||
detail: optionDetail(
|
||||
base: height == nil ? "Best available video" : "Up to \(qualityName)",
|
||||
estimatedBytes: estimatedBytes
|
||||
),
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .video,
|
||||
qualityName: qualityName,
|
||||
containerName: containerName
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: nil) {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio MP3",
|
||||
formatSelector: "bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "music.note",
|
||||
outputExtension: "mp3",
|
||||
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "MP3"
|
||||
))
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio M4A",
|
||||
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "waveform",
|
||||
outputExtension: "m4a",
|
||||
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "M4A"
|
||||
))
|
||||
}
|
||||
|
||||
if hasAudioFormat(rawFormats, preferredExtension: "webm") {
|
||||
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
|
||||
options.append(CleanFormatOption(
|
||||
name: "Audio Opus",
|
||||
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
|
||||
isAudioOnly: true,
|
||||
symbol: "waveform",
|
||||
outputExtension: "opus",
|
||||
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "Opus"
|
||||
))
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
|
||||
formats.contains { format in
|
||||
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
||||
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
|
||||
formats.contains { format in
|
||||
guard isAudio(format) else { return false }
|
||||
guard let preferredExtension else { return true }
|
||||
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
|
||||
let videoBytes = formats
|
||||
.filter { format in
|
||||
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
||||
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
||||
}
|
||||
.compactMap { formatSize($0) }
|
||||
.max()
|
||||
|
||||
guard let videoBytes else { return nil }
|
||||
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
|
||||
estimatedAudioBytes(formats, preferredExtension: nil) ??
|
||||
0
|
||||
return videoBytes + audioBytes
|
||||
}
|
||||
|
||||
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
|
||||
let preferred = formats
|
||||
.filter { format in
|
||||
guard isAudio(format) else { return false }
|
||||
guard let preferredExtension else { return true }
|
||||
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
||||
}
|
||||
.compactMap { formatSize($0) }
|
||||
.max()
|
||||
|
||||
if preferred != nil || preferredExtension == nil {
|
||||
return preferred
|
||||
}
|
||||
|
||||
return estimatedAudioBytes(formats, preferredExtension: nil)
|
||||
}
|
||||
|
||||
private static func isVideo(_ format: RawMediaFormat) -> Bool {
|
||||
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private static func isAudio(_ format: RawMediaFormat) -> Bool {
|
||||
let acodec = format.acodec?.lowercased()
|
||||
let vcodec = format.vcodec?.lowercased()
|
||||
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
|
||||
}
|
||||
|
||||
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
|
||||
guard let height else { return true }
|
||||
guard let formatHeight = format.height else { return false }
|
||||
return formatHeight <= height && formatHeight >= height - 100
|
||||
}
|
||||
|
||||
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
|
||||
format.filesize ?? format.filesize_approx
|
||||
}
|
||||
|
||||
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
|
||||
guard let estimatedBytes, estimatedBytes > 0 else { return base }
|
||||
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
|
||||
}
|
||||
|
||||
private static func videoSelector(height: Int?, container: String) -> String {
|
||||
let filter = heightFilter(height)
|
||||
switch container {
|
||||
case "mp4":
|
||||
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
case "webm":
|
||||
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
default:
|
||||
return "bestvideo\(filter)+bestaudio/best\(filter)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func heightFilter(_ height: Int?) -> String {
|
||||
guard let height else { return "" }
|
||||
return "[height<=\(height)]"
|
||||
}
|
||||
}
|
||||
|
||||
private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
private let executableURL: URL
|
||||
private let arguments: [String]
|
||||
private let lock = NSLock()
|
||||
private var process: Process?
|
||||
|
||||
init(executableURL: URL, arguments: [String]) {
|
||||
self.executableURL = executableURL
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
func run(timeoutSeconds: UInt64) async throws -> Data {
|
||||
try await withTaskCancellationHandler {
|
||||
try await withThrowingTaskGroup(of: Data.self) { group in
|
||||
group.addTask {
|
||||
try await self.runProcess()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
self.terminate()
|
||||
throw MediaExtractionEngine.ExtractionError.timedOut
|
||||
}
|
||||
|
||||
guard let result = try await group.next() else {
|
||||
throw MediaExtractionEngine.ExtractionError.invalidOutput
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
} onCancel: {
|
||||
self.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func runProcess() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
|
||||
process.executableURL = executableURL
|
||||
process.arguments = arguments
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = nil
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
// Allow a brief moment for final pipe data to flush
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
if Task.isCancelled {
|
||||
self.terminate()
|
||||
}
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
errorPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
// We do not care about the DispatchGroup if we throw immediately here
|
||||
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func terminate() {
|
||||
let p = lock.withLock { self.process }
|
||||
guard let p, p.isRunning else { return }
|
||||
ProcessTreeTerminator.terminate(p)
|
||||
}
|
||||
}
|
||||
@@ -1,288 +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 {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
||||
let cleanValue = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
||||
return DownloadRequestHeader(name: cleanName, value: cleanValue)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
let clean = normalized
|
||||
return clean.name.isEmpty && clean.value.isEmpty
|
||||
}
|
||||
|
||||
var headerLine: String {
|
||||
let clean = normalized
|
||||
return "\(clean.name): \(clean.value)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadTransferOptions: Equatable, Sendable {
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader] = []
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL] = []
|
||||
}
|
||||
|
||||
enum DownloadTransferOptionParser {
|
||||
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
|
||||
headerLines(text).compactMap { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let header = DownloadRequestHeader(name: name, value: value).normalized
|
||||
return header.isEmpty || header.name.isEmpty ? nil : header
|
||||
}
|
||||
}
|
||||
|
||||
static func invalidHeaderLines(_ text: String) -> [String] {
|
||||
headerLines(text).filter { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return true }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanCookieHeader(_ text: String) -> String? {
|
||||
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.lowercased().hasPrefix("cookie:") {
|
||||
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
static func parseMirrorURLs(_ text: String) -> [URL] {
|
||||
DownloadURLParser.parse(text)
|
||||
}
|
||||
|
||||
static func invalidMirrorLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
|
||||
}
|
||||
|
||||
private static func headerLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var id = UUID()
|
||||
var url: URL
|
||||
var fileName: String
|
||||
var category: DownloadCategory
|
||||
var destinationDirectory: URL
|
||||
var connectionsPerServer: Int
|
||||
var credentials: DownloadCredentials?
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader]?
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL]?
|
||||
var speedLimitKiBPerSecond: Int?
|
||||
var status: DownloadStatus = .queued
|
||||
var progress: Double = 0
|
||||
var speedText: String = "-"
|
||||
var etaText: String = "-"
|
||||
var connectionCount: Int = 0
|
||||
var sizeBytes: Int64?
|
||||
var bytesText: String = "-"
|
||||
var message: String = ""
|
||||
var createdAt = Date()
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
var mediaFormatSelector: String?
|
||||
var isAudioOnlyMedia: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, url, fileName, category, destinationDirectory, connectionsPerServer
|
||||
case credentials, checksum, requestHeaders, cookieHeader, mirrorURLs, speedLimitKiBPerSecond
|
||||
case status, progress, speedText, etaText, connectionCount, sizeBytes, bytesText, message
|
||||
case createdAt, lastTryAt, autoResumeOnLaunch, queueID
|
||||
case mediaFormatSelector, isAudioOnlyMedia
|
||||
}
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
var sortableSize: Int64 {
|
||||
sizeBytes ?? 0
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
requestHeaders: requestHeaders ?? [],
|
||||
cookieHeader: cookieHeader,
|
||||
mirrorURLs: mirrorURLs ?? []
|
||||
)
|
||||
}
|
||||
|
||||
var speedLimitText: String {
|
||||
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
|
||||
return "No limit"
|
||||
}
|
||||
return "\(speedLimitKiBPerSecond) KiB/s"
|
||||
}
|
||||
|
||||
var redactedForPersistence: DownloadItem {
|
||||
var item = self
|
||||
if item.credentials != nil {
|
||||
item.credentials?.password = ""
|
||||
}
|
||||
item.cookieHeader = nil
|
||||
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadRequestHeader {
|
||||
var containsSensitiveValue: Bool {
|
||||
switch normalized.name.lowercased() {
|
||||
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgress: Equatable, Sendable {
|
||||
var fraction: Double
|
||||
var bytesText: String
|
||||
var speedText: String
|
||||
var etaText: String
|
||||
var connectionCount: Int
|
||||
}
|
||||
|
||||
struct PendingDownload: Identifiable, Equatable, Sendable {
|
||||
enum MetadataState: Equatable, Sendable {
|
||||
case pending
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
var id = UUID()
|
||||
var url: URL
|
||||
var fileName: String
|
||||
var category: DownloadCategory
|
||||
var defaultDirectory: URL
|
||||
var sizeBytes: Int64?
|
||||
var mimeType: String?
|
||||
var state: MetadataState = .pending
|
||||
|
||||
var isMedia: Bool = false
|
||||
var mediaOptions: [CleanFormatOption] = []
|
||||
var selectedMediaOption: CleanFormatOption?
|
||||
var mediaMetadata: MediaMetadata?
|
||||
|
||||
var destinationPath: String {
|
||||
defaultDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
enum ProcessTreeTerminator {
|
||||
static func terminate(_ process: Process, forceAfter delay: TimeInterval = 0.5) {
|
||||
let rootPID = process.processIdentifier
|
||||
guard rootPID > 0 else { return }
|
||||
|
||||
let processIDs = descendants(of: rootPID) + [rootPID]
|
||||
signal(processIDs.reversed(), with: SIGTERM)
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) {
|
||||
signal(processIDs.reversed(), with: SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func descendants(of rootPID: pid_t) -> [pid_t] {
|
||||
var result: [pid_t] = []
|
||||
var pending = directChildren(of: rootPID)
|
||||
|
||||
while let processID = pending.popLast() {
|
||||
result.append(processID)
|
||||
pending.append(contentsOf: directChildren(of: processID))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func directChildren(of processID: pid_t) -> [pid_t] {
|
||||
var capacity = 32
|
||||
|
||||
while capacity <= 4096 {
|
||||
var processIDs = [pid_t](repeating: 0, count: capacity)
|
||||
let count = processIDs.withUnsafeMutableBytes { buffer in
|
||||
proc_listchildpids(processID, buffer.baseAddress, Int32(buffer.count))
|
||||
}
|
||||
|
||||
guard count > 0 else { return [] }
|
||||
if count < capacity {
|
||||
return Array(processIDs.prefix(Int(count))).filter { $0 > 0 }
|
||||
}
|
||||
capacity *= 2
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private static func signal<S: Sequence>(_ processIDs: S, with signal: Int32) where S.Element == pid_t {
|
||||
for processID in processIDs where processID > 0 {
|
||||
kill(processID, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct GitHubReleaseCheckService: @unchecked Sendable {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
init(
|
||||
owner: String = "nimbold",
|
||||
repository: String = "Firelink",
|
||||
fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
|
||||
try await URLSession.shared.data(for: request)
|
||||
}
|
||||
) {
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
|
||||
guard let current = AppVersion(currentVersion) else {
|
||||
throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
|
||||
}
|
||||
|
||||
let release = try await latestStableRelease()
|
||||
guard let latest = AppVersion(release.tagName) else {
|
||||
throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
|
||||
}
|
||||
|
||||
let update = AvailableReleaseUpdate(
|
||||
version: latest.description,
|
||||
tagName: release.tagName,
|
||||
title: release.name?.isEmpty == false ? release.name! : release.tagName,
|
||||
releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
|
||||
releaseURL: release.htmlURL,
|
||||
publishedAt: release.publishedAt
|
||||
)
|
||||
|
||||
if latest > current {
|
||||
return .updateAvailable(update)
|
||||
}
|
||||
|
||||
return .upToDate(latestVersion: latest.description, localVersion: current.description)
|
||||
}
|
||||
|
||||
private func latestStableRelease() async throws -> GitHubRelease {
|
||||
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
|
||||
throw ReleaseCheckFailure.invalidReleaseURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await fetch(request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ReleaseCheckFailure.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
|
||||
let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
|
||||
guard !stableReleases.isEmpty else {
|
||||
throw ReleaseCheckFailure.noStableRelease
|
||||
}
|
||||
|
||||
let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
|
||||
guard let version = AppVersion(release.tagName) else { return nil }
|
||||
return (release, version)
|
||||
}
|
||||
|
||||
guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
|
||||
throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
|
||||
}
|
||||
|
||||
return latest.release
|
||||
}
|
||||
}
|
||||
|
||||
struct GitHubRelease: Decodable, Equatable {
|
||||
let tagName: String
|
||||
let name: String?
|
||||
let body: String?
|
||||
let htmlURL: URL
|
||||
let draft: Bool
|
||||
let prerelease: Bool
|
||||
let publishedAt: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tagName = "tag_name"
|
||||
case name
|
||||
case body
|
||||
case htmlURL = "html_url"
|
||||
case draft
|
||||
case prerelease
|
||||
case publishedAt = "published_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct AvailableReleaseUpdate: Equatable, Identifiable {
|
||||
var id: String { tagName }
|
||||
let version: String
|
||||
let tagName: String
|
||||
let title: String
|
||||
let releaseNotes: String
|
||||
let releaseURL: URL
|
||||
let publishedAt: Date?
|
||||
}
|
||||
|
||||
enum ReleaseCheckOutcome: Equatable {
|
||||
case updateAvailable(AvailableReleaseUpdate)
|
||||
case upToDate(latestVersion: String, localVersion: String)
|
||||
}
|
||||
|
||||
enum ReleaseUpdateState: Equatable {
|
||||
case idle
|
||||
case checking
|
||||
case updateAvailable(AvailableReleaseUpdate)
|
||||
case upToDate(latestVersion: String, localVersion: String)
|
||||
case failed(message: String, recovery: String)
|
||||
}
|
||||
|
||||
enum ReleaseCheckFailure: Error, Equatable {
|
||||
case invalidReleaseURL
|
||||
case invalidResponse
|
||||
case httpStatus(Int)
|
||||
case noStableRelease
|
||||
case invalidCurrentVersion(String)
|
||||
case invalidReleaseVersion(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReleaseUpdateChecker: ObservableObject {
|
||||
@Published private(set) var state: ReleaseUpdateState = .idle
|
||||
@Published var automaticallyChecksForUpdates: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private let service: GitHubReleaseCheckService
|
||||
private let bundle: Bundle
|
||||
private var automaticCheckTask: Task<Void, Never>?
|
||||
|
||||
private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
|
||||
private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
|
||||
private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
|
||||
|
||||
init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
|
||||
self.service = service
|
||||
self.bundle = bundle
|
||||
|
||||
if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
|
||||
self.automaticallyChecksForUpdates = true
|
||||
} else {
|
||||
self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
|
||||
}
|
||||
}
|
||||
|
||||
var currentVersion: String {
|
||||
bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
guard state != .checking else { return }
|
||||
state = .checking
|
||||
|
||||
Task {
|
||||
do {
|
||||
let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
|
||||
UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
|
||||
apply(outcome)
|
||||
} catch {
|
||||
state = Self.failedState(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomaticallyIfNeeded() {
|
||||
guard automaticallyChecksForUpdates else { return }
|
||||
guard state == .idle else { return }
|
||||
|
||||
if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
|
||||
Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
|
||||
return
|
||||
}
|
||||
|
||||
automaticCheckTask?.cancel()
|
||||
automaticCheckTask = Task { [weak self] in
|
||||
self?.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ outcome: ReleaseCheckOutcome) {
|
||||
switch outcome {
|
||||
case .updateAvailable(let update):
|
||||
state = .updateAvailable(update)
|
||||
case .upToDate(let latestVersion, let localVersion):
|
||||
state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private static func failedState(for error: Error) -> ReleaseUpdateState {
|
||||
if let urlError = error as? URLError {
|
||||
switch urlError.code {
|
||||
case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
|
||||
return .failed(
|
||||
message: "Couldn't reach GitHub.",
|
||||
recovery: "Check your internet connection, then try again."
|
||||
)
|
||||
default:
|
||||
return .failed(
|
||||
message: "The update check couldn't finish.",
|
||||
recovery: urlError.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let failure = error as? ReleaseCheckFailure {
|
||||
switch failure {
|
||||
case .httpStatus(let statusCode):
|
||||
return .failed(
|
||||
message: "GitHub returned HTTP \(statusCode).",
|
||||
recovery: "Try again in a moment, or open the releases page manually."
|
||||
)
|
||||
case .noStableRelease:
|
||||
return .failed(
|
||||
message: "No stable release was found.",
|
||||
recovery: "Open GitHub Releases to check the project manually."
|
||||
)
|
||||
case .invalidCurrentVersion(let version):
|
||||
return .failed(
|
||||
message: "Firelink's current version could not be read.",
|
||||
recovery: "The app reported version \(version)."
|
||||
)
|
||||
case .invalidReleaseVersion(let tag):
|
||||
return .failed(
|
||||
message: "The latest release tag could not be compared.",
|
||||
recovery: "GitHub reported \(tag)."
|
||||
)
|
||||
case .invalidReleaseURL, .invalidResponse:
|
||||
return .failed(
|
||||
message: "The GitHub response could not be read.",
|
||||
recovery: "Try again in a moment."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return .failed(
|
||||
message: "The update check failed.",
|
||||
recovery: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppVersion: Comparable, CustomStringConvertible {
|
||||
private let components: [Int]
|
||||
|
||||
init?(_ rawValue: String) {
|
||||
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("v") || value.hasPrefix("V") {
|
||||
value.removeFirst()
|
||||
}
|
||||
|
||||
let prefix = value.prefix { character in
|
||||
character.isNumber || character == "."
|
||||
}
|
||||
|
||||
let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
guard !version.isEmpty else { return nil }
|
||||
|
||||
let components = version.split(separator: ".").compactMap { Int($0) }
|
||||
guard !components.isEmpty, components.count == version.split(separator: ".").count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.components = components
|
||||
}
|
||||
|
||||
var description: String {
|
||||
components.map(String.init).joined(separator: ".")
|
||||
}
|
||||
|
||||
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func == (lhs: AppVersion, rhs: AppVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
|
||||
if left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension JSONDecoder {
|
||||
static var githubReleaseDecoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -1,259 +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: "Su"
|
||||
case .monday: "Mo"
|
||||
case .tuesday: "Tu"
|
||||
case .wednesday: "We"
|
||||
case .thursday: "Th"
|
||||
case .friday: "Fr"
|
||||
case .saturday: "Sa"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var stopTimeEnabled: Bool = false
|
||||
var stopTime: Date = Calendar.current.date(bySettingHour: 8, 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)
|
||||
|
||||
let shouldRunToday: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRunToday = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRunToday = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRunToday {
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
|
||||
if settings.stopTimeEnabled {
|
||||
let stopHour = calendar.component(.hour, from: settings.stopTime)
|
||||
let stopMinute = calendar.component(.minute, from: settings.stopTime)
|
||||
|
||||
if stopHour == currentHour && stopMinute == currentMinute {
|
||||
lastTriggeredMinute = now
|
||||
pauseQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseQueues() {
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
for queueID in targetQueueIDs {
|
||||
downloadController.pauseActiveDownloads(queueID: queueID)
|
||||
}
|
||||
}
|
||||
|
||||
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,273 +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 stopTimeEnabled: Bool = false
|
||||
@State private var stopTime: 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: 20) {
|
||||
Group {
|
||||
timeSelectionSection
|
||||
queueSelectionSection
|
||||
postActionSection
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
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 {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("Timing", systemImage: "clock")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Start Time")
|
||||
.foregroundStyle(.secondary)
|
||||
DatePicker("Start", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Stop Time", isOn: $stopTimeEnabled)
|
||||
.foregroundStyle(.secondary)
|
||||
DatePicker("Stop", selection: $stopTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
.disabled(!stopTimeEnabled)
|
||||
.opacity(stopTimeEnabled ? 1.0 : 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Toggle("Run Every Day", isOn: $isEveryday)
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Button(action: {
|
||||
if selectedDays.contains(day) {
|
||||
selectedDays.remove(day)
|
||||
} else {
|
||||
selectedDays.insert(day)
|
||||
}
|
||||
}) {
|
||||
Text(day.shortName)
|
||||
.fontWeight(.medium)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.background(selectedDays.contains(day) ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundStyle(selectedDays.contains(day) ? Color.white : Color.primary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var queueSelectionSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("Queues to Schedule", systemImage: "list.bullet.rectangle")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var postActionSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("After Completion", systemImage: "powersleep")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("Choose what happens after scheduled downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("System Permissions", systemImage: "lock.shield")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
stopTimeEnabled = schedulerController.settings.stopTimeEnabled
|
||||
stopTime = schedulerController.settings.stopTime
|
||||
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.stopTimeEnabled = stopTimeEnabled
|
||||
schedulerController.settings.stopTime = stopTime
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: targetQueueIDs
|
||||
schedulerController.saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettingsPane: View {
|
||||
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
|
||||
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
|
||||
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
|
||||
private let denoURL = URL(string: "https://deno.com/")!
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
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: 16) {
|
||||
updateStatusView
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 2)
|
||||
|
||||
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.animation(.easeInOut, value: updateChecker.state)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Created by NimBold")
|
||||
Spacer()
|
||||
Link(destination: projectURL) {
|
||||
HStack(spacing: 4) {
|
||||
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
|
||||
let nsImage = NSImage(contentsOfFile: imgPath) {
|
||||
Image(nsImage: nsImage)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Source Code")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Powered by")
|
||||
HStack(spacing: 4) {
|
||||
Link("aria2", destination: aria2URL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("yt-dlp", destination: ytDlpURL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("ffmpeg", destination: ffmpegURL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("Deno", destination: denoURL)
|
||||
}
|
||||
Spacer()
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
|
||||
Text("Copyright © 2026 NimBold. All rights reserved.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var updateStatusView: some View {
|
||||
HStack(alignment: .center) {
|
||||
switch updateChecker.state {
|
||||
case .idle:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Check for Updates")
|
||||
.font(.headline)
|
||||
Text("Firelink checks GitHub Releases for new versions.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Check Now") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
|
||||
case .checking:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Checking for updates...")
|
||||
.font(.headline)
|
||||
Text("Connecting to GitHub...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.trailing, 16)
|
||||
|
||||
case .updateAvailable(let update):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink \(update.version) is available!")
|
||||
.font(.headline)
|
||||
Text("You currently have version \(appVersion).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link("View Release Notes", destination: update.releaseURL)
|
||||
.font(.caption)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Download Update") {
|
||||
NSWorkspace.shared.open(update.releaseURL)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
case .upToDate(let latestVersion, _):
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink is up to date")
|
||||
.font(.headline)
|
||||
Text("Version \(latestVersion) is the newest stable release.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Check Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
|
||||
case .failed(let message, let recovery):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(message)
|
||||
.font(.headline)
|
||||
Text(recovery)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Try Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent {
|
||||
Stepper("\(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Default connections:")
|
||||
Text("For new downloads")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
Stepper("\(settings.maxConcurrentDownloads)", value: $settings.maxConcurrentDownloads, in: 1...12)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Parallel downloads:")
|
||||
Text("Max simultaneous active files")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
HStack {
|
||||
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Global speed limit:")
|
||||
Text("0 = unlimited speed")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
Stepper("\(settings.maxAutomaticRetries)", value: $settings.maxAutomaticRetries, in: 0...10)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Automatic retries:")
|
||||
Text("If a connection fails")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: $settings.showNotifications) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Show notification when download completes")
|
||||
Text("Alerts you in Notification Center")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: $settings.playCompletionSound) {
|
||||
Text("Play sound when download completes")
|
||||
}
|
||||
.disabled(!settings.showNotifications)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct EngineSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@StateObject private var engineManager = MediaEngineManager.shared
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Binary") {
|
||||
if let url = executableURL {
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
} else {
|
||||
Text("Not found")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Core Downloader (Aria2)")
|
||||
} footer: {
|
||||
if executableURL == nil {
|
||||
Text("The bundled aria2 runtime is missing. Reinstall Firelink or rebuild its media engines.")
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Handles core HTTP/FTP and BitTorrent downloads.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
addonStatusRow(title: "Deno", state: engineManager.denoState, path: engineManager.binaryPath(for: .deno))
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
HStack {
|
||||
Spacer()
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
Text(source.rawValue).tag(source)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Media Extractors")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Powers video and audio extraction from supported sites.")
|
||||
|
||||
if settings.mediaCookieSource != .none {
|
||||
Text(settings.mediaCookieSource.statusDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
|
||||
LabeledContent(title) {
|
||||
HStack(spacing: 8) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Text("Error")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
|
||||
if let path {
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationSettingsPane: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showToast = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "puzzlepiece.extension.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Connect Browser Extension")
|
||||
.font(.title3.weight(.bold))
|
||||
Text("Capture downloads directly from your browser in three easy steps.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
// Step 1
|
||||
CompactStepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
if status == errSecSuccess {
|
||||
settings.extensionPairingToken = Data(bytes).base64EncodedString()
|
||||
} else {
|
||||
settings.extensionPairingToken = UUID().uuidString
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 2
|
||||
CompactStepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 3
|
||||
CompactStepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} header: {
|
||||
Text("Setup Guide")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
} header: {
|
||||
Text("Status")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactStepCardView: View {
|
||||
let stepNumber: Int
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let actionText: String?
|
||||
let action: (() -> Void)?
|
||||
var secondaryActionText: String? = nil
|
||||
var secondaryAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(alignment: .top) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Text Content
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(description)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
// Action Button
|
||||
VStack(spacing: 6) {
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.caption2.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||
Button(action: secondaryAction) {
|
||||
Text(secondaryActionText)
|
||||
.font(.caption2.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundColor(.primary)
|
||||
.cornerRadius(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeychainAccessCard: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
|
||||
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
|
||||
.frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
|
||||
|
||||
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
|
||||
.font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
|
||||
.font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
|
||||
|
||||
if !settings.isKeychainAccessGranted {
|
||||
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Button(role: .destructive) {
|
||||
settings.revokeKeychainAccess()
|
||||
} label: {
|
||||
Text("Revoke")
|
||||
}
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
settings.grantKeychainAccess()
|
||||
} label: {
|
||||
Text("Grant Access")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(footer: Text("When enabled, you can choose the download location each time you add a download. Otherwise, files are saved automatically.")) {
|
||||
Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
|
||||
}
|
||||
|
||||
Section(footer: Text("Folders will be created automatically when saving.")) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Folder path", text: $path, prompt: Text("Folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button("Choose...") {
|
||||
selectFolder()
|
||||
}
|
||||
}
|
||||
|
||||
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
|
||||
Text(displayMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
}
|
||||
.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 {
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Base folder path", text: $path, prompt: Text("Base folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button("Choose...") {
|
||||
selectFolder()
|
||||
}
|
||||
}
|
||||
|
||||
if !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
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,51 +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("macOS Integration") {
|
||||
Toggle("Show badge on Dock icon", isOn: $settings.showDockBadge)
|
||||
Text("Displays the number of active downloads on the Firelink Dock icon.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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,83 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NetworkSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Proxy") {
|
||||
Picker("Mode", 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)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Identity") {
|
||||
TextField("User Agent", text: $settings.customUserAgent, prompt: Text("e.g. Mozilla/5.0..."))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Text("Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var networkSummary: String {
|
||||
switch settings.proxySettings.mode {
|
||||
case .none:
|
||||
return "Downloads ignore configured proxies."
|
||||
case .system:
|
||||
return "Downloads use the matching macOS system proxy when one is configured."
|
||||
case .custom:
|
||||
if let proxyURI = settings.proxySettings.customProxyURI {
|
||||
return "Downloads use \(proxyURI)."
|
||||
} else {
|
||||
return "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,102 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,231 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var searchText = ""
|
||||
@State private var selection = Set<SiteLogin.ID>()
|
||||
@State private var editingLogin: SiteLogin?
|
||||
@State private var showEditor = false
|
||||
|
||||
var filteredLogins: [SiteLogin] {
|
||||
if searchText.isEmpty {
|
||||
return settings.siteLogins
|
||||
} else {
|
||||
return settings.siteLogins.filter {
|
||||
$0.urlPattern.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.username.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Search Logins", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(NSColor.textBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
if settings.siteLogins.isEmpty {
|
||||
ContentUnavailableView("No saved logins", systemImage: "key")
|
||||
.frame(minHeight: 250)
|
||||
} else if filteredLogins.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
.frame(minHeight: 250)
|
||||
} else {
|
||||
Table(filteredLogins, selection: $selection) {
|
||||
TableColumn("URL Pattern") { login in
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.contextMenu {
|
||||
contextMenuActions(for: login)
|
||||
}
|
||||
}
|
||||
TableColumn("Username") { login in
|
||||
Text(login.username)
|
||||
.contextMenu {
|
||||
contextMenuActions(for: login)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 200, idealHeight: 250, maxHeight: 300)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
editingLogin = nil
|
||||
showEditor = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
Divider()
|
||||
.frame(height: 16)
|
||||
|
||||
Button {
|
||||
deleteSelected()
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 4)
|
||||
.disabled(selection.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 4)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
} header: {
|
||||
Text("Saved Logins")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.sheet(isPresented: $showEditor) {
|
||||
LoginEditorSheet(login: editingLogin)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contextMenuActions(for login: SiteLogin) -> some View {
|
||||
Button("Edit") {
|
||||
editingLogin = login
|
||||
showEditor = true
|
||||
}
|
||||
Divider()
|
||||
Button("Copy Username") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(login.username, forType: .string)
|
||||
}
|
||||
Button("Copy Password") {
|
||||
if let password = KeychainCredentialStore.password(for: login.id) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(password, forType: .string)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
if let index = settings.siteLogins.firstIndex(where: { $0.id == login.id }) {
|
||||
settings.deleteSiteLogins(at: IndexSet(integer: index))
|
||||
}
|
||||
selection.remove(login.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelected() {
|
||||
let indices = settings.siteLogins.enumerated().compactMap { index, login in
|
||||
selection.contains(login.id) ? index : nil
|
||||
}
|
||||
settings.deleteSiteLogins(at: IndexSet(indices))
|
||||
selection.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginEditorSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
let login: SiteLogin?
|
||||
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(login == nil ? "Add Login" : "Edit Login")
|
||||
.font(.headline)
|
||||
.padding()
|
||||
|
||||
Form {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(login == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
|
||||
if !settings.message.isEmpty && (settings.message.hasPrefix("Add a") || settings.message.hasPrefix("A login") || settings.message.hasPrefix("Could not")) {
|
||||
Text(settings.message)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(login == nil ? "Add" : "Save") {
|
||||
settings.message = ""
|
||||
settings.saveSiteLogin(
|
||||
id: login?.id,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 400)
|
||||
.onAppear {
|
||||
if let login = login {
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
}
|
||||
settings.message = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastNotification: ViewModifier {
|
||||
var message: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
|
||||
if isShowing {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.zIndex(1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
|
||||
.task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
withAnimation {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String) -> some View {
|
||||
self.modifier(ToastNotification(message: message, isShowing: isShowing))
|
||||
}
|
||||
}
|
||||
@@ -1,224 +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 SidebarSelection: Hashable {
|
||||
case downloads(DownloadSidebarFilter)
|
||||
case queue(UUID)
|
||||
case scheduler
|
||||
case speedLimiter
|
||||
case settings
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
@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")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.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 ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : 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(.all)
|
||||
}
|
||||
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)))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func queueRow(for queue: DownloadQueue) -> some View {
|
||||
let row = 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
|
||||
)
|
||||
)
|
||||
|
||||
if queue.isMain {
|
||||
row
|
||||
} else {
|
||||
row.contextMenu {
|
||||
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,173 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SpeedUnit: String, CaseIterable, Identifiable {
|
||||
case kbs = "KB/s"
|
||||
case mbs = "MB/s"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
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 displayedSpeedValue: Int = 1
|
||||
@State private var limitUnit: SpeedUnit = .mbs
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox {
|
||||
limitSelectionSection
|
||||
.padding(8)
|
||||
}
|
||||
.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: 20) {
|
||||
HStack {
|
||||
Label("Global Speed Limit", systemImage: "speedometer")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
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(spacing: 16) {
|
||||
TextField("Speed", value: $displayedSpeedValue, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
Picker("Unit", selection: $limitUnit) {
|
||||
ForEach(SpeedUnit.allCases) { unit in
|
||||
Text(unit.rawValue).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 140)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Quick Presets")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
presetButton(title: "1 MB/s", value: 1, unit: .mbs)
|
||||
presetButton(title: "5 MB/s", value: 5, unit: .mbs)
|
||||
presetButton(title: "10 MB/s", value: 10, unit: .mbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presetButton(title: String, value: Int, unit: SpeedUnit) -> some View {
|
||||
Button(action: {
|
||||
displayedSpeedValue = value
|
||||
limitUnit = unit
|
||||
}) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
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
|
||||
let effectiveLimit = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
|
||||
|
||||
if effectiveLimit % 1024 == 0 && effectiveLimit >= 1024 {
|
||||
displayedSpeedValue = effectiveLimit / 1024
|
||||
limitUnit = .mbs
|
||||
} else {
|
||||
displayedSpeedValue = effectiveLimit
|
||||
limitUnit = .kbs
|
||||
}
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
let valueInKbs = limitUnit == .mbs ? displayedSpeedValue * 1024 : displayedSpeedValue
|
||||
let clampedSpeed = max(min(valueInKbs, 10_485_760), 1)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,893 +0,0 @@
|
||||
Usage: yt-dlp [OPTIONS] URL [URL...]
|
||||
|
||||
Options:
|
||||
|
||||
General Options:
|
||||
-h, --help Print this help text and exit
|
||||
--version Print program version and exit
|
||||
-U, --update Check if updates are available. Auto-update
|
||||
is not supported for unpackaged executables;
|
||||
Re-download the latest release
|
||||
--no-update Do not check for updates (default)
|
||||
--update-to [CHANNEL]@[TAG] Upgrade/downgrade to a specific version.
|
||||
CHANNEL can be a repository as well. CHANNEL
|
||||
and TAG default to "stable" and "latest"
|
||||
respectively if omitted; See "UPDATE" for
|
||||
details. Supported channels: stable,
|
||||
nightly, master
|
||||
-i, --ignore-errors Ignore download and postprocessing errors.
|
||||
The download will be considered successful
|
||||
even if the postprocessing fails
|
||||
--no-abort-on-error Continue with next video on download errors;
|
||||
e.g. to skip unavailable videos in a
|
||||
playlist (default)
|
||||
--abort-on-error Abort downloading of further videos if an
|
||||
error occurs (Alias: --no-ignore-errors)
|
||||
--list-extractors List all supported extractors and exit
|
||||
--extractor-descriptions Output descriptions of all supported
|
||||
extractors and exit
|
||||
--use-extractors NAMES Extractor names to use separated by commas.
|
||||
You can also use regexes, "all", "default"
|
||||
and "end" (end URL matching); e.g. --ies
|
||||
"holodex.*,end,youtube". Prefix the name
|
||||
with a "-" to exclude it, e.g. --ies
|
||||
default,-generic. Use --list-extractors for
|
||||
a list of extractor names. (Alias: --ies)
|
||||
--default-search PREFIX Use this prefix for unqualified URLs. E.g.
|
||||
"gvsearch2:python" downloads two videos from
|
||||
google videos for the search term "python".
|
||||
Use the value "auto" to let yt-dlp guess
|
||||
("auto_warning" to emit a warning when
|
||||
guessing). "error" just throws an error. The
|
||||
default value "fixup_error" repairs broken
|
||||
URLs, but emits an error if this is not
|
||||
possible instead of searching
|
||||
--ignore-config Don't load any more configuration files
|
||||
except those given to --config-locations.
|
||||
For backward compatibility, if this option
|
||||
is found inside the system configuration
|
||||
file, the user configuration is not loaded.
|
||||
(Alias: --no-config)
|
||||
--no-config-locations Do not load any custom configuration files
|
||||
(default). When given inside a configuration
|
||||
file, ignore all previous --config-locations
|
||||
defined in the current file
|
||||
--config-locations PATH Location of the main configuration file;
|
||||
either the path to the config or its
|
||||
containing directory ("-" for stdin). Can be
|
||||
used multiple times and inside other
|
||||
configuration files
|
||||
--plugin-dirs DIR Path to an additional directory to search
|
||||
for plugins. This option can be used
|
||||
multiple times to add multiple directories.
|
||||
Use "default" to search the default plugin
|
||||
directories (default)
|
||||
--no-plugin-dirs Clear plugin directories to search,
|
||||
including defaults and those provided by
|
||||
previous --plugin-dirs
|
||||
--js-runtimes RUNTIME[:PATH] Additional JavaScript runtime to enable,
|
||||
with an optional location for the runtime
|
||||
(either the path to the binary or its
|
||||
containing directory). This option can be
|
||||
used multiple times to enable multiple
|
||||
runtimes. Supported runtimes are (in order
|
||||
of priority, from highest to lowest): deno,
|
||||
node, quickjs, bun. Only "deno" is enabled
|
||||
by default. The highest priority runtime
|
||||
that is both enabled and available will be
|
||||
used. In order to use a lower priority
|
||||
runtime when "deno" is available, --no-js-
|
||||
runtimes needs to be passed before enabling
|
||||
other runtimes
|
||||
--no-js-runtimes Clear JavaScript runtimes to enable,
|
||||
including defaults and those provided by
|
||||
previous --js-runtimes
|
||||
--remote-components COMPONENT Remote components to allow yt-dlp to fetch
|
||||
when required. This option is currently not
|
||||
needed if you are using an official
|
||||
executable or have the requisite version of
|
||||
the yt-dlp-ejs package installed. You can
|
||||
use this option multiple times to allow
|
||||
multiple components. Supported values:
|
||||
ejs:npm (external JavaScript components from
|
||||
npm), ejs:github (external JavaScript
|
||||
components from yt-dlp-ejs GitHub). By
|
||||
default, no remote components are allowed
|
||||
--no-remote-components Disallow fetching of all remote components,
|
||||
including any previously allowed by
|
||||
--remote-components or defaults.
|
||||
--flat-playlist Do not extract a playlist's URL result
|
||||
entries; some entry metadata may be missing
|
||||
and downloading may be bypassed
|
||||
--no-flat-playlist Fully extract the videos of a playlist
|
||||
(default)
|
||||
--live-from-start Download livestreams from the start.
|
||||
Currently experimental and only supported
|
||||
for YouTube, Twitch, and TVer
|
||||
--no-live-from-start Download livestreams from the current time
|
||||
(default)
|
||||
--wait-for-video MIN[-MAX] Wait for scheduled streams to become
|
||||
available. Pass the minimum number of
|
||||
seconds (or range) to wait between retries
|
||||
--no-wait-for-video Do not wait for scheduled streams (default)
|
||||
--mark-watched Mark videos watched (even with --simulate)
|
||||
--no-mark-watched Do not mark videos watched (default)
|
||||
--color [STREAM:]POLICY Whether to emit color codes in output,
|
||||
optionally prefixed by the STREAM (stdout or
|
||||
stderr) to apply the setting to. Can be one
|
||||
of "always", "auto" (default), "never", or
|
||||
"no_color" (use non color terminal
|
||||
sequences). Use "auto-tty" or "no_color-tty"
|
||||
to decide based on terminal support only.
|
||||
Can be used multiple times
|
||||
--compat-options OPTS Options that can help keep compatibility
|
||||
with youtube-dl or youtube-dlc
|
||||
configurations by reverting some of the
|
||||
changes made in yt-dlp. See "Differences in
|
||||
default behavior" for details
|
||||
--alias ALIASES OPTIONS Create aliases for an option string. Unless
|
||||
an alias starts with a dash "-", it is
|
||||
prefixed with "--". Arguments are parsed
|
||||
according to the Python string formatting
|
||||
mini-language. E.g. --alias get-audio,-X "-S
|
||||
aext:{0},abr -x --audio-format {0}" creates
|
||||
options "--get-audio" and "-X" that takes an
|
||||
argument (ARG0) and expands to "-S
|
||||
aext:ARG0,abr -x --audio-format ARG0". All
|
||||
defined aliases are listed in the --help
|
||||
output. Alias options can trigger more
|
||||
aliases; so be careful to avoid defining
|
||||
recursive options. As a safety measure, each
|
||||
alias may be triggered a maximum of 100
|
||||
times. This option can be used multiple
|
||||
times
|
||||
-t, --preset-alias PRESET Applies a predefined set of options. e.g.
|
||||
--preset-alias mp3. The following presets
|
||||
are available: mp3, aac, mp4, mkv, sleep.
|
||||
See the "Preset Aliases" section at the end
|
||||
for more info. This option can be used
|
||||
multiple times
|
||||
|
||||
Network Options:
|
||||
--proxy URL Use the specified HTTP/HTTPS/SOCKS proxy. To
|
||||
enable SOCKS proxy, specify a proper scheme,
|
||||
e.g. socks5://user:pass@127.0.0.1:1080/.
|
||||
Pass in an empty string (--proxy "") for
|
||||
direct connection
|
||||
--socket-timeout SECONDS Time to wait before giving up, in seconds
|
||||
--source-address IP Client-side IP address to bind to
|
||||
--impersonate CLIENT[:OS] Client to impersonate for requests. E.g.
|
||||
chrome, chrome-110, chrome:windows-10. Pass
|
||||
--impersonate="" to impersonate any client.
|
||||
Note that forcing impersonation for all
|
||||
requests may have a detrimental impact on
|
||||
download speed and stability
|
||||
--list-impersonate-targets List available clients to impersonate.
|
||||
-4, --force-ipv4 Make all connections via IPv4
|
||||
-6, --force-ipv6 Make all connections via IPv6
|
||||
--enable-file-urls Enable file:// URLs. This is disabled by
|
||||
default for security reasons.
|
||||
|
||||
Geo-restriction:
|
||||
--geo-verification-proxy URL Use this proxy to verify the IP address for
|
||||
some geo-restricted sites. The default proxy
|
||||
specified by --proxy (or none, if the option
|
||||
is not present) is used for the actual
|
||||
downloading
|
||||
--xff VALUE How to fake X-Forwarded-For HTTP header to
|
||||
try bypassing geographic restriction. One of
|
||||
"default" (only when known to be useful),
|
||||
"never", an IP block in CIDR notation, or a
|
||||
two-letter ISO 3166-2 country code
|
||||
|
||||
Video Selection:
|
||||
-I, --playlist-items ITEM_SPEC Comma-separated playlist_index of the items
|
||||
to download. You can specify a range using
|
||||
"[START]:[STOP][:STEP]". For backward
|
||||
compatibility, START-STOP is also supported.
|
||||
Use negative indices to count from the right
|
||||
and negative STEP to download in reverse
|
||||
order. E.g. "-I 1:3,7,-5::2" used on a
|
||||
playlist of size 15 will download the items
|
||||
at index 1,2,3,7,11,13,15
|
||||
--min-filesize SIZE Abort download if filesize is smaller than
|
||||
SIZE, e.g. 50k or 44.6M
|
||||
--max-filesize SIZE Abort download if filesize is larger than
|
||||
SIZE, e.g. 50k or 44.6M
|
||||
--date DATE Download only videos uploaded on this date.
|
||||
The date can be "YYYYMMDD" or in the format
|
||||
[now|today|yesterday][-
|
||||
N[day|week|month|year]]. E.g. "--date
|
||||
today-2weeks" downloads only videos uploaded
|
||||
on the same day two weeks ago
|
||||
--datebefore DATE Download only videos uploaded on or before
|
||||
this date. The date formats accepted are the
|
||||
same as --date
|
||||
--dateafter DATE Download only videos uploaded on or after
|
||||
this date. The date formats accepted are the
|
||||
same as --date
|
||||
--match-filters FILTER Generic video filter. Any "OUTPUT TEMPLATE"
|
||||
field can be compared with a number or a
|
||||
string using the operators defined in
|
||||
"Filtering Formats". You can also simply
|
||||
specify a field to match if the field is
|
||||
present, use "!field" to check if the field
|
||||
is not present, and "&" to check multiple
|
||||
conditions. Use a "\" to escape "&" or
|
||||
quotes if needed. If used multiple times,
|
||||
the filter matches if at least one of the
|
||||
conditions is met. E.g. --match-filters
|
||||
!is_live --match-filters "like_count>?100 &
|
||||
description~='(?i)\bcats \& dogs\b'" matches
|
||||
only videos that are not live OR those that
|
||||
have a like count more than 100 (or the like
|
||||
field is not available) and also has a
|
||||
description that contains the phrase "cats &
|
||||
dogs" (caseless). Use "--match-filters -" to
|
||||
interactively ask whether to download each
|
||||
video
|
||||
--no-match-filters Do not use any --match-filters (default)
|
||||
--break-match-filters FILTER Same as "--match-filters" but stops the
|
||||
download process when a video is rejected
|
||||
--no-break-match-filters Do not use any --break-match-filters
|
||||
(default)
|
||||
--no-playlist Download only the video, if the URL refers
|
||||
to a video and a playlist
|
||||
--yes-playlist Download the playlist, if the URL refers to
|
||||
a video and a playlist
|
||||
--age-limit YEARS Download only videos suitable for the given
|
||||
age
|
||||
--download-archive FILE Download only videos not listed in the
|
||||
archive file. Record the IDs of all
|
||||
downloaded videos in it
|
||||
--no-download-archive Do not use archive file (default)
|
||||
--max-downloads NUMBER Abort after downloading NUMBER files
|
||||
--break-on-existing Stop the download process when encountering
|
||||
a file that is in the archive supplied with
|
||||
the --download-archive option
|
||||
--no-break-on-existing Do not stop the download process when
|
||||
encountering a file that is in the archive
|
||||
(default)
|
||||
--break-per-input Alters --max-downloads, --break-on-existing,
|
||||
--break-match-filters, and autonumber to
|
||||
reset per input URL
|
||||
--no-break-per-input --break-on-existing and similar options
|
||||
terminates the entire download queue
|
||||
--skip-playlist-after-errors N Number of allowed failures until the rest of
|
||||
the playlist is skipped
|
||||
|
||||
Download Options:
|
||||
-N, --concurrent-fragments N Number of fragments of a dash/hlsnative
|
||||
video that should be downloaded concurrently
|
||||
(default is 1)
|
||||
-r, --limit-rate RATE Maximum download rate in bytes per second,
|
||||
e.g. 50K or 4.2M
|
||||
--throttled-rate RATE Minimum download rate in bytes per second
|
||||
below which throttling is assumed and the
|
||||
video data is re-extracted, e.g. 100K
|
||||
-R, --retries RETRIES Number of retries (default is 10), or
|
||||
"infinite"
|
||||
--file-access-retries RETRIES Number of times to retry on file access
|
||||
error (default is 3), or "infinite"
|
||||
--fragment-retries RETRIES Number of retries for a fragment (default is
|
||||
10), or "infinite" (DASH, hlsnative and ISM)
|
||||
--retry-sleep [TYPE:]EXPR Time to sleep between retries in seconds
|
||||
(optionally) prefixed by the type of retry
|
||||
(http (default), fragment, file_access,
|
||||
extractor) to apply the sleep to. EXPR can
|
||||
be a number, linear=START[:END[:STEP=1]] or
|
||||
exp=START[:END[:BASE=2]]. This option can be
|
||||
used multiple times to set the sleep for the
|
||||
different retry types, e.g. --retry-sleep
|
||||
linear=1::2 --retry-sleep fragment:exp=1:20
|
||||
--skip-unavailable-fragments Skip unavailable fragments for DASH,
|
||||
hlsnative and ISM downloads (default)
|
||||
(Alias: --no-abort-on-unavailable-fragments)
|
||||
--abort-on-unavailable-fragments
|
||||
Abort download if a fragment is unavailable
|
||||
(Alias: --no-skip-unavailable-fragments)
|
||||
--keep-fragments Keep downloaded fragments on disk after
|
||||
downloading is finished
|
||||
--no-keep-fragments Delete downloaded fragments after
|
||||
downloading is finished (default)
|
||||
--buffer-size SIZE Size of download buffer, e.g. 1024 or 16K
|
||||
(default is 1024)
|
||||
--resize-buffer The buffer size is automatically resized
|
||||
from an initial value of --buffer-size
|
||||
(default)
|
||||
--no-resize-buffer Do not automatically adjust the buffer size
|
||||
--http-chunk-size SIZE Size of a chunk for chunk-based HTTP
|
||||
downloading, e.g. 10485760 or 10M (default
|
||||
is disabled). May be useful for bypassing
|
||||
bandwidth throttling imposed by a webserver
|
||||
(experimental)
|
||||
--playlist-random Download playlist videos in random order
|
||||
--lazy-playlist Process entries in the playlist as they are
|
||||
received. This disables n_entries,
|
||||
--playlist-random and --playlist-reverse
|
||||
--no-lazy-playlist Process videos in the playlist only after
|
||||
the entire playlist is parsed (default)
|
||||
--hls-use-mpegts Use the mpegts container for HLS videos;
|
||||
allowing some players to play the video
|
||||
while downloading, and reducing the chance
|
||||
of file corruption if download is
|
||||
interrupted. This is enabled by default for
|
||||
live streams
|
||||
--no-hls-use-mpegts Do not use the mpegts container for HLS
|
||||
videos. This is default when not downloading
|
||||
live streams
|
||||
--download-sections REGEX Download only chapters that match the
|
||||
regular expression. A "*" prefix denotes
|
||||
time-range instead of chapter. Negative
|
||||
timestamps are calculated from the end.
|
||||
"*from-url" can be used to download between
|
||||
the "start_time" and "end_time" extracted
|
||||
from the URL. Needs ffmpeg. This option can
|
||||
be used multiple times to download multiple
|
||||
sections, e.g. --download-sections
|
||||
"*10:15-inf" --download-sections "intro"
|
||||
--downloader [PROTO:]NAME Name or path of the external downloader to
|
||||
use (optionally) prefixed by the protocols
|
||||
(http, ftp, m3u8, dash, rstp, rtmp, mms) to
|
||||
use it for. Currently supports native,
|
||||
aria2c, axel, curl, ffmpeg, httpie, wget.
|
||||
You can use this option multiple times to
|
||||
set different downloaders for different
|
||||
protocols. E.g. --downloader aria2c
|
||||
--downloader "dash,m3u8:native" will use
|
||||
aria2c for http/ftp downloads, and the
|
||||
native downloader for dash/m3u8 downloads
|
||||
(Alias: --external-downloader)
|
||||
--downloader-args NAME:ARGS Give these arguments to the external
|
||||
downloader. Specify the downloader name and
|
||||
the arguments separated by a colon ":". For
|
||||
ffmpeg, arguments can be passed to different
|
||||
positions using the same syntax as
|
||||
--postprocessor-args. You can use this
|
||||
option multiple times to give different
|
||||
arguments to different downloaders (Alias:
|
||||
--external-downloader-args)
|
||||
|
||||
Filesystem Options:
|
||||
-a, --batch-file FILE File containing URLs to download ("-" for
|
||||
stdin), one URL per line. Lines starting
|
||||
with "#", ";" or "]" are considered as
|
||||
comments and ignored
|
||||
--no-batch-file Do not read URLs from batch file (default)
|
||||
-P, --paths [TYPES:]PATH The paths where the files should be
|
||||
downloaded. Specify the type of file and the
|
||||
path separated by a colon ":". All the same
|
||||
TYPES as --output are supported.
|
||||
Additionally, you can also provide "home"
|
||||
(default) and "temp" paths. All intermediary
|
||||
files are first downloaded to the temp path
|
||||
and then the final files are moved over to
|
||||
the home path after download is finished.
|
||||
This option is ignored if --output is an
|
||||
absolute path
|
||||
-o, --output [TYPES:]TEMPLATE Output filename template; see "OUTPUT
|
||||
TEMPLATE" for details
|
||||
--output-na-placeholder TEXT Placeholder for unavailable fields in
|
||||
--output (default: "NA")
|
||||
--restrict-filenames Restrict filenames to only ASCII characters,
|
||||
and avoid "&" and spaces in filenames
|
||||
--no-restrict-filenames Allow Unicode characters, "&" and spaces in
|
||||
filenames (default)
|
||||
--windows-filenames Force filenames to be Windows-compatible
|
||||
--no-windows-filenames Sanitize filenames only minimally
|
||||
--trim-filenames LENGTH Limit the filename length (excluding
|
||||
extension) to the specified number of
|
||||
characters
|
||||
-w, --no-overwrites Do not overwrite any files
|
||||
--force-overwrites Overwrite all video and metadata files. This
|
||||
option includes --no-continue
|
||||
--no-force-overwrites Do not overwrite the video, but overwrite
|
||||
related files (default)
|
||||
-c, --continue Resume partially downloaded files/fragments
|
||||
(default)
|
||||
--no-continue Do not resume partially downloaded
|
||||
fragments. If the file is not fragmented,
|
||||
restart download of the entire file
|
||||
--part Use .part files instead of writing directly
|
||||
into output file (default)
|
||||
--no-part Do not use .part files - write directly into
|
||||
output file
|
||||
--mtime Use the Last-modified header to set the file
|
||||
modification time
|
||||
--no-mtime Do not use the Last-modified header to set
|
||||
the file modification time (default)
|
||||
--write-description Write video description to a .description
|
||||
file
|
||||
--no-write-description Do not write video description (default)
|
||||
--write-info-json Write video metadata to a .info.json file
|
||||
(this may contain personal information)
|
||||
--no-write-info-json Do not write video metadata (default)
|
||||
--write-playlist-metafiles Write playlist metadata in addition to the
|
||||
video metadata when using --write-info-json,
|
||||
--write-description etc. (default)
|
||||
--no-write-playlist-metafiles Do not write playlist metadata when using
|
||||
--write-info-json, --write-description etc.
|
||||
--clean-info-json Remove some internal metadata such as
|
||||
filenames from the infojson (default)
|
||||
--no-clean-info-json Write all fields to the infojson
|
||||
--write-comments Retrieve video comments to be placed in the
|
||||
infojson. The comments are fetched even
|
||||
without this option if the extraction is
|
||||
known to be quick (Alias: --get-comments)
|
||||
--no-write-comments Do not retrieve video comments unless the
|
||||
extraction is known to be quick (Alias:
|
||||
--no-get-comments)
|
||||
--load-info-json FILE JSON file containing the video information
|
||||
(created with the "--write-info-json"
|
||||
option)
|
||||
--cookies FILE Netscape formatted file to read cookies from
|
||||
and dump cookie jar in
|
||||
--no-cookies Do not read/dump cookies from/to file
|
||||
(default)
|
||||
--cookies-from-browser BROWSER[+KEYRING][:PROFILE][::CONTAINER]
|
||||
The name of the browser to load cookies
|
||||
from. Currently supported browsers are:
|
||||
brave, chrome, chromium, edge, firefox,
|
||||
opera, safari, vivaldi, whale. Optionally,
|
||||
the KEYRING used for decrypting Chromium
|
||||
cookies on Linux, the name/path of the
|
||||
PROFILE to load cookies from, and the
|
||||
CONTAINER name (if Firefox) ("none" for no
|
||||
container) can be given with their
|
||||
respective separators. By default, all
|
||||
containers of the most recently accessed
|
||||
profile are used. Currently supported
|
||||
keyrings are: basictext, gnomekeyring,
|
||||
kwallet, kwallet5, kwallet6
|
||||
--no-cookies-from-browser Do not load cookies from browser (default)
|
||||
--cache-dir DIR Location in the filesystem where yt-dlp can
|
||||
store some downloaded information (such as
|
||||
client ids and signatures) permanently. By
|
||||
default ${XDG_CACHE_HOME}/yt-dlp
|
||||
--no-cache-dir Disable filesystem caching
|
||||
--rm-cache-dir Delete all filesystem cache files
|
||||
|
||||
Thumbnail Options:
|
||||
--write-thumbnail Write thumbnail image to disk
|
||||
--no-write-thumbnail Do not write thumbnail image to disk
|
||||
(default)
|
||||
--write-all-thumbnails Write all thumbnail image formats to disk
|
||||
--list-thumbnails List available thumbnails of each video.
|
||||
Simulate unless --no-simulate is used
|
||||
|
||||
Internet Shortcut Options:
|
||||
--write-link Write an internet shortcut file, depending
|
||||
on the current platform (.url, .webloc or
|
||||
.desktop). The URL may be cached by the OS
|
||||
--write-url-link Write a .url Windows internet shortcut. The
|
||||
OS caches the URL based on the file path
|
||||
--write-webloc-link Write a .webloc macOS internet shortcut
|
||||
--write-desktop-link Write a .desktop Linux internet shortcut
|
||||
|
||||
Verbosity and Simulation Options:
|
||||
-q, --quiet Activate quiet mode. If used with --verbose,
|
||||
print the log to stderr
|
||||
--no-quiet Deactivate quiet mode. (Default)
|
||||
--no-warnings Ignore warnings
|
||||
-s, --simulate Do not download the video and do not write
|
||||
anything to disk
|
||||
--no-simulate Download the video even if printing/listing
|
||||
options are used
|
||||
--ignore-no-formats-error Ignore "No video formats" error. Useful for
|
||||
extracting metadata even if the videos are
|
||||
not actually available for download
|
||||
(experimental)
|
||||
--no-ignore-no-formats-error Throw error when no downloadable video
|
||||
formats are found (default)
|
||||
--skip-download Do not download the video but write all
|
||||
related files (Alias: --no-download)
|
||||
-O, --print [WHEN:]TEMPLATE Field name or output template to print to
|
||||
screen, optionally prefixed with when to
|
||||
print it, separated by a ":". Supported
|
||||
values of "WHEN" are the same as that of
|
||||
--use-postprocessor (default: video).
|
||||
Implies --quiet. Implies --simulate unless
|
||||
--no-simulate or later stages of WHEN are
|
||||
used. This option can be used multiple times
|
||||
--print-to-file [WHEN:]TEMPLATE FILE
|
||||
Append given template to the file. The
|
||||
values of WHEN and TEMPLATE are the same as
|
||||
that of --print. FILE uses the same syntax
|
||||
as the output template. This option can be
|
||||
used multiple times
|
||||
-j, --dump-json Quiet, but print JSON information for each
|
||||
video. Simulate unless --no-simulate is
|
||||
used. See "OUTPUT TEMPLATE" for a
|
||||
description of available keys
|
||||
-J, --dump-single-json Quiet, but print JSON information for each
|
||||
URL or infojson passed. Simulate unless
|
||||
--no-simulate is used. If the URL refers to
|
||||
a playlist, the whole playlist information
|
||||
is dumped in a single line
|
||||
--force-write-archive Force download archive entries to be written
|
||||
as far as no errors occur, even if -s or
|
||||
another simulation option is used (Alias:
|
||||
--force-download-archive)
|
||||
--newline Output progress bar as new lines
|
||||
--no-progress Do not print progress bar
|
||||
--progress Show progress bar, even if in quiet mode
|
||||
--console-title Display progress in console titlebar
|
||||
--progress-template [TYPES:]TEMPLATE
|
||||
Template for progress outputs, optionally
|
||||
prefixed with one of "download:" (default),
|
||||
"download-title:" (the console title),
|
||||
"postprocess:", or "postprocess-title:".
|
||||
The video's fields are accessible under the
|
||||
"info" key and the progress attributes are
|
||||
accessible under "progress" key. E.g.
|
||||
--console-title --progress-template
|
||||
"download-
|
||||
title:%(info.id)s-%(progress.eta)s"
|
||||
--progress-delta SECONDS Time between progress output (default: 0)
|
||||
-v, --verbose Print various debugging information
|
||||
--dump-pages Print downloaded pages encoded using base64
|
||||
to debug problems (very verbose)
|
||||
--write-pages Write downloaded intermediary pages to files
|
||||
in the current directory to debug problems
|
||||
--print-traffic Display sent and read HTTP traffic
|
||||
|
||||
Workarounds:
|
||||
--encoding ENCODING Force the specified encoding (experimental)
|
||||
--legacy-server-connect Explicitly allow HTTPS connection to servers
|
||||
that do not support RFC 5746 secure
|
||||
renegotiation
|
||||
--no-check-certificates Suppress HTTPS certificate validation
|
||||
--prefer-insecure Use an unencrypted connection to retrieve
|
||||
information about the video (Currently
|
||||
supported only for YouTube)
|
||||
--add-headers FIELD:VALUE Specify a custom HTTP header and its value,
|
||||
separated by a colon ":". You can use this
|
||||
option multiple times
|
||||
--bidi-workaround Work around terminals that lack
|
||||
bidirectional text support. Requires bidiv
|
||||
or fribidi executable in PATH
|
||||
--sleep-requests SECONDS Number of seconds to sleep between requests
|
||||
during data extraction
|
||||
--sleep-interval SECONDS Number of seconds to sleep before each
|
||||
download. This is the minimum time to sleep
|
||||
when used along with --max-sleep-interval
|
||||
(Alias: --min-sleep-interval)
|
||||
--max-sleep-interval SECONDS Maximum number of seconds to sleep. Can only
|
||||
be used along with --min-sleep-interval
|
||||
--sleep-subtitles SECONDS Number of seconds to sleep before each
|
||||
subtitle download
|
||||
|
||||
Video Format Options:
|
||||
-f, --format FORMAT Video format code, see "FORMAT SELECTION"
|
||||
for more details
|
||||
-S, --format-sort SORTORDER Sort the formats by the fields given, see
|
||||
"Sorting Formats" for more details
|
||||
--format-sort-reset Disregard previous user specified sort order
|
||||
and reset to the default
|
||||
--format-sort-force Force user specified sort order to have
|
||||
precedence over all fields, see "Sorting
|
||||
Formats" for more details (Alias: --S-force)
|
||||
--no-format-sort-force Some fields have precedence over the user
|
||||
specified sort order (default)
|
||||
--video-multistreams Allow multiple video streams to be merged
|
||||
into a single file
|
||||
--no-video-multistreams Only one video stream is downloaded for each
|
||||
output file (default)
|
||||
--audio-multistreams Allow multiple audio streams to be merged
|
||||
into a single file
|
||||
--no-audio-multistreams Only one audio stream is downloaded for each
|
||||
output file (default)
|
||||
--prefer-free-formats Prefer video formats with free containers
|
||||
over non-free ones of the same quality. Use
|
||||
with "-S ext" to strictly prefer free
|
||||
containers irrespective of quality
|
||||
--no-prefer-free-formats Don't give any special preference to free
|
||||
containers (default)
|
||||
--check-formats Make sure formats are selected only from
|
||||
those that are actually downloadable
|
||||
--check-all-formats Check all formats for whether they are
|
||||
actually downloadable
|
||||
--no-check-formats Do not check that the formats are actually
|
||||
downloadable
|
||||
-F, --list-formats List available formats of each video.
|
||||
Simulate unless --no-simulate is used
|
||||
--merge-output-format FORMAT Containers that may be used when merging
|
||||
formats, separated by "/", e.g. "mp4/mkv".
|
||||
Ignored if no merge is required. (currently
|
||||
supported: avi, flv, mkv, mov, mp4, webm)
|
||||
|
||||
Subtitle Options:
|
||||
--write-subs Write subtitle file
|
||||
--no-write-subs Do not write subtitle file (default)
|
||||
--write-auto-subs Write automatically generated subtitle file
|
||||
(Alias: --write-automatic-subs)
|
||||
--no-write-auto-subs Do not write auto-generated subtitles
|
||||
(default) (Alias: --no-write-automatic-subs)
|
||||
--list-subs List available subtitles of each video.
|
||||
Simulate unless --no-simulate is used
|
||||
--sub-format FORMAT Subtitle format; accepts formats preference
|
||||
separated by "/", e.g. "srt" or
|
||||
"ass/srt/best"
|
||||
--sub-langs LANGS Languages of the subtitles to download (can
|
||||
be regex) or "all" separated by commas, e.g.
|
||||
--sub-langs "en.*,ja" (where "en.*" is a
|
||||
regex pattern that matches "en" followed by
|
||||
0 or more of any character). You can prefix
|
||||
the language code with a "-" to exclude it
|
||||
from the requested languages, e.g. --sub-
|
||||
langs all,-live_chat. Use --list-subs for a
|
||||
list of available language tags
|
||||
|
||||
Authentication Options:
|
||||
-u, --username USERNAME Login with this account ID
|
||||
-p, --password PASSWORD Account password. If this option is left
|
||||
out, yt-dlp will ask interactively
|
||||
-2, --twofactor TWOFACTOR Two-factor authentication code
|
||||
-n, --netrc Use .netrc authentication data
|
||||
--netrc-location PATH Location of .netrc authentication data;
|
||||
either the path or its containing directory.
|
||||
Defaults to ~/.netrc
|
||||
--netrc-cmd NETRC_CMD Command to execute to get the credentials
|
||||
for an extractor.
|
||||
--video-password PASSWORD Video-specific password
|
||||
--ap-mso MSO Adobe Pass multiple-system operator (TV
|
||||
provider) identifier, use --ap-list-mso for
|
||||
a list of available MSOs
|
||||
--ap-username USERNAME Multiple-system operator account login
|
||||
--ap-password PASSWORD Multiple-system operator account password.
|
||||
If this option is left out, yt-dlp will ask
|
||||
interactively
|
||||
--ap-list-mso List all supported multiple-system operators
|
||||
--client-certificate CERTFILE Path to client certificate file in PEM
|
||||
format. May include the private key
|
||||
--client-certificate-key KEYFILE
|
||||
Path to private key file for client
|
||||
certificate
|
||||
--client-certificate-password PASSWORD
|
||||
Password for client certificate private key,
|
||||
if encrypted. If not provided, and the key
|
||||
is encrypted, yt-dlp will ask interactively
|
||||
|
||||
Post-Processing Options:
|
||||
-x, --extract-audio Convert video files to audio-only files
|
||||
(requires ffmpeg and ffprobe)
|
||||
--audio-format FORMAT Format to convert the audio to when -x is
|
||||
used. (currently supported: best (default),
|
||||
aac, alac, flac, m4a, mp3, opus, vorbis,
|
||||
wav). You can specify multiple rules using
|
||||
similar syntax as --remux-video
|
||||
--audio-quality QUALITY Specify ffmpeg audio quality to use when
|
||||
converting the audio with -x. Insert a value
|
||||
between 0 (best) and 10 (worst) for VBR or a
|
||||
specific bitrate like 128K (default 5)
|
||||
--remux-video FORMAT Remux the video into another container if
|
||||
necessary (currently supported: avi, flv,
|
||||
gif, mkv, mov, mp4, webm, aac, aiff, alac,
|
||||
flac, m4a, mka, mp3, ogg, opus, vorbis,
|
||||
wav). If the target container does not
|
||||
support the video/audio codec, remuxing will
|
||||
fail. You can specify multiple rules; e.g.
|
||||
"aac>m4a/mov>mp4/mkv" will remux aac to m4a,
|
||||
mov to mp4 and anything else to mkv
|
||||
--recode-video FORMAT Re-encode the video into another format if
|
||||
necessary. The syntax and supported formats
|
||||
are the same as --remux-video
|
||||
--postprocessor-args NAME:ARGS Give these arguments to the postprocessors.
|
||||
Specify the postprocessor/executable name
|
||||
and the arguments separated by a colon ":"
|
||||
to give the argument to the specified
|
||||
postprocessor/executable. Supported PP are:
|
||||
Merger, ModifyChapters, SplitChapters,
|
||||
ExtractAudio, VideoRemuxer, VideoConvertor,
|
||||
Metadata, EmbedSubtitle, EmbedThumbnail,
|
||||
SubtitlesConvertor, ThumbnailsConvertor,
|
||||
FixupStretched, FixupM4a, FixupM3u8,
|
||||
FixupTimestamp and FixupDuration. The
|
||||
supported executables are: AtomicParsley,
|
||||
FFmpeg and FFprobe. You can also specify
|
||||
"PP+EXE:ARGS" to give the arguments to the
|
||||
specified executable only when being used by
|
||||
the specified postprocessor. Additionally,
|
||||
for ffmpeg/ffprobe, "_i"/"_o" can be
|
||||
appended to the prefix optionally followed
|
||||
by a number to pass the argument before the
|
||||
specified input/output file, e.g. --ppa
|
||||
"Merger+ffmpeg_i1:-v quiet". You can use
|
||||
this option multiple times to give different
|
||||
arguments to different postprocessors.
|
||||
(Alias: --ppa)
|
||||
-k, --keep-video Keep the intermediate video file on disk
|
||||
after post-processing
|
||||
--no-keep-video Delete the intermediate video file after
|
||||
post-processing (default)
|
||||
--post-overwrites Overwrite post-processed files (default)
|
||||
--no-post-overwrites Do not overwrite post-processed files
|
||||
--embed-subs Embed subtitles in the video (only for mp4,
|
||||
webm and mkv videos)
|
||||
--no-embed-subs Do not embed subtitles (default)
|
||||
--embed-thumbnail Embed thumbnail in the video as cover art
|
||||
--no-embed-thumbnail Do not embed thumbnail (default)
|
||||
--embed-metadata Embed metadata to the video file. Also
|
||||
embeds chapters/infojson if present unless
|
||||
--no-embed-chapters/--no-embed-info-json are
|
||||
used (Alias: --add-metadata)
|
||||
--no-embed-metadata Do not add metadata to file (default)
|
||||
(Alias: --no-add-metadata)
|
||||
--embed-chapters Add chapter markers to the video file
|
||||
(Alias: --add-chapters)
|
||||
--no-embed-chapters Do not add chapter markers (default) (Alias:
|
||||
--no-add-chapters)
|
||||
--embed-info-json Embed the infojson as an attachment to
|
||||
mkv/mka video files
|
||||
--no-embed-info-json Do not embed the infojson as an attachment
|
||||
to the video file
|
||||
--parse-metadata [WHEN:]FROM:TO
|
||||
Parse additional metadata like title/artist
|
||||
from other fields; see "MODIFYING METADATA"
|
||||
for details. Supported values of "WHEN" are
|
||||
the same as that of --use-postprocessor
|
||||
(default: pre_process)
|
||||
--replace-in-metadata [WHEN:]FIELDS REGEX REPLACE
|
||||
Replace text in a metadata field using the
|
||||
given regex. This option can be used
|
||||
multiple times. Supported values of "WHEN"
|
||||
are the same as that of --use-postprocessor
|
||||
(default: pre_process)
|
||||
--xattrs Write metadata to the video file's xattrs
|
||||
(using Dublin Core and XDG standards)
|
||||
--concat-playlist POLICY Concatenate videos in a playlist. One of
|
||||
"never", "always", or "multi_video"
|
||||
(default; only when the videos form a single
|
||||
show). All the video files must have the
|
||||
same codecs and number of streams to be
|
||||
concatenable. The "pl_video:" prefix can be
|
||||
used with "--paths" and "--output" to set
|
||||
the output filename for the concatenated
|
||||
files. See "OUTPUT TEMPLATE" for details
|
||||
--fixup POLICY Automatically correct known faults of the
|
||||
file. One of never (do nothing), warn (only
|
||||
emit a warning), detect_or_warn (the
|
||||
default; fix the file if we can, warn
|
||||
otherwise), force (try fixing even if the
|
||||
file already exists)
|
||||
--ffmpeg-location PATH Location of the ffmpeg binary; either the
|
||||
path to the binary or its containing
|
||||
directory
|
||||
--exec [WHEN:]CMD Execute a command, optionally prefixed with
|
||||
when to execute it, separated by a ":".
|
||||
Supported values of "WHEN" are the same as
|
||||
that of --use-postprocessor (default:
|
||||
after_move). The same syntax as the output
|
||||
template can be used to pass any field as
|
||||
arguments to the command. If no fields are
|
||||
passed, %(filepath,_filename|)q is appended
|
||||
to the end of the command. This option can
|
||||
be used multiple times
|
||||
--no-exec Remove any previously defined --exec
|
||||
--convert-subs FORMAT Convert the subtitles to another format
|
||||
(currently supported: ass, lrc, srt, vtt).
|
||||
Use "--convert-subs none" to disable
|
||||
conversion (default) (Alias: --convert-
|
||||
subtitles)
|
||||
--convert-thumbnails FORMAT Convert the thumbnails to another format
|
||||
(currently supported: jpg, png, webp). You
|
||||
can specify multiple rules using similar
|
||||
syntax as "--remux-video". Use "--convert-
|
||||
thumbnails none" to disable conversion
|
||||
(default)
|
||||
--split-chapters Split video into multiple files based on
|
||||
internal chapters. The "chapter:" prefix can
|
||||
be used with "--paths" and "--output" to set
|
||||
the output filename for the split files. See
|
||||
"OUTPUT TEMPLATE" for details
|
||||
--no-split-chapters Do not split video based on chapters
|
||||
(default)
|
||||
--remove-chapters REGEX Remove chapters whose title matches the
|
||||
given regular expression. The syntax is the
|
||||
same as --download-sections. This option can
|
||||
be used multiple times
|
||||
--no-remove-chapters Do not remove any chapters from the file
|
||||
(default)
|
||||
--force-keyframes-at-cuts Force keyframes at cuts when
|
||||
downloading/splitting/removing sections.
|
||||
This is slow due to needing a re-encode, but
|
||||
the resulting video may have fewer artifacts
|
||||
around the cuts
|
||||
--no-force-keyframes-at-cuts Do not force keyframes around the chapters
|
||||
when cutting/splitting (default)
|
||||
--use-postprocessor NAME[:ARGS]
|
||||
The (case-sensitive) name of plugin
|
||||
postprocessors to be enabled, and
|
||||
(optionally) arguments to be passed to it,
|
||||
separated by a colon ":". ARGS are a
|
||||
semicolon ";" delimited list of NAME=VALUE.
|
||||
The "when" argument determines when the
|
||||
postprocessor is invoked. It can be one of
|
||||
"pre_process" (after video extraction),
|
||||
"after_filter" (after video passes filter),
|
||||
"video" (after --format; before
|
||||
--print/--output), "before_dl" (before each
|
||||
video download), "post_process" (after each
|
||||
video download; default), "after_move"
|
||||
(after moving the video file to its final
|
||||
location), "after_video" (after downloading
|
||||
and processing all formats of a video), or
|
||||
"playlist" (at end of playlist). This option
|
||||
can be used multiple times to add different
|
||||
postprocessors
|
||||
|
||||
SponsorBlock Options:
|
||||
Make chapter entries for, or remove various segments (sponsor,
|
||||
introductions, etc.) from downloaded YouTube videos using the
|
||||
SponsorBlock API (https://sponsor.ajay.app)
|
||||
|
||||
--sponsorblock-mark CATS SponsorBlock categories to create chapters
|
||||
for, separated by commas. Available
|
||||
categories are sponsor, intro, outro,
|
||||
selfpromo, preview, filler, interaction,
|
||||
music_offtopic, hook, poi_highlight,
|
||||
chapter, all and default (=all). You can
|
||||
prefix the category with a "-" to exclude
|
||||
it. See [1] for descriptions of the
|
||||
categories. E.g. --sponsorblock-mark
|
||||
all,-preview [1] https://wiki.sponsor.ajay.a
|
||||
pp/w/Segment_Categories
|
||||
--sponsorblock-remove CATS SponsorBlock categories to be removed from
|
||||
the video file, separated by commas. If a
|
||||
category is present in both mark and remove,
|
||||
remove takes precedence. The syntax and
|
||||
available categories are the same as for
|
||||
--sponsorblock-mark except that "default"
|
||||
refers to "all,-filler" and poi_highlight,
|
||||
chapter are not available
|
||||
--sponsorblock-chapter-title TEMPLATE
|
||||
An output template for the title of the
|
||||
SponsorBlock chapters created by
|
||||
--sponsorblock-mark. The only available
|
||||
fields are start_time, end_time, category,
|
||||
categories, name, category_names. Defaults
|
||||
to "[SponsorBlock]: %(category_names)l"
|
||||
--no-sponsorblock Disable both --sponsorblock-mark and
|
||||
--sponsorblock-remove
|
||||
--sponsorblock-api URL SponsorBlock API location, defaults to
|
||||
https://sponsor.ajay.app
|
||||
|
||||
Extractor Options:
|
||||
--extractor-retries RETRIES Number of retries for known extractor errors
|
||||
(default is 3), or "infinite"
|
||||
--allow-dynamic-mpd Process dynamic DASH manifests (default)
|
||||
(Alias: --no-ignore-dynamic-mpd)
|
||||
--ignore-dynamic-mpd Do not process dynamic DASH manifests
|
||||
(Alias: --no-allow-dynamic-mpd)
|
||||
--hls-split-discontinuity Split HLS playlists to different formats at
|
||||
discontinuities such as ad breaks
|
||||
--no-hls-split-discontinuity Do not split HLS playlists into different
|
||||
formats at discontinuities such as ad breaks
|
||||
(default)
|
||||
--extractor-args IE_KEY:ARGS Pass ARGS arguments to the IE_KEY extractor.
|
||||
See "EXTRACTOR ARGUMENTS" for details. You
|
||||
can use this option multiple times to give
|
||||
arguments for different extractors
|
||||
|
||||
Preset Aliases:
|
||||
Predefined aliases for convenience and ease of use. Note that future
|
||||
versions of yt-dlp may add or adjust presets, but the existing preset
|
||||
names will not be changed or removed
|
||||
|
||||
-t mp3 -f 'ba[acodec^=mp3]/ba/b' -x --audio-format
|
||||
mp3
|
||||
|
||||
-t aac -f
|
||||
'ba[acodec^=aac]/ba[acodec^=mp4a.40.]/ba/b'
|
||||
-x --audio-format aac
|
||||
|
||||
-t mp4 --merge-output-format mp4 --remux-video mp4
|
||||
-S vcodec:h264,lang,quality,res,fps,hdr:12,a
|
||||
codec:aac
|
||||
|
||||
-t mkv --merge-output-format mkv --remux-video mkv
|
||||
|
||||
-t sleep --sleep-subtitles 5 --sleep-requests 0.75
|
||||
--sleep-interval 10 --max-sleep-interval 20
|
||||
|
||||
See full documentation at https://github.com/yt-dlp/yt-dlp#readme
|
||||
@@ -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.4",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nimbold/Firelink.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/nimbold/Firelink/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"download-manager",
|
||||
"tauri",
|
||||
"rust",
|
||||
"react",
|
||||
"typescript",
|
||||
"aria2",
|
||||
"yt-dlp",
|
||||
"ffmpeg",
|
||||
"desktop"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
||||
"build": "tsc && vite build",
|
||||
"check:updates": "node scripts/check-updates.js",
|
||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def process_images(src_path):
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
|
||||
# Crop the 28px black padding
|
||||
img = img.crop((28, 28, 1226, 1226))
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
# Lighter color (+1) at top, original color (0) at bottom
|
||||
bg_color = pixels[100, 100]
|
||||
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
|
||||
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
|
||||
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
|
||||
|
||||
new_img = Image.new("RGBA", (width, height))
|
||||
new_pixels = new_img.load()
|
||||
|
||||
for y in range(height):
|
||||
ratio = y / float(height - 1)
|
||||
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
|
||||
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
|
||||
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
|
||||
grad_color = (grad_r, grad_g, grad_b, 255)
|
||||
|
||||
for x in range(width):
|
||||
p = pixels[x, y]
|
||||
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
|
||||
|
||||
# Replace pure black corners or background with gradient
|
||||
if p[0] < 15 and p[1] < 15 and p[2] < 15:
|
||||
new_pixels[x, y] = grad_color
|
||||
elif dist < 15:
|
||||
new_pixels[x, y] = grad_color
|
||||
elif dist < 60:
|
||||
alpha = (dist - 15) / 45.0
|
||||
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
|
||||
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
|
||||
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
|
||||
new_pixels[x, y] = (r, g, b, 255)
|
||||
else:
|
||||
new_pixels[x, y] = p
|
||||
|
||||
img = new_img
|
||||
radius = int(width * 0.225)
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
|
||||
img.putalpha(mask)
|
||||
|
||||
# Save standard png
|
||||
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
|
||||
img_1024.save("Resources/AppIcon.png")
|
||||
|
||||
# Save Firefox extension icons
|
||||
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
|
||||
img_48.save("Extensions/Firefox/icons/icon-48.png")
|
||||
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
|
||||
img_128.save("Extensions/Firefox/icons/icon-128.png")
|
||||
|
||||
# MenuBarIconTemplate (64x64 monochrome)
|
||||
data = img.getdata()
|
||||
new_data = []
|
||||
|
||||
for item in data:
|
||||
r, g, b, a = item
|
||||
if r > 100 and r > b * 1.5 and a > 0:
|
||||
alpha = min(255, max(0, int((r - 40) * 1.2)))
|
||||
new_data.append((0, 0, 0, alpha))
|
||||
else:
|
||||
new_data.append((0, 0, 0, 0))
|
||||
|
||||
menu_bar_full = Image.new("RGBA", img.size)
|
||||
menu_bar_full.putdata(new_data)
|
||||
|
||||
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
|
||||
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
|
||||
print("Done generating main PNGs")
|
||||
|
||||
if __name__ == '__main__':
|
||||
process_images(sys.argv[1])
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run ${executable}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runNpmScript(script) {
|
||||
if (process.env.npm_execpath) {
|
||||
run(process.execPath, [process.env.npm_execpath, 'run', script]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
run('cmd.exe', ['/d', '/s', '/c', 'npm', 'run', script], {
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
run('npm', ['run', script]);
|
||||
}
|
||||
|
||||
run(process.execPath, ['scripts/stage-engines.js']);
|
||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
||||
|
||||
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
|
||||
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
fs.rmSync(engineDist, { recursive: true, force: true });
|
||||
fs.mkdirSync(engineDist, { recursive: true });
|
||||
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
|
||||
}
|
||||
|
||||
runNpmScript('build');
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const userAgent = 'firelink-update-check';
|
||||
|
||||
function parseJsonFile(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
|
||||
}
|
||||
|
||||
function normalizeVersion(value) {
|
||||
return String(value || '')
|
||||
.replace(/^v/, '')
|
||||
.replace(/^release-/, '');
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const a = normalizeVersion(left).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
|
||||
const b = normalizeVersion(right).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
|
||||
const length = Math.max(a.length, b.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const av = a[index] ?? 0;
|
||||
const bv = b[index] ?? 0;
|
||||
if (av === bv) continue;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return av > bv ? 1 : -1;
|
||||
return String(av).localeCompare(String(bv));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function npmOutdated(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
throw new Error(`npm workspace is missing package.json: ${cwd}`);
|
||||
}
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
const details = error.stderr?.toString().trim();
|
||||
throw new Error(details || `npm outdated failed in ${cwd}`);
|
||||
}
|
||||
const output = error.stdout?.toString() || '{}';
|
||||
return JSON.parse(output || '{}');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function githubLatest(repo) {
|
||||
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
||||
}
|
||||
|
||||
async function latestFfmpegStable() {
|
||||
const html = await fetchText('https://ffmpeg.org/releases/');
|
||||
const versions = [...html.matchAll(/ffmpeg-(\d+\.\d+(?:\.\d+)?)\.tar\.xz/g)].map(match => match[1]);
|
||||
return [...new Set(versions)].sort(compareVersions).at(-1);
|
||||
}
|
||||
|
||||
async function latestMartinRiedlMacArm64Release() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const releaseSection = html.split('Download Release Build')[1] || '';
|
||||
const match =
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
|
||||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
async function latestMartinRiedlMacArm64Snapshot() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
|
||||
const 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(
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [
|
||||
ytDlp,
|
||||
deno,
|
||||
aria2,
|
||||
ffmpeg,
|
||||
martinRiedlMacArm64Ffmpeg,
|
||||
martinRiedlMacArm64Snapshot,
|
||||
btbnFfmpegN81Build,
|
||||
] = await Promise.all([
|
||||
githubLatest('yt-dlp/yt-dlp'),
|
||||
githubLatest('denoland/deno'),
|
||||
githubLatest('aria2/aria2'),
|
||||
latestFfmpegStable(),
|
||||
latestMartinRiedlMacArm64Release(),
|
||||
latestMartinRiedlMacArm64Snapshot(),
|
||||
latestBtbnFfmpegN81Build(),
|
||||
]);
|
||||
const latestByEngine = {
|
||||
'yt-dlp': ytDlp.tag_name,
|
||||
deno: deno.tag_name,
|
||||
aria2c: aria2.tag_name,
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build || 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,264 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDebianPackagePath(line) {
|
||||
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse a Debian package path: ${line}`);
|
||||
}
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
? lines.map(line => {
|
||||
try {
|
||||
return parseDebianPackagePath(line);
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
const parts = packagePath.split('/');
|
||||
if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bsdtar', [
|
||||
'--extract',
|
||||
'--file', packageFile,
|
||||
'--directory', destination,
|
||||
'--no-same-owner',
|
||||
'--no-same-permissions',
|
||||
]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
|
||||
'usr/share/'
|
||||
);
|
||||
});
|
||||
|
||||
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
|
||||
'usr/bin/firelink'
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed dpkg-deb listing lines', () => {
|
||||
assert.throws(
|
||||
() => parseDebianPackagePath('not a dpkg-deb listing'),
|
||||
/Could not parse a Debian package path/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const appArg = argValue('--app');
|
||||
const dmgArg = argValue('--dmg');
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
console.error('macOS signing verification must run on a macOS host.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!appArg && !dmgArg) {
|
||||
console.error('Pass --app <Firelink.app> and/or --dmg <Firelink.dmg>.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`[OK] ${message}`);
|
||||
}
|
||||
|
||||
function note(message) {
|
||||
console.log(`[INFO] ${message}`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function runResult(command, args) {
|
||||
return spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function assertAppPath(appPath, label) {
|
||||
if (!fs.existsSync(appPath)) {
|
||||
fail(`${label} does not exist at ${appPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(appPath);
|
||||
if (!stat.isDirectory() || path.extname(appPath) !== '.app') {
|
||||
fail(`${label} is not an .app bundle: ${appPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
ok(`${label} exists: ${appPath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function codesignDetails(targetPath) {
|
||||
const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]);
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
output: `${result.stdout || ''}${result.stderr || ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
function verifyCodeSignature(targetPath, label, options = {}) {
|
||||
const {
|
||||
deep = false,
|
||||
quietOk = false,
|
||||
requireAdhoc = false,
|
||||
warnOnVerifyFailure = false,
|
||||
} = options;
|
||||
const verifyArgs = ['--verify'];
|
||||
if (deep) {
|
||||
verifyArgs.push('--deep');
|
||||
}
|
||||
verifyArgs.push('--strict', '--verbose=2', targetPath);
|
||||
|
||||
const details = codesignDetails(targetPath);
|
||||
if (!details.ok) {
|
||||
fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
let status = 'verified';
|
||||
try {
|
||||
run('codesign', verifyArgs);
|
||||
if (!quietOk) {
|
||||
ok(`${label}: codesign verification passed`);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
if (!warnOnVerifyFailure) {
|
||||
fail(`${label}: codesign verification failed: ${detail}`);
|
||||
return 'failed';
|
||||
}
|
||||
note(`${label}: signed, but individual verification warned: ${detail}`);
|
||||
status = 'warning';
|
||||
}
|
||||
|
||||
if (requireAdhoc && !details.output.includes('Signature=adhoc')) {
|
||||
fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`);
|
||||
return 'failed';
|
||||
}
|
||||
if (requireAdhoc && !quietOk) {
|
||||
ok(`${label}: ad-hoc signature present`);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function walkFiles(root, visitor) {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(entryPath, visitor);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
visitor(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fileBrief(filePath) {
|
||||
try {
|
||||
return run('file', ['--brief', filePath], { timeout: 5000 }).trim();
|
||||
} catch (error) {
|
||||
fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMachOObjects(appPath, label) {
|
||||
const machOFiles = [];
|
||||
walkFiles(appPath, filePath => {
|
||||
const description = fileBrief(filePath);
|
||||
if (description.includes('Mach-O')) {
|
||||
machOFiles.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (machOFiles.length === 0) {
|
||||
fail(`${label}: no Mach-O files found inside app bundle`);
|
||||
return;
|
||||
}
|
||||
|
||||
let warningCount = 0;
|
||||
let signedCount = 0;
|
||||
let verifiedCount = 0;
|
||||
for (const filePath of machOFiles) {
|
||||
const relative = path.relative(appPath, filePath).split(path.sep).join('/');
|
||||
const isPrimaryExecutable = relative === 'Contents/MacOS/firelink';
|
||||
const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative);
|
||||
const mayWarn = !isPrimaryExecutable && !isDirectEngine;
|
||||
|
||||
const result = verifyCodeSignature(filePath, `${label} ${relative}`, {
|
||||
quietOk: true,
|
||||
warnOnVerifyFailure: mayWarn,
|
||||
});
|
||||
if (result !== 'failed') {
|
||||
signedCount += 1;
|
||||
if (result === 'verified') {
|
||||
verifiedCount += 1;
|
||||
} else {
|
||||
warningCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`);
|
||||
ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`);
|
||||
if (warningCount > 0) {
|
||||
note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assessGatekeeper(appPath, label) {
|
||||
const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]);
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||
|
||||
if (result.status === 0) {
|
||||
note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) {
|
||||
fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`);
|
||||
return;
|
||||
}
|
||||
|
||||
note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`);
|
||||
}
|
||||
|
||||
function reportQuarantine(targetPath, label) {
|
||||
const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]);
|
||||
if (result.status === 0) {
|
||||
fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`);
|
||||
} else {
|
||||
ok(`${label}: no quarantine xattr on generated artifact`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyApp(appPath, label) {
|
||||
const resolved = path.resolve(appPath);
|
||||
if (!assertAppPath(resolved, label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportQuarantine(resolved, label);
|
||||
verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true });
|
||||
verifyMachOObjects(resolved, label);
|
||||
assessGatekeeper(resolved, label);
|
||||
}
|
||||
|
||||
function attachDmg(dmgPath) {
|
||||
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-'));
|
||||
try {
|
||||
const plist = run('hdiutil', [
|
||||
'attach',
|
||||
'-plist',
|
||||
'-nobrowse',
|
||||
'-readonly',
|
||||
'-mountpoint',
|
||||
mountPoint,
|
||||
dmgPath,
|
||||
], { timeout: 60000 });
|
||||
return { mountPoint, plist };
|
||||
} catch (error) {
|
||||
fs.rmSync(mountPoint, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function detachDmg(mountPoint) {
|
||||
const result = runResult('hdiutil', ['detach', mountPoint]);
|
||||
if (result.status !== 0) {
|
||||
note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`);
|
||||
const forced = runResult('hdiutil', ['detach', '-force', mountPoint]);
|
||||
if (forced.status !== 0) {
|
||||
fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`);
|
||||
}
|
||||
}
|
||||
fs.rmSync(mountPoint, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function verifyDmg(dmgPath) {
|
||||
const resolved = path.resolve(dmgPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
fail(`DMG does not exist at ${resolved}`);
|
||||
return;
|
||||
}
|
||||
|
||||
reportQuarantine(resolved, 'DMG');
|
||||
let mount;
|
||||
try {
|
||||
mount = attachDmg(resolved);
|
||||
ok(`DMG mounted at ${mount.mountPoint}`);
|
||||
const apps = fs.readdirSync(mount.mountPoint)
|
||||
.filter(name => name.endsWith('.app'))
|
||||
.map(name => path.join(mount.mountPoint, name));
|
||||
if (apps.length !== 1) {
|
||||
fail(`Expected exactly one .app inside DMG, found ${apps.length}`);
|
||||
return;
|
||||
}
|
||||
verifyApp(apps[0], 'DMG app');
|
||||
} catch (error) {
|
||||
fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`);
|
||||
} finally {
|
||||
if (mount) {
|
||||
detachDmg(mount.mountPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appArg) {
|
||||
verifyApp(appArg, 'Built app');
|
||||
}
|
||||
|
||||
if (dmgArg) {
|
||||
verifyDmg(dmgArg);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (exitCode !== 0) {
|
||||
console.error('[FAIL] macOS signing verification failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[PASS] macOS ad-hoc signing verification passed.');
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const capability = JSON.parse(fs.readFileSync('src-tauri/capabilities/default.json', 'utf8'));
|
||||
const permissions = new Set(capability.permissions);
|
||||
|
||||
test('custom window controls have required Tauri permissions', () => {
|
||||
const windowControls = fs.readFileSync('src/components/WindowControls.tsx', 'utf8');
|
||||
const requiredPermissions = new Map([
|
||||
['.close()', 'core:window:allow-close'],
|
||||
['.minimize()', 'core:window:allow-minimize'],
|
||||
['.startDragging()', 'core:window:allow-start-dragging'],
|
||||
['.toggleMaximize()', 'core:window:allow-toggle-maximize'],
|
||||
]);
|
||||
|
||||
for (const [apiCall, permission] of requiredPermissions) {
|
||||
if (windowControls.includes(apiCall)) {
|
||||
assert.equal(
|
||||
permissions.has(permission),
|
||||
true,
|
||||
`${apiCall} requires ${permission} in src-tauri/capabilities/default.json`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,71 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.4"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/nimbold/Firelink"
|
||||
homepage = "https://github.com/nimbold/Firelink"
|
||||
keywords = ["download-manager", "tauri", "aria2", "yt-dlp", "ffmpeg"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "firelink_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.18"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||