Compare commits

..

1 Commits

Author SHA1 Message Date
nimbold 5f99bb791e docs: add 0.5.4 release notes 2026-06-04 15:46:21 +03:30
426 changed files with 6544 additions and 65906 deletions
-6
View File
@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 425 B

-80
View File
@@ -1,80 +0,0 @@
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 }}
+81 -258
View File
@@ -2,276 +2,99 @@ name: Release
on:
push:
tags: ['v*']
tags:
- "v*"
workflow_dispatch:
inputs:
publish_release:
description: 'Publish the GitHub release after all platform build and verification jobs pass'
type: boolean
default: true
version:
description: "Release version, for example 0.1.0"
required: true
default: "0.1.0"
permissions:
contents: write
jobs:
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 }}
macos-arm64-dmg:
name: Build macOS ARM64 DMG
runs-on: macos-26
steps:
- uses: actions/checkout@v7
- name: Check out repository
uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Verify release version
run: node scripts/verify-release-version.js
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
appstream \
curl \
patchelf \
libdbus-1-dev \
pkg-config \
xvfb \
libtinfo5 \
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
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null
New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null
$payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq "firelink.exe" } |
Sort-Object FullName |
Select-Object -First 1
if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." }
Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force
Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline
@"
Firelink portable
Extract this folder to a writable location and launch firelink.exe.
Close Firelink before copying or moving this folder.
Settings, queues, logs, and WebView data are stored under data\.
Only one Firelink instance can run at a time; close the installed app before launching this copy.
Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records.
Saved site passwords remain in the Windows credential store and are not portable.
The portable folder contains the extension pairing credential; treat it as sensitive and do not share it.
Saved absolute download locations may need to be selected again after moving to another drive.
The portable archive does not register the firelink:// protocol; use the installer for browser launch integration.
"@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt')
New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null
$portableExe = Join-Path $portableRoot 'firelink.exe'
node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }}
node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data
Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } |
Remove-Item -Force -ErrorAction SilentlyContinue
$portableDataDir = Join-Path $portableRoot 'data'
for ($attempt = 1; $attempt -le 10; $attempt++) {
Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue
if (-not (Test-Path $portableDataDir)) { break }
Start-Sleep -Milliseconds (200 * $attempt)
}
if (Test-Path $portableDataDir) {
throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data."
}
$portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip"
7z a -tzip $portableZip "$portableRoot\*" -y
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
if ($install.ExitCode -ne 0) { throw "Silent NSIS install failed with exit code $($install.ExitCode)." }
$exe = $null
foreach ($attempt in 1..60) {
$exe = Get-ChildItem $installRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1
if ($exe) { break }
Start-Sleep -Seconds 1
}
if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." }
node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows
- name: Verify Linux AppImage payload and launch
if: runner.os == 'Linux'
run: |
APPIMAGE="$(find src-tauri/target/${{ matrix.target }}/release/bundle/appimage -name '*.AppImage' -print -quit)"
chmod +x "$APPIMAGE"
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
- uses: actions/upload-artifact@v7
with:
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Windows'
with:
name: Firelink-Windows-x64-portable-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-portable/*.zip
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
if-no-files-found: error
retention-days: 3
publish:
name: Publish GitHub release
if: >-
startsWith(github.ref, 'refs/tags/v') &&
(
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
)
needs: build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: release-assets
merge-multiple: true
- name: Extract changelog release notes
- name: Resolve version
id: version
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${{ inputs.version }}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Show build environment
run: |
uname -a
swift --version
xcodebuild -version
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- name: Verify macOS 26 SDK
shell: bash
run: |
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
SDK_MAJOR="${SDK_VERSION%%.*}"
if [[ "$SDK_MAJOR" != "26" ]]; then
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
exit 1
fi
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
BUILD_NUMBER: ${{ github.run_number }}
run: Scripts/create_app_bundle.sh
- name: Verify ARM64 binary
run: |
file build/Firelink.app/Contents/MacOS/Firelink
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
codesign --verify --deep --strict build/Firelink.app
- name: Create DMG
env:
VERSION: ${{ steps.version.outputs.version }}
ARCH: arm64
run: Scripts/create_dmg.sh
- name: Upload workflow artifact
uses: actions/upload-artifact@v7
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'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
test -s release_notes.md
- name: Normalize release asset names
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
rename_asset() {
local pattern="$1"
local destination="$2"
local source
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
if (( ${#matches[@]} != 1 )); then
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
exit 1
fi
source="${matches[0]}"
mv "$source" "release-assets/$destination"
}
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
- name: Generate checksums
run: |
cd release-assets
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
files: release-assets/**
body_path: release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
fi
+4 -85
View File
@@ -1,89 +1,8 @@
# OS and editor files
.DS_Store
skills-lock.json
.idea/
.build/
build/
dist/
DerivedData/
.vscode/
*.suo
*.sw?
*.xcuserdata/
*.xcuserstate
# Local agent and planning notes
AGENT.md
AGENTS.md
CLAUDE.md
GEMINI.md
implementation_plan.md
CROSS_PLATFORM_CHECKLIST.md
Cross-platform-checklist-gemini.MD
YouTube_media_download_handoff.md
Release_checklist.md
Release Checklist/
RC Read-only/
RC Read_only/
# Frontend output and logs
.agents/
release_notes.md
node_modules/
dist/
dist-ssr/
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.local
# Rust and Tauri output
target/
src-tauri/target/
src-tauri/gen/
src-tauri/engine-dist/
src-tauri/provisioned-engines/
# Locally provisioned native engines
src-tauri/binaries/aria2c
src-tauri/binaries/deno
src-tauri/binaries/ffmpeg
src-tauri/binaries/yt-dlp
# Packaged artifacts
build/
*.dmg
*.AppImage
*.deb
*.msi
*.exe
# Local secrets and signing material
.env
.env.*
*.key
*.pem
!src-tauri/binaries/_internal/certifi/cacert.pem
*.p8
*.p12
*.cer
*.der
*.mobileprovision
sparkle_private_key*
SparklePrivateKey*
private-key*
private_key*
# Legacy Swift build output and downloaded engines
legacy/swift/.build/
legacy/swift/DerivedData/
legacy/swift/Sources/Firelink/*-version.txt
legacy/swift/Sources/Firelink/yt-dlp
legacy/swift/Sources/Firelink/deno
legacy/swift/Sources/Firelink/ffmpeg
legacy/swift/Sources/Firelink/aria2c
legacy/swift/Sources/Firelink/_internal/*
legacy/swift/Sources/Firelink/aria2-libs/
legacy/swift/Sources/Firelink/aria2-licenses/
legacy/swift/Sources/Firelink/aria2-cacert.pem
!legacy/swift/Sources/Firelink/_internal/.gitkeep
+2 -2
View File
@@ -1,3 +1,3 @@
[submodule "Extensions/Browser"]
path = Extensions/Browser
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
url = https://github.com/nimbold/Firelink-Extension.git
+2 -384
View File
@@ -1,392 +1,10 @@
# Changelog
All notable changes to Firelink will be documented in this file.
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies.
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table.
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path.
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels.
### Fixes
- Prevent stale lifecycle work, duplicate terminal events, and overlapping controls from deleting, reviving, or leaving the wrong download in a misleading state.
- Keep missed completion events and connection-limit updates from leaving a finished or active download visually stuck.
- Bound backend connection counts so user-selected limits are respected instead of allowing overlapping settings to exceed them.
- Keep extension handoffs in the Add window and make selected-link batches retain their page context for folder suggestions.
## [1.1.1] - 2026-07-17
This patch release focuses on transfer reliability, browser captures, and easier download control.
### New
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
### Improved
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
- Improve playlist state, media size estimates, and large-list performance.
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
### Fixed
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
## [1.1.0] - 2026-07-15
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
### New
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
### Improved
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
### Fixed
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
## [1.0.4] - 2026-07-12
### New
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
### Improved
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
### Fixed
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
## [1.0.3] - 2026-07-09
### Improved
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
### Fixed
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
## [1.0.2] - 2026-07-08
### New
- Add explicit **Fetch media** actions for Firelink Companion so video and audio pages can be sent to Firelink from the extension popup or page context menu.
- Add a Chromium extension install path in Firelink's Integrations settings and release documentation, addressing the Chromium support request in [#4](https://github.com/nimbold/Firelink/issues/4).
### Improved
- Improve YouTube and social-media download handling with better proxy support, cleaner diagnostics, safer log redaction, and more reliable metadata fetching based on reports in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
- Let category subfolders be disabled so files can save directly into a base download folder, addressing [#6](https://github.com/nimbold/Firelink/issues/6).
- Refresh bundled media engines and release tooling for the current cross-platform desktop builds.
- Strengthen release packaging checks for macOS, Windows, and Linux, including macOS ad-hoc signing verification and Windows installer icon handling.
### Fixed
- Fix YouTube downloads that failed when Chrome's cookie database could not be copied by retrying public media without browser cookies, addressing [#7](https://github.com/nimbold/Firelink/issues/7).
- Fix the hidden-sidebar trap on Settings and other pages so the sidebar can always be shown again, addressing [#9](https://github.com/nimbold/Firelink/issues/9).
- Fix Windows proxy detection so system proxy schemes are preserved for metadata and media requests, addressing [#5](https://github.com/nimbold/Firelink/issues/5).
- Fix captured and auto-captured browser links so they always route through Firelink's Add window before reaching the download list.
- Fix media downloads after a metadata fallback so pages without selectable preview formats can still let yt-dlp choose the best downloadable file instead of crashing.
- Fix media cleanup and retry paths so failed, canceled, or retried media downloads leave fewer stale temporary files behind.
## [1.0.1] - 2026-07-04
### Fixed
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
## [1.0.0] - 2026-07-04
### Highlights
- Firelink is now a cross-platform desktop download manager built with Rust, Tauri, React, and TypeScript.
- macOS, Windows, and Linux release builds are prepared through native GitHub Actions runners.
- The older macOS-only Swift app has been removed from the active product and replaced by the new desktop app.
### New
- Add full browser-to-desktop integration through Firelink Companion, including signed local requests, desktop identity checks, and automatic-capture protection.
- Add a complete Add window for links captured from the browser, pasted manually, or extracted from media pages.
- Add segmented direct downloads, media downloads, queue controls, scheduling, speed limits, and duplicate handling.
- Add persistent downloads, categories, save-location settings, site logins, and safe redownload behavior.
- Add native tray controls, notifications, completion sounds, sleep prevention, file reveal/open/trash actions, and OS keychain support where available.
- Add built-in diagnostics for media engines, app logs, packaged-engine validation, and release smoke checks.
### Improved
- Rework the main window, settings, logs, Add window, download list, toasts, context menus, and table interactions for the new desktop interface.
- Improve media extraction with better YouTube metadata loading, format filtering, size estimates, output names, progress, retries, and cleanup.
- Improve download reliability on rate-limited servers, servers with broken range support, interrupted app sessions, and queue-concurrency changes.
- Improve extension captures so browser downloads are resumed unless Firelink confirms the handoff.
- Improve cross-platform packaging by bundling target-specific aria2, yt-dlp, FFmpeg, and Deno payloads.
- Improve privacy by avoiding startup keychain prompts, redacting secrets from local persistence, and narrowing when browser cookies are forwarded.
### Fixed
- Fix stuck queued/downloading/completed states, stale aria2 dispatches, queue shrink behavior, retry caps, scheduler actions, and post-queue system actions.
- Fix many Windows, Linux, and macOS packaging issues, including hidden helper consoles, AppImage engine handling, installer validation, and smoke-test timing.
- Fix media child-process cleanup so paused, canceled, failed, or removed downloads do not leave stray processes or partial files behind.
- Fix UI hangs, overflow, sorting, selection, animation, and contrast issues across the main list, Add window, settings, and log viewer.
- Fix security issues around local server authentication, request signing, replay protection, private IP metadata checks, path ownership, header sanitization, and command execution boundaries.
### Notes
- This is a major release from `0.7.3`. Older Firelink Companion builds are not compatible with every 1.0 capture path; update the extension together with the desktop app.
- macOS builds are unsigned and not notarized. Windows builds are unsigned. See [RELEASE.md](RELEASE.md) for distribution details.
## [0.7.3] - 2026-06-11
### New Features & Improvements
- Add Deno to about credits and engines list.
- Enhance speed and ETA display logic during pause and drop.
- Accumulate track sizes to present a unified overall progress bar.
### Fixes
- Resolve unknown speed flickering and ultra-wide high-resolution detection.
- Emit distinct status messages for individual tracks during download.
- Pad overall progress total for first track to prevent 100% snapback.
## [0.7.2] - 2026-06-11
### Fixed
- Prevented yt-dlp and JavaScript child processes from keeping metadata fetches or canceled downloads alive indefinitely.
- Replaced the repeatedly extracted one-file yt-dlp build with a stable prewarmed runtime cache.
- Bundled Deno so YouTube JavaScript challenges and formats above 720p do not depend on system-installed tools.
- Stopped masking empty-format extraction failures and removed brittle forced YouTube client selection.
### Changed
- Pinned and checksum-verified yt-dlp, Deno, FFmpeg, aria2, and aria2's libraries for matching local and GitHub Actions builds.
- Removed aria2's runtime dependency on Homebrew and configured its bundled CA certificate for direct and yt-dlp-delegated HTTPS downloads.
- Added bounded network retries and optional aria2c acceleration for large direct media downloads.
## [0.7.1] - 2026-06-11
### Fixes
- Increased the `yt-dlp` metadata extraction timeout to 120 seconds to properly handle YouTube's new JavaScript Proof-of-Work bot protection challenges.
- Improved the `AddDownloadsView` UI to display the exact underlying error message during extraction failures rather than a generic masked string.
### Security Fixes
- Addressed multiple vulnerabilities identified in the v0.7.0 security audit.
- Moved `yt-dlp` credential passing from CLI arguments to secure temporary configuration files to prevent process list leakage.
- Enforced strict `0o600` POSIX permissions on `aria2c` temporary configuration files to protect generated RPC secrets.
- Replaced the unauthenticated local connection protocol with a secure HMAC-SHA256 signature validation.
- Excluded sensitive properties like `rpcSecret` and `rpcPort` from `DownloadItem` serialization so they are never saved to disk in plaintext.
- Mitigated SSRF (Server-Side Request Forgery) by strictly validating metadata fetch requests against private IP addresses and loopback ranges.
- Prevented potential path traversal vulnerabilities by validating destination file URLs during duplicate resolution.
- Sanitized custom HTTP headers to prevent CR/LF injection vectors.
- Re-architected `aria2c` port-finding with POSIX sockets to eliminate a known race-condition window.
- Applied rate-limiting and text length bounds to the custom `firelink://` scheme to mitigate DoS and injection attempts.
### Fixes
- Fixed a metadata extraction timeout when downloading from YouTube by preventing child processes from holding process pipes open.
- Resolved an issue to correctly assign filenames for auto-captured downloads.
- Restored the UUID fallback for token generation to prevent silent failures if secure random byte generation fails.
- Hardened local API security by immediately rejecting requests if the expected pairing token is completely empty.
- Implemented a thread-safe cleanup mechanism for temporary directories to resolve a concurrency race condition during engine cancellation.
## [0.7.0] - 2026-06-11
### New Features & Improvements
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
### Fixes
- Fix issues regarding proxy environment propagation into media download processes.
- Resolve multiple critical bugs related to configuration storage and download stability.
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
## [0.6.6] - 2026-06-10
### New Features
- Add cascading media format pickers with inline loading states during metadata extraction.
- Redesign the Integration settings pane for a more modern experience.
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
### Improvements
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
- Optimize core UI components to significantly improve rendering performance and overall app stability.
### Fixes
- Fix layout and dynamic sizing bugs in the Add Downloads window.
- Fix formatting inconsistencies in media options selection.
- Fix toast notification rendering glitches.
## [0.6.5] - 2026-06-09
### Fixes
- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
## [0.6.4] - 2026-06-09
### New Features
- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
### Improvements
- Polish the browser extension pairing UI with a secure masked token field and improved styling.
### Changes
- Remove stale references to the legacy static token from the Firelink Companion extension.
### Fixes
- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
## [0.6.3] - 2026-06-09
### Improvements
- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
### Fixes
- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
## [0.6.2] - 2026-06-08
### Fixes
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
## [0.6.1] - 2026-06-08
### New Features
- No new user-facing features in this patch release.
### Improvements
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
### Changes
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
- Remove stale media add-on update language now that media engines are bundled with the app.
- Update Firelink Companion to `1.0.8`.
### Fixes
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
## [0.6.0] - 2026-06-08
### New features
- Enhance mixed media support and add duplicate resolution.
- Redesign settings panes and enhance update flows.
- Improve yt-dlp fetching speed and redesign media detection UI.
- Enhance media engine settings with cookie extraction and update checks.
- Modernize Integration settings UI and add official install button.
- Integrate yt-dlp to DownloadController and add global queue support.
- Implement smart progressive disclosure UI and media extraction engine.
- Implement gatekeeper architecture for on-demand media engine binaries.
- Inline update checks to avoid unnecessary modals.
### Changes
- Add backward compatibility support for extension tokens.
- Update Firelink-Extension submodule to latest.
- Update app icons and icon generation scripts.
- Tone down icon gradient to 1.9x for modern subtle look.
- Increase gradient contrast for stronger lighting effect.
- Switch to lighter gradient (+1 to 0).
- Revert to plain mode without gradient.
- Apply premium gradient to the correct new icon and app icon.
- Remove redundant version string from up-to-date message.
- Update release metadata for the framework-embedded dmg.
### Fixes
- Cap max height of download links text editor.
- Harden media download flow.
- Pass extractor arguments to yt-dlp download process.
- Restore single click selection by removing simultaneousGesture.
- Restore Download Properties routing and gestures.
- Pass UUID as String for download properties WindowGroup to prevent routing failures.
- Size column fallback and table row interactions.
- Media download UX and table row selection.
- Media downloads connections, progress parsing, file size, and selection highlight.
- Stabilize yt-dlp metadata and add-on updates.
- Block automatic metadata fetch for private IP addresses (security).
- Actually update extension icons with the 1.9x gradient icon.
- Correctly remove black padding and mask corners.
- Harden release metadata.
- Correct no-update handling to prevent false error messages.
## [0.5.7] - 2026-06-06
### New features
- Replaced the basic in-app update checker with an integrated release-checking flow.
- Added secure update metadata checks before presenting new releases in the app.
## [0.5.6] - 2026-06-05
### New features
- Added the official transparent GitHub icon to the Source Code link in the About page.
### Changes
- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
### Fixes
- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
## [0.5.5] - 2026-06-05
### New features
- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
- Added authenticated metadata probing so batch previews can use custom or saved credentials.
### Changes
- Updated Download Properties disclosure sections so their full title row opens and closes them.
- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
- Expanded download table hit areas so double-clicks register across empty cell space.
### Fixes
- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
- Fixed Chunk Map layout overlap in Download Properties.
- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
## [0.5.4] - 2026-06-04
### New features
@@ -485,7 +103,7 @@ This is a stability-focused release with safer downloads, browser handoffs, sett
## [0.4.0] - 2026-06-03
### Changes
- Reorganized Settings sections so related download preferences sit together and app logs live under App.
- Reorganized Settings sections so related download preferences sit together and app diagnostics 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.
Submodule Extensions/Firefox added at f443e096c2
+20
View File
@@ -0,0 +1,20 @@
.PHONY: build app dmg run verify clean
build:
swift build -c release
app:
Scripts/create_app_bundle.sh
dmg: app
Scripts/create_dmg.sh
run:
swift run Firelink
verify:
Scripts/verify.sh
clean:
swift package clean
rm -rf build dist
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Firelink",
platforms: [
.macOS(.v14)
],
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
targets: [
.executableTarget(
name: "Firelink",
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
]
)
]
)
+72 -119
View File
@@ -1,145 +1,98 @@
<div align="center">
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
# Firelink
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.2.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#supported-platforms)
[![Windows](.github/badges/windows.svg)](#supported-platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#supported-platforms)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md)
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
</div>
<br/>
---
**Firelink** brings the efficiency of multi-segmented download managers (like IDM or FDM) to macOS with a modern, native SwiftUI interface. Designed specifically for Apple Silicon, it delivers high-speed concurrent transfers, drag-and-drop queue control, automated file organization, and Keychain-secured authentication—all in a lightweight native package.
---
### 📸 Screenshots
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
<div align="center">
<img src="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>
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup</sub>
</div>
## Overview
Firelink manages direct downloads, browser captures, media extraction, playlists, scheduling, and file placement from one desktop app. It uses a Rust/Tauri backend with a React and TypeScript interface, and bundles the engines needed for its download and media workflows.
The current desktop release is **1.2.0**, paired with [Firelink Companion 2.0.6](https://github.com/nimbold/Firelink-Extension/releases). The release focuses on a more dependable download table and queue, better browser handoffs, broader localization, and safer packaged builds.
## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Playlist downloads** for YouTube playlists with queueing and efficient rendering for large lists.
- **Customizable download table** with selectable columns, sorting, drag-to-reorder, and bulk actions.
- **Add window** for metadata, duplicate handling, captured links, clipboard-prefilled URLs, save locations, and live connection limits.
- **Persistent queues** with pause, resume, retry, redownload, scheduling, and multi-select actions.
- **Optional batch folders** for grouping related multi-link downloads in a new, editable folder.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add-window review, replay protection, and server checks.
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Localization** for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian, with RTL support. Non-English translations are LLM-assisted and welcome user review.
- **Diagnostics** with engine health checks, structured logs, and package verification.
## Installation
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
| Platform | Package | Notes |
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. |
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately.
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside the executable under `data/`.
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
- Credentials, browser cookies, and URL query or fragment data are not saved in portable queue records.
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
- The folder contains the extension pairing credential. Treat it as sensitive and do not share it.
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
- The installer remains the supported path for `firelink://` browser launch registration.
<summary><b>☀️ View Light Theme Screenshots</b></summary>
<br/>
<div align="center">
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
</div>
</details>
## Browser Extension
---
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink. Captured downloads always open Firelink's Add window so you can review them before starting or queuing them.
## ✨ Features
It provides automatic capture for regular downloads, explicit **Fetch media** actions, link and selected-text context menus, Firefox and Chromium support, signed local requests, and a safe browser fallback when Firelink is unavailable.
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
<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>
&nbsp;&nbsp;
<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>
---
Install the extension, open Firelink, and pair it from **Settings -> Integrations**. Use the latest [Firelink Companion 2.0.6 release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.2.0. Chromium users can load `firelink-chromium.zip` by following the [manual installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
## 🛠️ Quick Start
## Supported Platforms
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported with a native build, bundled-engine checks, launch smoke test, and ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported with a native build, engine checks, installer smoke test, NSIS installer, and portable ZIP. |
| **Linux x64** | Supported with native builds, bundled-engine checks, package/AppImage smoke tests, `.deb`, `.rpm`, and AppImage packages. |
## 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
npm install
Run the application directly:
```bash
swift run Firelink
```
Launch the app with `npm run tauri dev`. Run the core checks with:
```sh
node --test scripts/*.node-test.js
npm test -- --run
npm run build
cd src-tauri
cargo test --all-targets
Or build a production `.app` bundle:
```bash
make app && open build/Firelink.app
```
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
---
## Help and Project Status
## 🧩 Browser Extension
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
## Technology and License
---
Firelink is built with [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/), [SQLite](https://www.sqlite.org/), [aria2](https://aria2.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [FFmpeg](https://ffmpeg.org/), and [Deno](https://deno.com/).
## 🗺️ Roadmap
Firelink is available under the [MIT License](LICENSE).
- [x] Zero-Config `aria2c` bundling.
- [x] Global & per-download Speed Limiter.
- [x] Browser Extensions support.
- [x] In-app integrated Settings UI.
- [ ] Notarized `.app` releases and Homebrew formulae.
---
## 🏆 Credits
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
---
## 📄 License
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
-81
View File
@@ -1,81 +0,0 @@
# Firelink Release Process
Targets:
- macOS arm64 DMG
- Windows x64 NSIS installer
- Windows x64 portable ZIP
- Linux x64 AppImage
- Linux x64 Debian package
- Linux x64 RPM package
## Distribution policy
Firelink does not use an Apple Developer account. macOS releases are ad-hoc signed but not notarized or Gatekeeper-approved. Users may still need to explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must not describe these builds as Developer ID signed, notarized, or Gatekeeper-approved.
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
## Engine supply chain
Firelink never falls back to system-installed media tools.
- `engines.lock.json` pins current committed macOS payload hashes.
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
- `scripts/provision-engines.js` downloads and verifies target archives.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
## Version update
Keep versions aligned:
- `package.json`
- `src-tauri/Cargo.toml`
- `src-tauri/tauri.conf.json`
## Local macOS build
```bash
npm ci
node scripts/stage-engines.js --target aarch64-apple-darwin
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
npm test -- --run
npm run build
cd src-tauri && cargo test --all-targets
cd ..
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
```
Verify packaged resources, then launch outside repository working directory:
```bash
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
```
GitHub release publication follows `.github/workflows/release.yml`. A `v*` tag
push builds, verifies, and publishes the GitHub release after the platform jobs
pass. A `workflow_dispatch` on a `v*` tag also publishes when its
`publish_release` input is enabled. The current workflow has no separate
release-certification inputs; clean-machine QA remains a release-owner gate
before pushing the tag.
## Automated release builds
Push a version tag to build and verify native artifacts:
```bash
git tag v<version>
git push origin v<version>
```
GitHub Actions builds all targets on native runners, verifies engines inside
final package contents, performs packaged launch smoke where supported, and
publishes the GitHub Release after the build matrix passes.
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}"
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}"
BUILD_NUMBER="${BUILD_NUMBER:-1}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js"
cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json"
cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons"
cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup"
ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>local.firelink.swiftui</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$ICON_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$MARKETING_VERSION</string>
<key>CFBundleVersion</key>
<string>$BUILD_NUMBER</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
</dict>
</plist>
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
fi
echo "Created $APP_DIR"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
VERSION="${VERSION:-0.1.0}"
ARCH="${ARCH:-arm64}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
DIST_DIR="$ROOT_DIR/dist"
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
if [[ ! -d "$APP_DIR" ]]; then
echo "Missing app bundle: $APP_DIR" >&2
echo "Run Scripts/create_app_bundle.sh first." >&2
exit 1
fi
rm -rf "$DMG_STAGING_DIR"
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
ln -s /Applications "$DMG_STAGING_DIR/Applications"
rm -f "$DMG_PATH"
hdiutil create \
-volname "$APP_NAME $VERSION" \
-srcfolder "$DMG_STAGING_DIR" \
-ov \
-format UDZO \
"$DMG_PATH"
echo "Created $DMG_PATH"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
swift build
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+609
View File
@@ -0,0 +1,609 @@
import SwiftUI
struct AddDownloadsView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
@State private var linkText = ""
@State private var pendingDownloads: [PendingDownload] = []
@State private var connectionsPerServer = 16.0
@State private var overrideDestination = false
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID
@State private var speedLimitEnabled = false
@State private var speedLimitKiBPerSecond = 1024
@State private var showsAdvancedTransfer = false
@State private var checksumEnabled = false
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
@State private var checksumValue = ""
@State private var headerText = ""
@State private var cookieText = ""
@State private var mirrorText = ""
@State private var useAuthorization = false
@State private var authUsername = ""
@State private var authPassword = ""
@State private var saveLogin = false
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(12)
}
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
applyPendingReferer()
linkText = text
controller.pendingPasteboardText = nil
}
}
.onDisappear {
metadataTask?.cancel()
}
}
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.subheadline.weight(.semibold))
TextEditor(text: $linkText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 82)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
.foregroundStyle(.secondary)
Spacer()
Button {
refreshMetadata(for: linkText)
} label: {
Label("Refresh Metadata", systemImage: "arrow.clockwise")
}
.disabled(DownloadURLParser.parse(linkText).isEmpty)
}
.font(.caption)
}
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.subheadline.weight(.semibold))
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
.disabled(!overrideDestination)
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
.disabled(!overrideDestination)
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 145)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 98, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit this batch", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
.disabled(!speedLimitEnabled)
}
}
}
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.subheadline.weight(.semibold))
.padding(.top, 4)
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var summarySection: some View {
HStack(spacing: 8) {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
}
private var previewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.subheadline.weight(.semibold))
Table(pendingDownloads) {
TableColumn("File") { item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
}
}
}
TableColumn("Size") { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
}
.width(110)
}
.frame(minHeight: 135)
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
}
.disabled(!canAddDownloads)
Button("Start Downloads") {
addDownloads(start: true)
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
}
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.labelsHidden()
.frame(width: 130)
TextField("Expected digest", text: $checksumValue)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.subheadline.weight(.semibold))
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
}
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.semibold))
}
}
private var requiredSpaceText: String {
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
guard knownBytes > 0 else { return "Unknown" }
return ByteFormatter.string(knownBytes)
}
private var unknownSizeCount: Int {
pendingDownloads.filter { $0.sizeBytes == nil }.count
}
private var freeSpaceText: String {
guard let bytes = availableCapacity() else { return "Unknown" }
return ByteFormatter.string(bytes)
}
private var actionMessage: String {
if pendingDownloads.isEmpty {
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
}
if let validationMessage {
return validationMessage
}
if unknownSizeCount > 0 {
return "Some servers did not report file size before download."
}
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
}
private var canAddDownloads: Bool {
!pendingDownloads.isEmpty && validationMessage == nil
}
private var validationMessage: String? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Add the expected checksum digest, or turn checksum off."
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
return "Headers must use Name: Value lines."
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
}
return nil
}
private var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await MainActor.run {
refreshMetadata(for: text)
}
}
}
private func applyPendingReferer() {
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
!referer.isEmpty,
URL(string: referer) != nil else {
controller.pendingReferer = nil
return
}
let refererHeader = "Referer: \(referer)"
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? refererHeader
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
}
controller.pendingReferer = nil
}
private func refreshMetadata(for text: String) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
pendingDownloads = urls.map { url in
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
url: url,
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
)
}
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
useAuthorization = true
authUsername = creds.username
authPassword = creds.password
saveLogin = false
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
}
}
}
await MainActor.run {
metadataTask = nil
}
}
}
private func selectDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if let currentURL = overrideDirectory {
panel.directoryURL = currentURL
}
if panel.runModal() == .OK, let url = panel.url {
destinationPath = url.path
overrideDestination = true
}
}
private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
queueID: targetQueueID,
credentials: explicitCredentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
private var overrideDirectory: URL? {
guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true)
}
private func destinationDirectory(for item: PendingDownload) -> URL {
overrideDirectory ?? item.defaultDirectory
}
private func availableCapacity() -> Int64? {
let urls = pendingDownloads.isEmpty
? [settings.destinationDirectory(for: .other)]
: pendingDownloads.map { destinationDirectory(for: $0) }
return urls.compactMap { url in
let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [
.volumeAvailableCapacityForImportantUsageKey,
.volumeAvailableCapacityKey
])
if let important = values?.volumeAvailableCapacityForImportantUsage {
return important
}
if let available = values?.volumeAvailableCapacity {
return Int64(available)
}
return nil
}
.min()
}
private func existingVolumeURL(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
private func categoryColor(_ category: DownloadCategory) -> Color {
switch category {
case .musics: .pink
case .movies: .indigo
case .compressed: .orange
case .pictures: .teal
case .documents: .blue
case .other: .gray
}
}
}
private struct SummaryTile: View {
let title: String
let value: String
let symbolName: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(9)
.frame(maxWidth: .infinity)
.frame(minHeight: 52)
.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)
}
}
}
+320
View File
@@ -0,0 +1,320 @@
import Foundation
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var urlPattern: String
var username: String
}
enum ProxyMode: String, Codable, CaseIterable, Sendable {
case none
case system
case custom
var title: String {
switch self {
case .none: "No proxy"
case .system: "Use system proxy"
case .custom: "Set proxy"
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
}
}
var uriScheme: String {
rawValue
}
}
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 proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
if proxySettings != normalized {
proxySettings = normalized
return
}
save()
}
}
@Published var downloadDirectories: [DownloadCategory: String] {
didSet { save() }
}
@Published var siteLogins: [SiteLogin] {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
private let storageKey = "Firelink.AppSettings.v1"
private var saveTask: Task<Void, Never>?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
appFontSize = stored.appFontSize ?? .standard
listRowDensity = stored.listRowDensity ?? .standard
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
appFontSize = .standard
listRowDensity = .standard
perServerConnections = 16
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
downloadDirectories = Self.defaultDirectories()
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
downloadDirectories[category] = Self.defaultDirectory(for: category).path
}
}
func destinationDirectory(for category: DownloadCategory) -> URL {
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
}
func setDirectory(_ path: String, for category: DownloadCategory) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
}
func resetDirectories() {
downloadDirectories = Self.defaultDirectories()
}
var downloadProxyConfiguration: DownloadProxyConfiguration {
DownloadProxyConfiguration(
mode: proxySettings.mode,
customProxyURI: proxySettings.customProxyURI
)
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
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
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
message = "Could not save the password to Keychain."
return
}
siteLogins.append(login)
message = "Added login for \(pattern)."
}
func deleteSiteLogins(at offsets: IndexSet) {
for offset in offsets {
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
}
siteLogins.remove(atOffsets: offsets)
}
func credentials(for url: URL) -> DownloadCredentials? {
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
func credentials(for login: SiteLogin) -> DownloadCredentials? {
guard let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
appFontSize: appFontSize,
listRowDensity: listRowDensity,
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
let defaults = self.defaults
let storageKey = self.storageKey
saveTask?.cancel()
saveTask = Task { @MainActor [defaults, storageKey] in
let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored)
}.value
guard !Task.isCancelled, let encoded = data else { return }
defaults.set(encoded, forKey: storageKey)
}
}
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !pattern.isEmpty else { return false }
let host = (url.host(percentEncoded: false) ?? "").lowercased()
let absolute = url.absoluteString.lowercased()
let normalizedPattern = URL(string: pattern)?.host ?? pattern
if normalizedPattern.hasPrefix("*.") {
let suffix = String(normalizedPattern.dropFirst(2))
return host == suffix || host.hasSuffix(".\(suffix)")
}
if normalizedPattern.contains("*") {
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
.replacingOccurrences(of: "\\*", with: ".*")
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
}
if normalizedPattern.contains("/") {
return absolute.contains(normalizedPattern)
}
return host == normalizedPattern
}
private static func defaultDirectories() -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
}
private static func defaultDirectory(for category: DownloadCategory) -> URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
}
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
guard let category = DownloadCategory(rawValue: key) else { return nil }
return (category, value)
})
}
}
private struct StoredSettings: Codable {
var appTheme: AppTheme?
var appFontSize: AppFontSize?
var listRowDensity: ListRowDensity?
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
+467
View File
@@ -0,0 +1,467 @@
import Foundation
import CFNetwork
import Network
final class Aria2DownloadEngine {
struct Handle {
let processIdentifier: Int32
let rpcPort: Int
let rpcSecret: String
let cancel: @Sendable () -> Void
}
static func findFreePort() -> Int {
var port: UInt16 = 6800
let parameters = NWParameters.tcp
for p in 6800...6900 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
if let listener = try? NWListener(using: parameters) {
listener.cancel()
port = UInt16(p)
break
}
}
return Int(port)
}
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
case unsupportedProxy(String)
var errorDescription: String? {
switch self {
case .executableNotFound:
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
case .launchFailed(let details):
"Could not start aria2c: \(details)"
case .unsupportedProxy(let details):
details
}
}
}
private let executableURL: URL?
init(executableURL: URL? = Aria2DownloadEngine.findExecutable()) {
self.executableURL = executableURL
}
static func findExecutable() -> URL? {
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
}
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for folder in path.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
if FileManager.default.isExecutableFile(atPath: candidate.path) {
return candidate
}
}
return nil
}
static func versionString() async -> String? {
guard let executableURL = findExecutable() else { return nil }
return await Task.detached {
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = nil
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
do {
try process.run()
// Close the write file handle in the parent process immediately
// This guarantees readToEnd() won't hang waiting for the parent itself
outputPipe.fileHandleForWriting.closeFile()
let data = try? outputPipe.fileHandleForReading.readToEnd()
process.waitUntilExit()
guard process.terminationStatus == 0, let data = data else { return nil }
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}.value
}
func start(
item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
guard let executableURL else {
throw EngineError.executableNotFound
}
try FileManager.default.createDirectory(
at: item.destinationDirectory,
withIntermediateDirectories: true
)
let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
)
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()
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
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completion(.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")
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
}
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
throw EngineError.launchFailed(error.localizedDescription)
}
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
if process.isRunning {
process.terminate()
}
}
}
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-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,
rpcSecret: String
) throws -> [String] {
var arguments = [
"--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-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
switch configuration.mode {
case .none:
return clearedProxyArguments()
case .system:
switch systemProxyResolution(for: item.url) {
case .direct:
return clearedProxyArguments()
case .proxy(let proxyURI):
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
case .unsupported(let message):
throw EngineError.unsupportedProxy(message)
}
case .custom:
guard let proxyURI = configuration.customProxyURI else { return [] }
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
}
}
private func clearedProxyArguments() -> [String] {
[
"--all-proxy=",
"--http-proxy=",
"--https-proxy=",
"--ftp-proxy="
]
}
private func proxyArgumentName(for urlScheme: String?) -> String {
switch urlScheme?.lowercased() {
case "http": "--http-proxy"
case "https": "--https-proxy"
case "ftp": "--ftp-proxy"
default: "--all-proxy"
}
}
private enum SystemProxyResolution {
case direct
case proxy(String)
case unsupported(String)
}
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
return .direct
}
for proxy in proxies {
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
if type == kCFProxyTypeNone as String {
return .direct
}
if type == kCFProxyTypeSOCKS as String {
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
}
if type == kCFProxyTypeAutoConfigurationURL as String ||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
}
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
return .proxy(uri)
}
}
return .direct
}
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
!host.isEmpty else {
return nil
}
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
let scheme: String
if type == kCFProxyTypeHTTPS as String {
scheme = "https"
} else if type == kCFProxyTypeFTP as String {
scheme = "ftp"
} else {
scheme = "http"
}
guard let port else {
return "\(scheme)://\(host)"
}
return "\(scheme)://\(host):\(port)"
}
private func inputFileContent(for item: DownloadItem) -> String {
let connections = min(max(item.connectionsPerServer, 1), 16)
let urls = ([item.url] + (item.mirrorURLs ?? []))
.map { sanitizedOptionValue($0.absoluteString) }
.joined(separator: "\t")
var lines = [
urls,
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
" split=\(connections)",
" max-connection-per-server=\(connections)"
]
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
}
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
}
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
}
if let credentials = item.credentials, !credentials.isEmpty {
let scheme = item.url.scheme?.lowercased()
if scheme == "ftp" || scheme == "sftp" {
lines.append(" ftp-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" ftp-passwd=\(sanitizedOptionValue(credentials.password))")
} else {
lines.append(" http-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" http-passwd=\(sanitizedOptionValue(credentials.password))")
lines.append(" http-auth-challenge=true")
}
}
return lines.joined(separator: "\n") + "\n"
}
private func sanitizedOptionValue(_ value: String) -> String {
value.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
}
}
final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var storage = Data()
private let maxBytes: Int
init(maxBytes: Int = 512 * 1024) {
self.maxBytes = maxBytes
}
var data: Data {
lock.withLock { storage }
}
func append(_ data: Data) {
lock.withLock {
storage.append(data)
if storage.count > maxBytes {
storage.removeFirst(storage.count - maxBytes)
}
}
}
}
final class 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])
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "MenuBarIconTemplate.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+160
View File
@@ -0,0 +1,160 @@
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)
}
}
+229
View File
@@ -0,0 +1,229 @@
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@State private var showDeleteConfirmation = false
var body: some View {
NavigationSplitView {
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
.themeBackground(settings.appTheme.theme.secondaryBackground)
} detail: {
detailView
.themeBackground(settings.appTheme.theme.background)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
for provider in providers {
if provider.canLoadObject(ofClass: URL.self) {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url = url {
DispatchQueue.main.async {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
} else if provider.canLoadObject(ofClass: String.self) {
_ = provider.loadObject(ofClass: String.self) { text, _ in
if let text = text {
DispatchQueue.main.async {
controller.pendingPasteboardText = text
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
}
}
return true
}
}
@ViewBuilder
private var detailView: some View {
switch sidebarSelection {
case .downloads(let filter):
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .speedLimiter:
SpeedLimiterView()
case .settings:
SettingsPaneContainer()
}
}
private func queueView(queueID: UUID) -> some View {
downloadsView(
filter: .all,
title: controller.queueName(for: queueID),
items: controller.queueItems(for: queueID),
queueID: queueID
)
}
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
downloadsView(
filter: filter,
title: filter.title,
items: filteredDownloads(for: filter),
queueID: nil
)
}
private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View {
VStack(spacing: 0) {
DownloadTable(
items: items,
selection: $selection,
title: title,
queueID: queueID
)
Divider()
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
ToolbarItemGroup {
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
Button {
if selectedItems.isEmpty {
controller.pauseActiveDownloads(queueID: queueID)
} else {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
}
.disabled(!canStop)
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
Button {
if selectedItems.isEmpty {
controller.startQueue(queueID: queueID)
} else {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
}
.disabled(!canStart)
}
}
.background {
Button("") {
if !selection.isEmpty {
showDeleteConfirmation = true
}
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
handlePaste(queueID: queueID)
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
Button("") {
selectAll(items: items)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
}
}
private var selectedItems: [DownloadItem] {
controller.downloads.filter { selection.contains($0.id) }
}
private func deleteSelected(deleteFiles: Bool) {
for item in selectedItems {
controller.delete(item, deleteFiles: deleteFiles)
}
selection.removeAll()
}
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingReferer = nil
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
private func selectAll(items: [DownloadItem]) {
selection = Set(items.map { $0.id })
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
}
return controller.activeCount > 0
}
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.queueItems(for: queueID).contains { $0.status == .queued }
}
return controller.queuedCount > 0
}
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
controller.downloads
case .queued:
controller.downloads.filter { $0.status == .queued }
case .active:
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .unfinished:
controller.downloads.filter { $0.status != .completed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
}
}
+899
View File
@@ -0,0 +1,899 @@
import AppKit
import Combine
import Foundation
import UserNotifications
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
@Published var pendingReferer: String?
var pendingAddQueueID: UUID?
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
}()
private var saveTask: Task<Void, Never>?
init(settings: AppSettings) {
self.settings = settings
let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in
Task { @MainActor in
self?.updateSleepActivity()
}
}
.store(in: &cancellables)
settings.$globalSpeedLimitKiBPerSecond
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
}
}
.store(in: &cancellables)
settings.$maxConcurrentDownloads
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
self?.pumpQueue()
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.saveDownloads()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
}
.store(in: &cancellables)
if shouldResumeRecoveredDownloads {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.queuePumpScope = .all
self.pumpQueue()
}
}
}
deinit {
sleepActivity?.end()
}
var activeCount: Int {
downloads.filter { $0.status == .downloading }.count
}
var queuedCount: Int {
downloads.filter { $0.status == .queued }.count
}
var completedCount: Int {
downloads.filter { $0.status == .completed }.count
}
var unfinishedCount: Int {
downloads.filter { $0.status != .completed }.count
}
var hasAria2: Bool {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
engineMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
return
}
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
let item = DownloadItem(
url: url,
fileName: fileName,
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
credentials: settings.credentials(for: url),
queueID: normalizedQueueID(queueID)
)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID,
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
speedLimitKiBPerSecond: Int? = nil
) {
let clampedConnections = min(max(connectionsPerServer, 1), 16)
let targetQueueID = normalizedQueueID(queueID)
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
return DownloadItem(
url: pending.url,
fileName: fileName,
category: FileClassifier.category(forFileName: fileName),
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: credentials ?? settings.credentials(for: pending.url),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader,
mirrorURLs: transferOptions.mirrorURLs,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
)
}
for item in items {
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
}
downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
saveDownloads()
if startImmediately {
startQueue(queueID: targetQueueID)
}
}
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
if let queueID {
let queueID = normalizedQueueID(queueID)
switch queuePumpScope {
case .all:
break
case .idle:
queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: [])
case .scoped(var queueIDs, let itemIDs):
queueIDs.insert(queueID)
queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
} else {
queuePumpScope = .all
}
markQueuedDownloadsForAutoResume(queueID: queueID)
pumpQueue()
}
func pause(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func pauseActiveDownloads(queueID: UUID? = nil) {
let targetQueueID = queueID.map(normalizedQueueID)
let activeItems = downloads.filter { item in
item.status == .downloading && (targetQueueID == nil || item.queueID == targetQueueID)
}
guard !activeItems.isEmpty else { return }
for item in activeItems {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
}
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func queue(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .queued
if item.status != .paused {
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
}
$0.message = "Added to queue"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
let queueID = normalizedQueueID(queueID)
var changed = false
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
guard downloads[index].status != .completed,
downloads[index].status != .downloading else {
continue
}
downloads[index].status = .queued
downloads[index].queueID = queueID
downloads[index].message = "Added to \(queueName(for: queueID))"
downloads[index].autoResumeOnLaunch = false
automaticRetryCounts[downloads[index].id] = nil
changed = true
}
if changed {
saveDownloads()
updateSleepActivity()
}
}
func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.message = ""
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func redownload(_ item: DownloadItem) {
trashFiles(for: item)
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Redownloading"
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
delete(item, deleteFiles: deleteFiles)
}
}
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
if deleteFiles {
trashFiles(for: item)
} else if item.status != .completed {
removeCacheFiles(for: item)
}
KeychainCredentialStore.deletePassword(for: item.id)
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
saveDownloads()
}
func queueName(for id: UUID) -> String {
queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name
}
func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id)
return downloads.filter { validQueueID($0.queueID) == id }
}
func queueCount(for id: UUID) -> Int {
queueItems(for: id).count
}
@discardableResult
func addQueue() -> DownloadQueue {
var index = 2
var name = "Queue \(index)"
let existingNames = Set(queues.map(\.name))
while existingNames.contains(name) {
index += 1
name = "Queue \(index)"
}
let queue = DownloadQueue(name: name)
queues.append(queue)
saveDownloads()
return queue
}
func renameQueue(id: UUID, name: String) {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty,
id != DownloadQueue.mainQueueID,
let index = queues.firstIndex(where: { $0.id == id }) else {
return
}
queues[index].name = cleanName
saveDownloads()
}
func removeQueue(id: UUID) {
guard id != DownloadQueue.mainQueueID,
queues.contains(where: { $0.id == id }) else {
return
}
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
downloads[index].queueID = nil
}
queues.removeAll { $0.id == id }
engineMessage = "Removed queue. Downloads remain in Unfinished."
saveDownloads()
}
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID)
guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return
}
let item = downloads.remove(at: source)
let insertionIndex = source < target ? target - 1 : target
downloads.insert(item, at: insertionIndex)
saveDownloads()
}
private func pumpQueue() {
guard hasAria2 else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return
}
pruneActiveQueueScopes()
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
start(next)
}
if restrictQueueToAutoResume &&
activeCount == 0 &&
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
pruneActiveQueueScopes()
}
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
$0.lastTryAt = Date()
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
$0.autoResumeOnLaunch = true
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
},
completion: { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.activeHandles[item.id] = nil
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.updateSleepActivity()
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
updateSleepActivity()
pumpQueue()
}
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
}
func updateDownload(
id: UUID,
url: URL,
fileName: String,
destinationDirectory: URL,
connectionsPerServer: Int,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions,
speedLimitKiBPerSecond: Int?
) {
update(id) {
$0.url = url
$0.fileName = FileClassifier.sanitizedFileName(fileName)
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
$0.checksum = transferOptions.checksum
$0.requestHeaders = transferOptions.requestHeaders
$0.cookieHeader = transferOptions.cookieHeader
$0.mirrorURLs = transferOptions.mirrorURLs
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
$0.message = "Properties updated"
}
if let password = credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: id)
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
applySpeedLimitToActiveDownload(id: id)
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
private func applySpeedLimitsToActiveDownloads() {
for item in downloads where item.status == .downloading {
applySpeedLimitToActiveDownload(id: item.id)
}
}
private func applySpeedLimitToActiveDownload(id: UUID) {
guard let handle = activeHandles[id],
let item = downloads.first(where: { $0.id == id }) else {
return
}
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
Task {
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
}
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
if let queueID {
let normalizedID = normalizedQueueID(queueID)
for index in downloads.indices where downloads[index].status == .queued &&
validQueueID(downloads[index].queueID) == normalizedID {
downloads[index].autoResumeOnLaunch = true
}
} else {
for index in downloads.indices where downloads[index].status == .queued {
downloads[index].autoResumeOnLaunch = true
}
}
saveDownloads()
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = error.localizedDescription
$0.autoResumeOnLaunch = false
}
saveDownloads()
if let item = downloads.first(where: { $0.id == itemID }) {
showNotification(title: "Download Failed", body: item.fileName)
}
return
}
automaticRetryCounts[itemID] = retryCount + 1
update(itemID) {
$0.status = .queued
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
switch queuePumpScope {
case .idle:
return false
case .all:
return true
case .scoped(let queueIDs, let itemIDs):
if itemIDs.contains(item.id) {
return true
}
guard let queueID = validQueueID(item.queueID) else { return false }
return queueIDs.contains(queueID)
}
}
private func pruneActiveQueueScopes() {
switch queuePumpScope {
case .idle:
return
case .all:
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
queuePumpScope = .idle
}
case .scoped(let queueIDs, let itemIDs):
let activeQueueIDs = queueIDs.filter { queueID in
downloads.contains { item in
validQueueID(item.queueID) == queueID &&
(item.status == .queued || item.status == .downloading)
}
}
let activeItemIDs = itemIDs.filter { itemID in
downloads.contains { item in
item.id == itemID && (item.status == .queued || item.status == .downloading)
}
}
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
? .idle
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
}
}
private enum QueuePumpScope {
case idle
case all
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
func includingItem(_ itemID: UUID) -> QueuePumpScope {
switch self {
case .idle:
return .scoped(queueIDs: [], itemIDs: [itemID])
case .all:
return .all
case .scoped(let queueIDs, var itemIDs):
itemIDs.insert(itemID)
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
}
}
private func updateSleepActivity() {
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
if shouldPreventSleep, sleepActivity == nil {
sleepActivity = SleepActivityHandle(activity: ProcessInfo.processInfo.beginActivity(
options: [.idleSystemSleepDisabled],
reason: "Firelink is downloading files."
))
} else if !shouldPreventSleep, let activity = sleepActivity {
activity.end()
sleepActivity = nil
}
}
private func removeCacheFiles(for item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
let candidates = [URL(fileURLWithPath: fileURL.path + ".aria2")]
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
try? FileManager.default.removeItem(at: candidate)
}
}
private func trashFiles(for item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
let candidates = [
fileURL,
URL(fileURLWithPath: fileURL.path + ".aria2")
]
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
try? FileManager.default.trashItem(at: candidate, resultingItemURL: nil)
}
}
private func saveDownloads() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL
saveTask?.cancel()
saveTask = Task.detached(priority: .background) {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
guard !Task.isCancelled else { return }
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
}
}
}
private func loadDownloads() -> Bool {
do {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL)
let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState
isLegacyDownloadList = false
} else {
state = StoredDownloadState(
queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
)
isLegacyDownloadList = true
}
var shouldResumeRecoveredDownloads = false
var shouldRewriteStoredDownloads = isLegacyDownloadList
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
let redacted = adjusted.redactedForPersistence
if redacted != adjusted {
adjusted = redacted
shouldRewriteStoredDownloads = true
}
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
}
if adjusted.status == .downloading {
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = true
shouldResumeRecoveredDownloads = true
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
adjusted.message = "Recovered queued download."
shouldResumeRecoveredDownloads = true
}
return adjusted
}
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
} catch {
print("Failed to load downloads: \(error)")
return false
}
}
private func normalizedQueueID(_ id: UUID?) -> UUID {
validQueueID(id) ?? DownloadQueue.mainQueueID
}
private func validQueueID(_ id: UUID?) -> UUID? {
guard let id, queues.contains(where: { $0.id == id }) else {
return nil
}
return id
}
private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] {
var normalized = queues
if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) {
normalized.insert(.main, at: 0)
}
if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 {
let main = normalized.remove(at: mainIndex)
normalized.insert(main, at: 0)
}
return normalized
}
private func showNotification(title: String, body: String) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
}
private struct StoredDownloadState: Codable {
var queues: [DownloadQueue]
var downloads: [DownloadItem]
}
private final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol
init(activity: NSObjectProtocol) {
self.activity = activity
}
func end() {
ProcessInfo.processInfo.endActivity(activity)
}
}
@@ -0,0 +1,123 @@
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,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
var pending = PendingDownload(
url: url,
fileName: initialName,
category: initialCategory,
defaultDirectory: initialDirectory,
state: .loading
)
guard url.scheme?.lowercased().hasPrefix("http") == true else {
pending.state = .loaded
return pending
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
request.setValue(header.value, forHTTPHeaderField: header.name)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
pending.state = .loaded
return pending
}
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
let fileName = fileName(fromContentDisposition: disposition) {
pending.fileName = FileClassifier.sanitizedFileName(fileName)
pending.category = FileClassifier.category(forFileName: pending.fileName)
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
}
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let bytes = Int64(contentLength) {
pending.sizeBytes = bytes
} else if response.expectedContentLength > 0 {
pending.sizeBytes = response.expectedContentLength
}
pending.mimeType = httpResponse.mimeType
pending.state = .loaded
} catch {
pending.state = .failed(error.localizedDescription)
}
return pending
}
private static func fileName(fromContentDisposition header: String) -> String? {
let parts = header.components(separatedBy: ";")
for part in parts {
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("filename*="),
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if trimmed.lowercased().hasPrefix("filename=") {
return String(trimmed.dropFirst("filename=".count))
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
return nil
}
}
enum ByteFormatter {
static func string(_ bytes: Int64?) -> String {
guard let bytes else { return "Unknown" }
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.includesUnit = true
formatter.includesCount = true
return formatter.string(fromByteCount: bytes)
}
}
@@ -0,0 +1,315 @@
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
init(item: DownloadItem) {
self.item = item
_urlText = State(initialValue: item.url.absoluteString)
_fileName = State(initialValue: item.fileName)
_destinationPath = State(initialValue: item.destinationDirectory.path)
_connections = State(initialValue: item.connectionsPerServer)
_speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0)
_speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1))
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
_password = State(initialValue: credentials.password)
} else {
_loginMode = State(initialValue: .matching)
_username = State(initialValue: "")
_password = State(initialValue: "")
}
if let checksum = item.checksum {
_checksumEnabled = State(initialValue: true)
_checksumAlgorithm = State(initialValue: checksum.algorithm)
_checksumValue = State(initialValue: checksum.value)
} else {
_checksumEnabled = State(initialValue: false)
_checksumAlgorithm = State(initialValue: .sha256)
_checksumValue = State(initialValue: "")
}
_headerText = State(initialValue: (item.requestHeaders ?? []).map(\.headerLine).joined(separator: "\n"))
_cookieText = State(initialValue: item.cookieHeader ?? "")
_mirrorText = State(initialValue: (item.mirrorURLs ?? []).map(\.absoluteString).joined(separator: "\n"))
}
var body: some View {
VStack(spacing: 0) {
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.callout, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
TextField("Save location", text: $destinationPath)
.font(.system(.callout, design: .monospaced))
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
Toggle("Limit speed", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
Section("Site Login") {
Picker("Login", selection: $loginMode) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
if loginMode == .matching {
Text(matchingLoginText)
.font(.caption)
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
SecureField("Password", text: $password)
}
}
DisclosureGroup("Advanced Transfer", isExpanded: $showsAdvancedTransfer) {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.callout, design: .monospaced))
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 60)
}
TextField("Cookies", text: $cookieText)
.font(.system(.callout, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 60)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
if item.status == .downloading && item.rpcPort != nil {
Section("Chunk Map") {
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(14)
.background(.bar)
}
.frame(width: 720, height: 720)
}
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 InfoGrid: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
info("Status", item.status.rawValue)
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
info("Size", ByteFormatter.string(item.sizeBytes))
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
info("Destination", item.destinationPath)
}
}
private func info(_ label: String, _ value: String) -> some View {
GridRow {
Text(label)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(2)
}
}
}
+360
View File
@@ -0,0 +1,360 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct DownloadTable: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
let items: [DownloadItem]
@Binding var selection: Set<DownloadItem.ID>
let title: String
var queueID: UUID?
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
var sortedItems: [DownloadItem] {
items.sorted(using: sortOrder)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(title)
.font(.headline)
Text("\(items.count)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 5))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
}
.draggable(item.id.uuidString)
}
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
doubleClickableCell(for: item) {
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
doubleClickableCell(for: item) {
progressBarCell(for: item)
}
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
doubleClickableCell(for: item) {
Text(item.status.rawValue)
}
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
doubleClickableCell(for: item) {
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
doubleClickableCell(for: item) {
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
doubleClickableCell(for: item) {
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 100, ideal: 155)
}
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
}
.confirmationDialog(
"Delete Download",
isPresented: Binding(
get: { pendingDeleteItems != nil },
set: { isPresented in
if !isPresented {
pendingDeleteItems = nil
}
}
),
presenting: pendingDeleteItems
) { ids in
Button("Remove from List") {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: false) }
selection.subtract(ids)
pendingDeleteItems = nil
}
Button("Move to Trash", role: .destructive) {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: true) }
selection.subtract(ids)
pendingDeleteItems = nil
}
Button("Cancel", role: .cancel) {
pendingDeleteItems = nil
}
} message: { ids in
let items = controller.downloads.filter { ids.contains($0.id) }
if items.allSatisfy({ $0.status == .completed }) {
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink, or also move the downloaded files to Trash.")
} else {
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial files there.")
}
}
}
private func doubleClickableCell<Content: View>(
for item: DownloadItem,
@ViewBuilder content: () -> Content
) -> some View {
content()
.contentShape(Rectangle())
.onTapGesture(count: 2) {
performPrimaryAction(for: item)
}
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
if !targetItems.isEmpty {
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
for target in targetItems {
openFile(target)
}
} label: {
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
}
}
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
Divider()
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Move to Queue", systemImage: "list.bullet")
}
Divider()
}
Button {
NSPasteboard.general.clearContents()
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
NSPasteboard.general.setString(urls, forType: .string)
} label: {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
}
}
}
private func categoryColor(for category: DownloadCategory) -> Color {
switch category {
case .musics: return .pink
case .movies: return .indigo
case .compressed: return .orange
case .pictures: return .teal
case .documents: return .blue
case .other: return .gray
}
}
private func statusColor(for status: DownloadStatus) -> Color {
switch status {
case .queued: return .secondary
case .downloading: return .blue
case .paused: return .orange
case .completed: return .green
case .failed: return .red
case .canceled: return .gray
}
}
@ViewBuilder
private func progressBarCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.lineLimit(1)
.truncationMode(.tail)
} else {
GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4)
.fill(statusColor(for: item.status))
.frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
}
}
.frame(height: 16)
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
}
private func showInFinder(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
private func existingFolder(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
private func openFile(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.open(fileURL)
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
}
+86
View File
@@ -0,0 +1,86 @@
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))
}
}
+118
View File
@@ -0,0 +1,118 @@
import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
// Server must be retained to keep listening
private let extensionServer: LocalExtensionServer?
init() {
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
}
var body: some Scene {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
}
.windowResizability(.contentSize)
.commands {
CommandGroup(after: .newItem) {
Button("Add Downloads...") {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
controller.startQueue()
}
.keyboardShortcut("r", modifiers: [.command])
Button("Stop Downloads") {
controller.pauseActiveDownloads()
}
.disabled(controller.activeCount == 0)
}
}
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
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
}
}
@@ -0,0 +1,50 @@
import Foundation
import Security
enum KeychainCredentialStore {
private static let service = "local.firelink.site-login"
static func password(for id: UUID) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
@discardableResult
static func setPassword(_ password: String, for id: UUID) -> Bool {
deletePassword(for: id)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
@discardableResult
static func deletePassword(for id: UUID) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
+263
View File
@@ -0,0 +1,263 @@
import Foundation
import Network
import AppKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let portRange = 6412...6422
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
static let extensionRequestToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
self.downloadController = downloadController
let parameters = NWParameters.tcp
var createdListener: NWListener?
var selectedPort: UInt16?
for portValue in Constants.portRange {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
selectedPort = UInt16(portValue)
break
} catch {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
self.port = selectedPort ?? 6412
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection)
}
listener.stateUpdateHandler = { state in
print("LocalExtensionServer state: \(state)")
}
listener.start(queue: queue)
}
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(from: connection, accumulatedData: Data())
}
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else {
connection.cancel()
return
}
var requestData = accumulatedData
if let data {
requestData.append(data)
}
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
return
}
if let request = HTTPRequest(data: requestData) {
let status = self.processRequest(request)
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
return
}
if isComplete {
self.sendResponse(.badRequest, connection: connection, origin: nil)
return
}
self.receiveRequest(from: connection, accumulatedData: requestData)
}
}
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
var headers = [
"HTTP/1.1 \(status.rawValue) \(status.reason)",
"Content-Length: 0",
"Connection: close"
]
if let origin, isAllowedExtensionOrigin(origin) {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
guard let url = URL(string: origin),
let scheme = url.scheme?.lowercased() else {
return false
}
return scheme == "moz-extension" || scheme == "chrome-extension"
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
if request.method == "OPTIONS" {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
guard request.method == "POST" else {
return .methodNotAllowed
}
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
return .forbidden
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
return .unsupportedMediaType
}
struct Payload: Decodable {
let urls: [String]
let referer: String?
}
do {
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
let validURLs = payload.urls
.prefix(Constants.maxURLCount)
.compactMap { rawURL -> String? in
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
Constants.allowedSchemes.contains(scheme) else {
return nil
}
return url.absoluteString
}
guard !validURLs.isEmpty else {
return .badRequest
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
self.downloadController.pendingReferer = payload.referer
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
}
return .ok
} catch {
print("Failed to parse local request JSON: \(error)")
return .badRequest
}
}
}
private struct HTTPRequest {
var method: String
var path: String
var headers: [String: String]
var body: Data
init?(data: Data) {
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
return nil
}
let headerData = data[..<headerRange.lowerBound]
guard let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else {
return nil
}
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
guard requestParts.count >= 2 else {
return nil
}
var parsedHeaders: [String: String] = [:]
for line in lines.dropFirst() {
guard let colonIndex = line.firstIndex(of: ":") else {
continue
}
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
parsedHeaders[name] = value
}
let bodyStart = headerRange.upperBound
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
guard expectedBodyLength >= 0,
data.count >= bodyStart + expectedBodyLength else {
return nil
}
method = String(requestParts[0]).uppercased()
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
headers = parsedHeaders
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
}
func header(named name: String) -> String? {
headers[name.lowercased()]
}
}
private enum HTTPStatus: Int {
case ok = 200
case noContent = 204
case badRequest = 400
case forbidden = 403
case notFound = 404
case methodNotAllowed = 405
case payloadTooLarge = 413
case unsupportedMediaType = 415
var reason: String {
switch self {
case .ok: "OK"
case .noContent: "No Content"
case .badRequest: "Bad Request"
case .forbidden: "Forbidden"
case .notFound: "Not Found"
case .methodNotAllowed: "Method Not Allowed"
case .payloadTooLarge: "Payload Too Large"
case .unsupportedMediaType: "Unsupported Media Type"
}
}
}
+266
View File
@@ -0,0 +1,266 @@
import Foundation
enum DownloadStatus: String, Codable, CaseIterable, Sendable {
case queued = "Queued"
case downloading = "Downloading"
case paused = "Paused"
case completed = "Completed"
case failed = "Failed"
case canceled = "Canceled"
}
enum DownloadCategory: String, Codable, CaseIterable, Sendable {
case musics = "Musics"
case movies = "Movies"
case compressed = "Compressed"
case pictures = "Pictures"
case documents = "Documents"
case other = "Other"
var symbolName: String {
switch self {
case .musics: "music.note"
case .movies: "film"
case .compressed: "archivebox"
case .pictures: "photo"
case .documents: "doc.text"
case .other: "folder"
}
}
}
struct DownloadQueue: Identifiable, Codable, Equatable, Sendable {
static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
var id = UUID()
var name: String
var isMain: Bool {
id == Self.mainQueueID
}
static var main: DownloadQueue {
DownloadQueue(id: mainQueueID, name: "Main queue")
}
}
struct DownloadCredentials: Codable, Equatable, Sendable {
var username: String
var password: String
var isEmpty: Bool {
username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
password.isEmpty
}
}
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
case md5
case sha1 = "sha-1"
case sha256 = "sha-256"
case sha512 = "sha-512"
var id: String { rawValue }
var title: String {
switch self {
case .md5: "MD5"
case .sha1: "SHA-1"
case .sha256: "SHA-256"
case .sha512: "SHA-512"
}
}
}
struct DownloadChecksum: Codable, Equatable, Sendable {
var algorithm: ChecksumAlgorithm
var value: String
var normalized: DownloadChecksum {
DownloadChecksum(
algorithm: algorithm,
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
)
}
var isEmpty: Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
struct DownloadRequestHeader: Codable, Equatable, Sendable {
var name: String
var value: String
var normalized: DownloadRequestHeader {
DownloadRequestHeader(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
var isEmpty: Bool {
let clean = normalized
return clean.name.isEmpty && clean.value.isEmpty
}
var headerLine: String {
let clean = normalized
return "\(clean.name): \(clean.value)"
}
}
struct DownloadTransferOptions: Equatable, Sendable {
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader] = []
var cookieHeader: String?
var mirrorURLs: [URL] = []
}
enum DownloadTransferOptionParser {
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
headerLines(text).compactMap { line in
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
let header = DownloadRequestHeader(name: name, value: value).normalized
return header.isEmpty || header.name.isEmpty ? nil : header
}
}
static func invalidHeaderLines(_ text: String) -> [String] {
headerLines(text).filter { line in
guard let colonIndex = line.firstIndex(of: ":") else { return true }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty
}
}
static func cleanCookieHeader(_ text: String) -> String? {
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
if value.lowercased().hasPrefix("cookie:") {
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value.isEmpty ? nil : value
}
static func parseMirrorURLs(_ text: String) -> [URL] {
DownloadURLParser.parse(text)
}
static func invalidMirrorLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
}
private static func headerLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var destinationDirectory: URL
var connectionsPerServer: Int
var credentials: DownloadCredentials?
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader]?
var cookieHeader: String?
var mirrorURLs: [URL]?
var speedLimitKiBPerSecond: Int?
var status: DownloadStatus = .queued
var progress: Double = 0
var speedText: String = "-"
var etaText: String = "-"
var connectionCount: Int = 0
var sizeBytes: Int64?
var bytesText: String = "-"
var message: String = ""
var createdAt = Date()
var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var queueID: UUID?
var rpcPort: Int?
var rpcSecret: String?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
var sortableSize: Int64 {
sizeBytes ?? 0
}
var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksum,
requestHeaders: requestHeaders ?? [],
cookieHeader: cookieHeader,
mirrorURLs: mirrorURLs ?? []
)
}
var speedLimitText: String {
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
return "No limit"
}
return "\(speedLimitKiBPerSecond) KiB/s"
}
var redactedForPersistence: DownloadItem {
var item = self
if item.credentials != nil {
item.credentials?.password = ""
}
item.cookieHeader = nil
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
return item
}
}
private extension DownloadRequestHeader {
var containsSensitiveValue: Bool {
switch normalized.name.lowercased() {
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
true
default:
false
}
}
}
struct DownloadProgress: Equatable, Sendable {
var fraction: Double
var bytesText: String
var speedText: String
var etaText: String
var connectionCount: Int
}
struct PendingDownload: Identifiable, Equatable, Sendable {
enum MetadataState: Equatable, Sendable {
case pending
case loading
case loaded
case failed(String)
}
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var defaultDirectory: URL
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path
}
}
+240
View File
@@ -0,0 +1,240 @@
import AppKit
import Combine
import Foundation
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
case doNothing = "Do nothing"
case sleep = "Sleep"
case restart = "Restart"
case shutdown = "Shut down"
var id: String { rawValue }
}
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
var id: Int { rawValue }
var shortName: String {
switch self {
case .sunday: "S"
case .monday: "M"
case .tuesday: "T"
case .wednesday: "W"
case .thursday: "T"
case .friday: "F"
case .saturday: "S"
}
}
}
struct SchedulerSettings: Codable, Equatable {
var isEnabled: Bool = false
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
}
@MainActor
final class SchedulerController: ObservableObject {
@Published var settings: SchedulerSettings
@Published var isRunning: Bool = false
@Published var hasAutomationPermission: Bool = false
private let downloadController: DownloadController
private var cancellables = Set<AnyCancellable>()
private var timer: Timer?
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date?
init(downloadController: DownloadController) {
self.downloadController = downloadController
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
self.settings = stored
} else {
self.settings = SchedulerSettings()
}
if self.settings.targetQueueIDs.isEmpty {
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
}
checkAutomationPermission()
startTimer()
$settings
.dropFirst()
.sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
}
.store(in: &cancellables)
// Observe downloads to check if we should trigger post-action
downloadController.$downloads
.dropFirst()
.sink { [weak self] _ in
self?.checkIfRunningFinished()
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkSchedule()
}
}
}
private func checkSchedule() {
guard settings.isEnabled else { return }
let now = Date()
let calendar = Calendar.current
// Check if we already triggered in this exact minute
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
return
}
let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now)
let currentMinute = calendar.component(.minute, from: now)
let currentWeekday = calendar.component(.weekday, from: now)
if startHour == currentHour && startMinute == currentMinute {
let shouldRun: Bool
if settings.isEveryday {
shouldRun = true
} else {
let day = SchedulerDay(rawValue: currentWeekday)
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
}
if shouldRun {
lastTriggeredMinute = now
triggerQueues()
}
}
}
private func triggerQueues() {
let targetQueueIDs = effectiveTargetQueueIDs()
let runnableQueueIDs = targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
guard !runnableQueueIDs.isEmpty else { return }
isRunning = true
for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID)
}
checkIfRunningFinished()
}
private func checkIfRunningFinished() {
guard isRunning else { return }
let targetQueueIDs = effectiveTargetQueueIDs()
let hasActiveItems = targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func effectiveTargetQueueIDs() -> Set<UUID> {
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
scriptCode = "tell application \"Finder\" to sleep"
case .restart:
scriptCode = "tell application \"Finder\" to restart"
case .shutdown:
scriptCode = "tell application \"Finder\" to shut down"
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to perform scheduler post action: \(error)")
}
}
}
func checkAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
hasAutomationPermission = (status == noErr)
}
func requestAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
if status != noErr {
triggerAutomationConsentPrompt()
}
checkAutomationPermission()
if !hasAutomationPermission {
openAutomationPermissionSettings()
}
}
func openAutomationPermissionSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
return
}
NSWorkspace.shared.open(url)
}
private func triggerAutomationConsentPrompt() {
let scriptCode = "tell application \"Finder\" to get name"
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to trigger Automation permission prompt: \(error)")
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var startTime: Date = Date()
@State private var isEveryday: Bool = true
@State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
timeSelectionSection
queueSelectionSection
postActionSection
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
schedulerController.checkAutomationPermission()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
schedulerController.checkAutomationPermission()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Scheduler")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Settings") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Start Time")
.font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
Toggle("Everyday", isOn: $isEveryday)
if !isEveryday {
HStack(spacing: 12) {
ForEach(SchedulerDay.allCases) { day in
Toggle(day.shortName, isOn: Binding(
get: { selectedDays.contains(day) },
set: { isSelected in
if isSelected {
selectedDays.insert(day)
} else {
selectedDays.remove(day)
}
}
))
.toggleStyle(.button)
}
}
}
}
}
private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start")
.font(.headline)
if downloadController.queues.isEmpty {
Text("No queues available")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(downloadController.queues) { queue in
Toggle(queue.name, isOn: Binding(
get: { targetQueueIDs.contains(queue.id) },
set: { isSelected in
if isSelected {
targetQueueIDs.insert(queue.id)
} else {
targetQueueIDs.remove(queue.id)
}
}
))
}
}
}
}
}
private var postActionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("After Completion")
.font(.headline)
Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.pickerStyle(.radioGroup)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("System Permissions")
.font(.headline)
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
}
.buttonStyle(.borderedProminent)
}
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: targetQueueIDs
schedulerController.saveSettings()
}
}
+750
View File
@@ -0,0 +1,750 @@
import AppKit
import SwiftUI
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
private struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
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)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
private struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
private struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
var body: some View {
LabeledContent {
HStack(spacing: 8) {
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
}
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 {
settings.setDirectory(url.path, for: category)
}
}
}
private struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
Form {
Section("Add Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
.formStyle(.grouped)
}
}
private 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)
}
}
private struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = bundledFirefoxExtensionURL() else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func bundledFirefoxExtensionURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
? sourceTreeExtension
: nil
}
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
try fileManager.copyItem(
at: sourceURL.appendingPathComponent(component),
to: destinationURL.appendingPathComponent(component)
)
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case unfinished
case category(DownloadCategory)
var title: String {
switch self {
case .all: "All Downloads"
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .unfinished: "Unfinished"
case .category(let category): category.rawValue
}
}
}
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case scheduler
case speedLimiter
case settings
}
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@State private var queueName = ""
var body: some View {
List(selection: $selection) {
Section("Library") {
Label("All", systemImage: "tray.full")
.badge(controller.downloads.count)
.tag(SidebarSelection.downloads(.all))
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(SidebarSelection.downloads(.active))
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(SidebarSelection.downloads(.completed))
Label("Unfinished", systemImage: "circle.dashed")
.badge(controller.unfinishedCount)
.tag(SidebarSelection.downloads(.unfinished))
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
folderRow(for: category)
}
}
Section("Queues") {
ForEach(controller.queues) { queue in
queueRow(for: queue)
}
Button {
let queue = controller.addQueue()
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
Label("Speed Limiter", systemImage: "speedometer")
.tag(SidebarSelection.speedLimiter)
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(selection == .settings ? Color.accentColor : Color.clear)
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.background(.regularMaterial)
}
.alert("Rename Queue", isPresented: Binding(
get: { queueBeingRenamed != nil },
set: { isPresented in
if !isPresented {
queueBeingRenamed = nil
}
}
)) {
TextField("Queue name", text: $queueName)
Button("Rename") {
if let queue = queueBeingRenamed {
controller.renameQueue(id: queue.id, name: queueName)
}
queueBeingRenamed = nil
}
Button("Cancel", role: .cancel) {
queueBeingRenamed = nil
}
}
.confirmationDialog(
"Delete Queue",
isPresented: Binding(
get: { queueBeingRemoved != nil },
set: { isPresented in
if !isPresented {
queueBeingRemoved = nil
}
}
),
presenting: queueBeingRemoved
) { queue in
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
}
queueBeingRemoved = nil
}
Button("Cancel", role: .cancel) {
queueBeingRemoved = nil
}
} message: { queue in
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
}
}
private func folderRow(for category: DownloadCategory) -> some View {
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
}
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
of: [.text],
delegate: QueueSidebarDropDelegate(
queueID: queue.id,
selection: $selection,
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
private struct QueueSidebarDropDelegate: DropDelegate {
let queueID: UUID
@Binding var selection: SidebarSelection
let controller: DownloadController
func performDrop(info: DropInfo) -> Bool {
guard let provider = info.itemProviders(for: [.text]).first else {
return false
}
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
Task { @MainActor in
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
selection = .queue(queueID)
}
}
return true
}
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
let text: String?
if let data = item as? Data {
text = String(data: data, encoding: .utf8)
} else {
text = item as? String
}
guard let text else { return nil }
return Set(text
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
.compactMap { UUID(uuidString: String($0)) })
}
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
limitSelectionSection
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Speed Limiter")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Limit") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit")
.font(.headline)
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:")
}
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
// Helpful presets
HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
}
.buttonStyle(.bordered)
.padding(.top, 8)
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Speed Limit Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
}
private func saveState() {
// Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed
lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
}
}
+21
View File
@@ -0,0 +1,21 @@
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)
}
}
+173
View File
@@ -0,0 +1,173 @@
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
}
}
}
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))
}
}
+41
View File
@@ -0,0 +1,41 @@
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")
}
}
}
-42
View File
@@ -1,42 +0,0 @@
# 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.
-51
View File
@@ -1,51 +0,0 @@
{
"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.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
},
"ffmpeg": {
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-gpl-8.1.zip",
"sha256": "ebf57e8b1a10b176b88c3cbc66e68a4aed472cf47520b0fbf003e892fb3be642"
},
"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.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
},
"ffmpeg": {
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-linux64-gpl-8.1.tar.xz",
"sha256": "c6c54589dd3443fb86b441351218d1f0e0cba8221134c3d2e7e3eda80e984747"
},
"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"
}
}
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"schemaVersion": 1,
"targets": {
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.07.04",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
},
"aria2c": {
"version": "1.37.0",
"source": "https://github.com/aria2/aria2",
"build": "arm64 executable with adjacent aria2-libs",
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "N-125610-g312c830916",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
},
"deno": {
"version": "2.9.3",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
}
},
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
},
"aria2-libs": {
"files": 7,
"sha256": "f6735fa4162513ca79adcc6bb65afdccdcd34c7c05e5d45c78da6d2e0c359e57"
}
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
<!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>
-2684
View File
File diff suppressed because it is too large Load Diff
-70
View File
@@ -1,70 +0,0 @@
{
"name": "firelink",
"private": true,
"version": "1.2.0",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
"repository": {
"type": "git",
"url": "https://github.com/nimbold/Firelink.git"
},
"bugs": {
"url": "https://github.com/nimbold/Firelink/issues"
},
"keywords": [
"download-manager",
"tauri",
"rust",
"react",
"typescript",
"aria2",
"yt-dlp",
"ffmpeg",
"desktop"
],
"engines": {
"node": ">=22"
},
"type": "module",
"scripts": {
"dev": "vite",
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:i18n": "vitest run src/i18n/resources.test.ts",
"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.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"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.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.21",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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]);
}
// A packaged Tauri artifact must have its own consent identity. A source or
// commit fingerprint cannot distinguish two fresh signed/package builds made
// from the same checkout, which would let an updated binary silently reuse the
// previous binary's credential-store approval. Keep any configured identity
// as useful provenance, but always add the artifact nonce.
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`;
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
fs.rmSync(engineDist, { recursive: true, force: true });
fs.mkdirSync(engineDist, { recursive: true });
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
}
runNpmScript('build');
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const userAgent = 'firelink-update-check';
function parseJsonFile(file) {
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
}
function normalizeVersion(value) {
return String(value || '')
.replace(/^v/, '')
.replace(/^release-/, '');
}
function compareVersions(left, right) {
const a = normalizeVersion(left).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
const b = normalizeVersion(right).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
const length = Math.max(a.length, b.length);
for (let index = 0; index < length; index += 1) {
const av = a[index] ?? 0;
const bv = b[index] ?? 0;
if (av === bv) continue;
if (typeof av === 'number' && typeof bv === 'number') return av > bv ? 1 : -1;
return String(av).localeCompare(String(bv));
}
return 0;
}
function npmOutdated(cwd) {
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
throw new Error(`npm workspace is missing package.json: ${cwd}`);
}
try {
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
return {};
} catch (error) {
if (error.status !== 1) {
const details = error.stderr?.toString().trim();
throw new Error(details || `npm outdated failed in ${cwd}`);
}
const output = error.stdout?.toString() || '{}';
return JSON.parse(output || '{}');
}
}
async function fetchJson(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.json();
}
async function fetchText(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.text();
}
async function githubLatest(repo) {
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
}
async function latestFfmpegStable() {
const html = await fetchText('https://ffmpeg.org/releases/');
const versions = [...html.matchAll(/ffmpeg-(\d+\.\d+(?:\.\d+)?)\.tar\.xz/g)].map(match => match[1]);
return [...new Set(versions)].sort(compareVersions).at(-1);
}
async function latestMartinRiedlMacArm64Release() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const releaseSection = html.split('Download Release Build')[1] || '';
const match =
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
return match?.[1];
}
async function latestMartinRiedlMacArm64Snapshot() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
const card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
const match =
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
return match?.[1]
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
: undefined;
}
async function latestBtbnFfmpegN81Build() {
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
for (const release of releases) {
if (release.tag_name === 'latest') continue;
const assets = (release.assets || [])
.map(asset => {
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
if (!match) return undefined;
return {
target: match[2] === 'win64' ? 'windows' : 'linux',
version: match[1],
url: asset.browser_download_url,
};
})
.filter(Boolean);
const unique = [...new Set(assets.map(asset => asset.version))];
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
if (unique.length === 1 && byTarget.windows && byTarget.linux) {
return {
version: unique[0],
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
};
}
}
return undefined;
}
function printNpmReport(label, outdated) {
const entries = Object.entries(outdated);
if (!entries.length) {
console.log(`${label}: current`);
return 0;
}
console.log(`${label}: ${entries.length} outdated package(s)`);
for (const [name, info] of entries) {
console.log(` ${name}: ${info.current} -> ${info.latest} (wanted ${info.wanted})`);
}
return entries.length;
}
function sourceEngineVersions(sourceLock) {
const rows = [];
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
for (const [engine, meta] of Object.entries(engines)) {
rows.push({ target, engine, version: meta.version, url: meta.url });
}
}
return rows;
}
function packagedEngineVersions(engineLock) {
const rows = [];
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
rows.push({ target, engine, version: meta.version, url: meta.url });
}
}
return rows;
}
function checkRows(rows, latestByEngine, latestByTargetEngine = {}, latestUrlsByTargetEngine = {}) {
let outdated = 0;
for (const row of rows) {
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
if (!latest) continue;
const current = normalizeVersion(row.version);
const wanted = normalizeVersion(latest);
const latestUrl = latestUrlsByTargetEngine[`${row.target}:${row.engine}`];
const versionOutdated = compareVersions(current, wanted) < 0;
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
const status = versionOutdated ? 'outdated' : sourceOutdated ? 'source-outdated' : 'current';
if (status !== 'current') outdated += 1;
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
}
return outdated;
}
async function main() {
let outdatedCount = 0;
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
outdatedCount += printNpmReport(
'Browser extension npm',
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
);
const [
ytDlp,
deno,
aria2,
ffmpeg,
martinRiedlMacArm64Ffmpeg,
martinRiedlMacArm64Snapshot,
btbnFfmpegN81Build,
] = await Promise.all([
githubLatest('yt-dlp/yt-dlp'),
githubLatest('denoland/deno'),
githubLatest('aria2/aria2'),
latestFfmpegStable(),
latestMartinRiedlMacArm64Release(),
latestMartinRiedlMacArm64Snapshot(),
latestBtbnFfmpegN81Build(),
]);
const latestByEngine = {
'yt-dlp': ytDlp.tag_name,
deno: deno.tag_name,
aria2c: aria2.tag_name,
ffmpeg,
};
const latestByTargetEngine = {
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg,
};
const latestUrlsByTargetEngine = {
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.urls.windows,
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.urls.linux,
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.url,
};
console.log('\nlatest engines:');
for (const [engine, version] of Object.entries(latestByEngine)) {
console.log(` ${engine}: ${normalizeVersion(version)}`);
}
console.log('\nlatest engine provider builds:');
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build?.version || ffmpeg)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
console.log('\nengine source lock:');
outdatedCount += checkRows(
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
latestByEngine,
latestByTargetEngine,
latestUrlsByTargetEngine
);
console.log('\npackaged engine lock:');
outdatedCount += checkRows(
packagedEngineVersions(parseJsonFile('engines.lock.json')),
latestByEngine,
latestByTargetEngine,
latestUrlsByTargetEngine
);
if (outdatedCount > 0) {
console.error(`\n${outdatedCount} outdated item(s) found.`);
process.exit(1);
}
console.log('\nAll checked packages and engines are current.');
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
-45
View File
@@ -1,45 +0,0 @@
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') };
}
@@ -1,42 +0,0 @@
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 });
}
});
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { execFileSync } from 'node:child_process';
import { 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' : '';
const DOWNLOAD_ATTEMPTS = 3;
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function removeFileWithRetry(file) {
for (let attempt = 0; ; attempt += 1) {
try {
fs.rmSync(file, { force: true });
return;
} catch (error) {
const retryable = process.platform === 'win32'
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
throw error;
}
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
}
}
}
function createDownloadTimeout() {
const controller = new AbortController();
let timer;
const refresh = () => {
clearTimeout(timer);
timer = setTimeout(() => {
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
}, DOWNLOAD_IDLE_TIMEOUT_MS);
};
const dispose = () => clearTimeout(timer);
refresh();
return { signal: controller.signal, refresh, dispose };
}
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'}`
);
let lastError;
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
const downloadTimeout = createDownloadTimeout();
try {
const response = await fetch(source.url, {
redirect: 'follow',
signal: downloadTimeout.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
await pipeline(
Readable.fromWeb(response.body),
new Transform({
transform(chunk, encoding, callback) {
downloadTimeout.refresh();
callback(null, chunk, encoding);
},
}),
fs.createWriteStream(archive),
{ signal: downloadTimeout.signal }
);
break;
} catch (error) {
lastError = error;
await removeFileWithRetry(archive);
if (attempt === DOWNLOAD_ATTEMPTS) {
throw new Error(
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
} finally {
downloadTimeout.dispose();
}
}
if (lastError && !fs.existsSync(archive)) {
throw lastError;
}
const actual = sha256(archive);
if (actual !== source.sha256) {
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
}
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 });
}
-157
View File
@@ -1,157 +0,0 @@
#!/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}.`);
-354
View File
@@ -1,354 +0,0 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const executableArg = argValue('--executable');
if (!executableArg) {
console.error('Pass --executable <path>.');
process.exit(1);
}
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const READY_PORT_TIMEOUT_MS = 500;
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
env: {
...process.env,
FIRELINK_SMOKE_TEST: '1',
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
GDK_BACKEND: 'x11',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let spawnError = null;
let readyPort = null;
let childExit = null;
child.on('error', error => {
spawnError = error;
});
child.on('exit', (code, signal) => {
childExit = { code, signal };
if (readyPort === null) {
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
}
});
child.stderr.on('data', data => {
stderr += data.toString();
});
child.stdout.on('data', () => {});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function findReadyPort() {
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
if (spawnError || childExit) {
break;
}
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
const matches = await Promise.all(ports.map(async port => {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild ? port : null;
} catch {
return null;
}
}));
readyPort = matches.find(port => port !== null) ?? null;
if (readyPort === null) {
await sleep(250);
}
}
}
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
}
const script = `
$root = ${rootPid}
$all = Get-CimInstance Win32_Process
$pending = @($root)
$descendants = @()
while ($pending.Count -gt 0) {
$parent = $pending[0]
if ($pending.Count -eq 1) {
$pending = @()
} else {
$pending = $pending[1..($pending.Count - 1)]
}
$children = @($all | Where-Object { $_.ParentProcessId -eq $parent })
foreach ($child in $children) {
$descendants += $child.ProcessId
$pending += $child.ProcessId
}
}
$visible = foreach ($childPid in $descendants) {
$process = Get-Process -Id $childPid -ErrorAction SilentlyContinue
if ($process -and $process.MainWindowHandle -ne 0) {
"$($process.ProcessName)($childPid)"
}
}
if ($visible.Count -gt 0) {
Write-Error "Visible child process windows detected: $($visible -join ', ')"
exit 1
}
`;
try {
execFileSync('powershell', ['-NoProfile', '-Command', script], {
stdio: 'pipe',
windowsHide: true,
});
} catch (error) {
const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim();
throw new Error(detail || 'Visible child process window check failed.');
}
}
function waitForChildExit(timeoutMs) {
if (childExit) {
return Promise.resolve(true);
}
return new Promise(resolve => {
let timer;
const onExit = () => {
clearTimeout(timer);
child.off('exit', onExit);
resolve(true);
};
timer = setTimeout(() => {
child.off('exit', onExit);
resolve(false);
}, timeoutMs);
child.once('exit', onExit);
});
}
function isProcessGroupAlive(rootPid) {
if (process.platform === 'win32') {
return false;
}
try {
process.kill(-rootPid, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
async function waitForProcessGroupExit(rootPid, timeoutMs) {
if (process.platform === 'win32') {
return true;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessGroupAlive(rootPid)) {
return true;
}
await sleep(100);
}
return !isProcessGroupAlive(rootPid);
}
function windowsBundleRoot() {
return path.dirname(executable).replaceAll("'", "''");
}
function windowsBundleProcessIds() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
Select-Object -ExpandProperty ProcessId
`;
try {
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return output
.split(/\r?\n/)
.map(value => Number.parseInt(value.trim(), 10))
.filter(Number.isInteger);
} catch {
return null;
}
}
function terminateWindowsBundleProcesses() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
`;
try {
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
async function waitForWindowsBundleExit(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const processIds = windowsBundleProcessIds();
if (processIds?.length === 0) {
return true;
}
await sleep(100);
}
return windowsBundleProcessIds()?.length === 0;
}
async function terminateChild() {
if (!child.pid) {
return true;
}
const childWasRunning = !childExit;
if (process.platform === 'win32') {
if (childWasRunning) {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
const childExited = await waitForChildExit(10000);
const bundleExited = await waitForWindowsBundleExit(5000);
if (childExited && bundleExited) {
return true;
}
terminateWindowsBundleProcesses();
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
}
if (childWasRunning && !childExit) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
if (!childExit) {
child.kill('SIGTERM');
}
}
}
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
return true;
}
if (!childWasRunning || childExit) {
return false;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
if (!childExit) {
child.kill('SIGKILL');
}
}
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
}
async function assertPortableStorage() {
const portableRoot = path.dirname(executable);
const marker = path.join(portableRoot, 'portable.flag');
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
const webviewData = path.join(portableRoot, 'data', 'webview');
for (let attempt = 0; attempt < 40; attempt += 1) {
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
if (markerReady && databaseReady && webviewReady) {
return;
}
await sleep(250);
}
throw new Error(
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
);
}
try {
await findReadyPort();
if (readyPort === null) {
if (spawnError) {
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
} else if (childExit) {
throw new Error(
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
);
} else {
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
}
}
if (assertNoVisibleChildWindows) {
assertNoVisibleWindows(child.pid);
}
if (assertPortableData) {
await assertPortableStorage();
}
if (childExit) {
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
if (!await terminateChild()) {
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
process.exitCode = 1;
}
}
-132
View File
@@ -1,132 +0,0 @@
#!/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}`);
-592
View File
@@ -1,592 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
const currentArch = archMap[os.arch()];
const currentPlatform = platformMap[os.platform()];
if (!currentArch || !currentPlatform) {
console.error(`Unsupported architecture or platform: ${os.arch()} / ${os.platform()}`);
process.exit(1);
}
const targetTriple = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| `${currentArch}-${currentPlatform}`;
const hostTriple = `${currentArch}-${currentPlatform}`;
const canExecuteTarget = targetTriple === hostTriple;
const isWindows = targetTriple.includes('windows');
const isMacOS = targetTriple.includes('apple-darwin');
const isLinux = targetTriple.includes('linux');
const ext = isWindows ? '.exe' : '';
const suffix = `-${targetTriple}${ext}`;
const scriptsDir = __dirname;
const searchRoot = argValue('--search-root');
function findEngineRoot(root) {
const expected = `yt-dlp-${targetTriple}${ext}`;
const matches = [];
const resolvedRoot = path.resolve(root);
const hasExpectedEngineFile = directory => {
try {
return fs.lstatSync(path.join(directory, expected)).isFile();
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
}
};
if (hasExpectedEngineFile(resolvedRoot)) {
matches.push(resolvedRoot);
}
const walk = directory => {
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
}
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
walk(candidate);
}
}
};
walk(resolvedRoot);
if (matches.length !== 1) {
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
}
return matches[0];
}
const configuredRoot = argValue('--root')
|| (process.argv.includes('--staged')
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
: searchRoot
? findEngineRoot(searchRoot)
: null);
const binariesDir = configuredRoot
? path.resolve(configuredRoot)
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
const FORBIDDEN_STDERR = [
'Failed to load Python shared library',
'Library not loaded',
'image not found',
'Connection refused',
];
let exitCode = 0;
function fail(msg) {
console.error(`[FAIL] ${msg}`);
exitCode = 1;
}
function ok(msg) {
console.log(`[OK] ${msg}`);
}
function rejectSymlinks(root, label) {
if (!fs.existsSync(root)) {
return;
}
let symlinkCount = 0;
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
const relative = path.relative(root, file).split(path.sep).join('/');
if (entry.isSymbolicLink()) {
fail(`Unsupported symlink in ${label}: ${relative}`);
symlinkCount += 1;
} else if (entry.isDirectory()) {
walk(file);
}
}
};
walk(root);
if (symlinkCount === 0) {
ok(`${label} contains no symlinks`);
}
}
function binName(engine) {
return `${engine}${suffix}`;
}
function binPath(engine) {
return path.join(binariesDir, binName(engine));
}
function engineEnv(engine) {
if (engine !== 'aria2c') {
return process.env;
}
const modulesDir = path.join(binariesDir, 'aria2-libs');
if (!fs.existsSync(modulesDir)) {
return process.env;
}
return {
...process.env,
OPENSSL_MODULES: modulesDir,
};
}
// ───── Check 1: Sidecar existence ─────
console.log(`\n─── 1. Sidecar existence (${targetTriple}) ───`);
for (const eng of requiredEngines) {
const p = binPath(eng);
if (fs.existsSync(p)) {
ok(`Found ${binName(eng)}`);
} else {
fail(`Missing ${binName(eng)}`);
}
}
if (exitCode !== 0) {
console.error('\nAborting: missing required sidecars.');
process.exit(1);
}
// ───── Check 2: Executable permission ─────
console.log('\n─── 2. Executable permission ───');
for (const eng of requiredEngines) {
if (isWindows) {
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
continue;
}
try {
fs.accessSync(binPath(eng), fs.constants.X_OK);
ok(`Executable ${binName(eng)}`);
} catch {
fail(`Not executable ${binName(eng)}`);
}
}
// ───── Check 3: file(1) identification ─────
console.log('\n─── 3. file(1) identification ───');
for (const eng of requiredEngines) {
if (os.platform() === 'win32') {
try {
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
if (header === 'MZ') {
ok(`${binName(eng)}: Windows PE executable`);
} else {
fail(`${binName(eng)}: missing Windows PE MZ header`);
}
} catch (e) {
fail(`identify ${binName(eng)}: ${e.message}`);
}
continue;
}
try {
const out = execFileSync('file', ['--brief', binPath(eng)], {
encoding: 'utf-8',
timeout: 5000,
}).trim();
ok(`${binName(eng)}: ${out}`);
} catch (e) {
fail(`file ${binName(eng)}: ${e.message}`);
}
}
// ───── Check 4 & 5: otool -L linkage (macOS only) ─────
if (isMacOS) {
console.log('\n─── 4 & 5. otool -L linkage check ───');
for (const eng of requiredEngines) {
const p = binPath(eng);
try {
const out = execFileSync('otool', ['-L', p], {
encoding: 'utf-8',
timeout: 10000,
});
const lines = out
.split('\n')
.slice(1)
.map((l) => l.trim())
.filter(Boolean);
let hasBad = false;
for (const fp of FORBIDDEN_OTOOL_PATHS) {
for (const line of lines) {
if (line.includes(fp)) {
fail(`${binName(eng)} links to '${fp}': ${line}`);
hasBad = true;
}
}
}
if (!hasBad) {
ok(`${binName(eng)}: no local-only dylib paths`);
}
} catch (e) {
fail(`otool -L ${binName(eng)}: ${e.message}`);
}
}
}
// ───── Check 6: yt-dlp packaging ─────
console.log('\n─── 6. yt-dlp packaging ───');
{
const yt = binPath('yt-dlp');
if (!fs.existsSync(yt)) {
fail('yt-dlp binary not found, cannot verify packaging');
} else {
const internalDir = path.join(binariesDir, '_internal');
const hasInternal = fs.existsSync(internalDir) && fs.statSync(internalDir).isDirectory();
if (hasInternal) {
console.log(' Detected PyInstaller onedir layout (_internal/ present)');
let entries;
try {
entries = fs.readdirSync(internalDir);
ok(`_internal/ directory exists with ${entries.length} entries`);
} catch (e) {
fail(`Cannot read _internal/: ${e.message}`);
}
rejectSymlinks(internalDir, '_internal/');
const requiredRuntimeFiles = [
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
];
for (const required of requiredRuntimeFiles) {
if (fs.existsSync(required)) {
ok(`yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
} else {
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
}
}
const runtimeCandidates = isWindows
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
: isLinux
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
if (runtimeCandidates.length > 0) {
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
} else {
fail(`Missing embedded Python runtime in ${internalDir}`);
}
} else {
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
}
}
}
const aria2LibsDir = path.join(binariesDir, 'aria2-libs');
if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) {
rejectSymlinks(aria2LibsDir, 'aria2-libs/');
}
// ───── Check 7, 8 & 9: Engine version self-tests ─────
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
function runEngine(label, engine, args, timeout = 30000) {
const p = binPath(engine);
if (!fs.existsSync(p)) {
fail(`${label} binary not found at ${p}`);
return;
}
let stderr = '';
try {
const stdout = execFileSync(p, args, {
encoding: 'utf-8',
env: engineEnv(engine),
timeout,
stdio: ['ignore', 'pipe', 'pipe'],
});
const firstLine = stdout.trim().split('\n')[0];
ok(`${label} version: ${firstLine}`);
} catch (e) {
stderr = e.stderr || '';
const stdout = e.stdout || '';
const detail = stdout.trim().split('\n')[0] || e.message;
fail(`${label} execution failed: ${detail}`);
}
if (stderr) {
for (const pattern of FORBIDDEN_STDERR) {
if (stderr.includes(pattern)) {
fail(`${label} stderr contains '${pattern}'`);
}
}
}
}
function waitForProcessExit(proc, timeoutMs) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve(true);
}
return new Promise(resolve => {
let settled = false;
let timer;
const finish = value => {
if (settled) return;
settled = true;
clearTimeout(timer);
proc.removeListener('exit', onExit);
resolve(value);
};
const onExit = () => finish(true);
timer = setTimeout(() => finish(false), timeoutMs);
proc.once('exit', onExit);
if (proc.exitCode !== null || proc.signalCode !== null) {
finish(true);
}
});
}
async function terminateProcess(proc, label) {
if (proc.exitCode === null && proc.signalCode === null) {
try {
proc.kill('SIGTERM');
} catch {}
if (await waitForProcessExit(proc, 2000)) {
return true;
}
try {
proc.kill('SIGKILL');
} catch {}
if (await waitForProcessExit(proc, 2000)) {
return true;
}
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
return false;
}
return true;
}
function findAvailablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Could not determine the verifier RPC port.'));
return;
}
const port = address.port;
server.close(error => {
if (error) {
reject(error);
} else {
resolve(port);
}
});
});
});
}
function isPortBindingFailure(message) {
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
}
const coldStartTimeout = isMacOS ? 120000 : 30000;
if (canExecuteTarget) {
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
if (isMacOS) {
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
// Warm checks enforce engine startup performance after that OS validation.
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
runEngine('deno warm start', 'deno', ['--version'], 8000);
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
}
} else {
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
}
// ───── aria2 RPC smoke test (native target only) ─────
if (canExecuteTarget) {
console.log('\n─── aria2 RPC smoke test ───');
await (async function testAria2Rpc() {
const p = binPath('aria2c');
if (!fs.existsSync(p)) {
fail('aria2c binary not found, cannot run RPC test');
return;
}
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'firelink-verify',
method: 'aria2.getVersion',
params: [],
});
let result = { ok: false, error: 'RPC test did not run.' };
let rpcStderr = '';
const maxPortAttempts = 3;
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
let port;
try {
port = await findAvailablePort();
} catch (error) {
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
break;
}
const proc = spawn(p, [
'--enable-rpc',
`--rpc-listen-port=${port}`,
'--rpc-max-request-size=1K',
'--quiet',
'--console-log-level=error',
'--rpc-listen-all=false',
], {
env: engineEnv('aria2c'),
stdio: ['ignore', 'ignore', 'pipe'],
timeout: 15000,
});
let spawnError = null;
let childExit = null;
let attemptStderr = '';
proc.once('error', error => {
spawnError = error;
});
proc.once('exit', (code, signal) => {
childExit = { code, signal };
});
proc.stderr.on('data', data => {
attemptStderr += data.toString();
});
const attemptResult = await new Promise(resolve => {
const maxAttempts = 20;
let attempts = 0;
function resolveProcessFailure() {
if (spawnError) {
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
} else if (childExit) {
resolve({
ok: false,
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
});
}
}
function tryFetch() {
if (spawnError || childExit) {
resolveProcessFailure();
return;
}
attempts += 1;
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
.then(async response => {
resolve({ ok: true, data: await response.text() });
})
.catch(() => {
if (spawnError || childExit) {
resolveProcessFailure();
} else if (attempts >= maxAttempts) {
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
} else {
setTimeout(tryFetch, 300);
}
});
}
tryFetch();
});
const exitedBeforeCleanup = childExit;
const terminated = proc.pid
? await terminateProcess(proc, 'aria2 RPC verifier process')
: true;
rpcStderr = attemptStderr;
result = attemptResult;
if (
result.ok
&& exitedBeforeCleanup
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
) {
result = {
ok: false,
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
};
}
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
break;
}
if (portAttempt + 1 < maxPortAttempts) {
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
}
}
if (result.ok) {
try {
const resp = JSON.parse(result.data);
if (resp?.result?.version) {
ok(`aria2 RPC version: ${resp.result.version}`);
} else {
fail(`aria2 RPC unexpected response: ${result.data}`);
}
} catch (e) {
fail(`aria2 RPC parse error: ${e.message}`);
}
} else {
fail(result.error);
}
if (rpcStderr) {
for (const pattern of FORBIDDEN_STDERR) {
if (rpcStderr.includes(pattern)) {
fail(`aria2 RPC stderr contains '${pattern}'`);
}
}
}
})();
}
// ───── Result ─────
console.log('');
if (exitCode !== 0) {
console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`);
process.exit(1);
} else {
console.log('[PASS] All engine verification checks passed.');
process.exit(0);
}
-272
View File
@@ -1,272 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(__dirname, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function fail(message) {
console.error(`[FAIL] ${message}`);
process.exit(1);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
});
if (result.error) {
fail(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
if (options.stdio === 'pipe') {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
}
fail(`${command} exited with status ${result.status}`);
}
return result;
}
function findSingle(directory, extension, label) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
fail(`${label} directory does not exist: ${directory}`);
}
const matches = fs.readdirSync(directory)
.filter(name => name.endsWith(extension))
.map(name => path.join(directory, name));
if (matches.length !== 1) {
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
}
return matches[0];
}
function assertPackageListing(packageFile, packageType, expectedPath) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
const listing = result.stdout ?? '';
assertSafePackageListing(listing, packageType);
if (!listing.includes(expectedPath)) {
fail(`${packageType} package is missing ${expectedPath}`);
}
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
fail(`${packageType} package is missing its desktop entry`);
}
}
function assertPackageRecommendations(packageFile, packageType) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
const recommendations = result.stdout ?? '';
const dependencyNames = packageType === 'deb'
? recommendations
.split(/[,|]/)
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
: recommendations
.split('\n')
.map(value => value.trim().split(/\s+/, 1)[0]);
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
if (!dependencyNames.includes(dependency)) {
fail(`${packageType} package is missing its ${dependency} recommendation`);
}
}
}
export function parseDebianPackagePath(line) {
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
if (!match) {
throw new Error(`Could not parse a Debian package path: ${line}`);
}
return match[1].replace(/^\.\//, '');
}
export function isSafePackagePath(packagePath) {
if (packagePath === '') {
return true;
}
const parts = packagePath.split('/');
return !parts.includes('..') && (parts[0] === 'usr' || packagePath === 'usr');
}
function assertSafePackageListing(listing, packageType) {
const lines = listing.split('\n').filter(Boolean);
const paths = packageType === 'deb'
? lines.map(line => {
try {
return parseDebianPackagePath(line);
} catch (error) {
fail(error.message);
}
})
: lines.map(line => line.replace(/^\/+/, ''));
for (const packagePath of paths) {
if (!isSafePackagePath(packagePath)) {
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
}
}
}
function extractDeb(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('dpkg-deb', ['--extract', packageFile, destination]);
}
function extractRpm(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('bsdtar', [
'--extract',
'--file', packageFile,
'--directory', destination,
'--no-same-owner',
'--no-same-permissions',
]);
}
function readPayloadManifest(root, label) {
const manifestPath = path.join(root, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
fail(`${label} payload manifest is missing`);
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(`${label} payload manifest is invalid: ${error.message}`);
}
return manifest;
}
function findPayloadRoot(root, target, label) {
const expectedBinary = `yt-dlp-${target}`;
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
if (!entry.isDirectory()) continue;
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
matches.push(candidate);
}
walk(candidate);
}
};
walk(root);
if (matches.length !== 1) {
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
}
return matches[0];
}
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
if (sourceManifest.target !== target) {
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
}
const packagedManifest = readPayloadManifest(packagedRoot, label);
if (packagedManifest.target !== target) {
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
}
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
.sort();
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
fail(`${label} payload files differ from the provisioned engine manifest`);
}
for (const relative of expectedFiles) {
const packagedFile = path.join(packagedRoot, relative);
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
fail(`${label} payload checksum mismatch: ${relative}`);
}
}
}
function findExecutable(root) {
const candidates = [
path.join(root, 'usr', 'bin', 'firelink'),
path.join(root, 'usr', 'bin', 'Firelink'),
];
const executable = candidates.find(candidate => {
if (!fs.existsSync(candidate)) return false;
const stat = fs.lstatSync(candidate);
return stat.isFile() && !stat.isSymbolicLink();
});
if (!executable) {
fail(`Packaged Firelink executable was not found under ${root}`);
}
return executable;
}
function verifyExtractedPackage(packageType, packageFile, target, root) {
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const packagedRoot = findPayloadRoot(root, target, packageType);
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
run(process.execPath, [
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
'--search-root',
root,
'--target',
target,
]);
const executable = findExecutable(root);
run('xvfb-run', [
'-a',
process.execPath,
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
'--executable',
executable,
], { env: { APPDIR: root } });
}
function main() {
const target = argValue('--target');
if (!target) fail('Pass --target <Rust target triple>.');
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
const debRoot = path.join(extractionRoot, 'deb');
const rpmRoot = path.join(extractionRoot, 'rpm');
try {
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(deb, 'deb');
extractDeb(deb, debRoot);
verifyExtractedPackage('deb', deb, target, debRoot);
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(rpm, 'rpm');
extractRpm(rpm, rpmRoot);
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
console.log('Linux .deb and .rpm payload and launch verification passed.');
} finally {
fs.rmSync(extractionRoot, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
main();
}
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
test('parses current dpkg-deb listings without a ./ prefix', () => {
assert.equal(
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
'usr/share/'
);
});
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
assert.equal(
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
'usr/bin/firelink'
);
});
test('accepts the package root in legacy dpkg-deb listings', () => {
assert.equal(
parseDebianPackagePath('drwxr-xr-x root/root 0 2026-07-12 07:24 ./'),
''
);
assert.equal(isSafePackagePath(''), true);
});
test('rejects paths outside the package usr tree', () => {
assert.equal(isSafePackagePath('../tmp/firelink'), false);
assert.equal(isSafePackagePath('etc/firelink'), false);
});
test('rejects malformed dpkg-deb listing lines', () => {
assert.throws(
() => parseDebianPackagePath('not a dpkg-deb listing'),
/Could not parse a Debian package path/
);
});
-306
View File
@@ -1,306 +0,0 @@
#!/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.');
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readPackageVersion(root) {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
}
function readTauriVersion(root) {
return JSON.parse(
fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')
).version;
}
function readCargoVersion(root) {
const cargo = fs.readFileSync(path.join(root, 'src-tauri', 'Cargo.toml'), 'utf8');
const packageSection = cargo.match(/^\[package\]\s*([\s\S]*?)(?=^\[)/m)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) {
throw new Error('Could not read the [package] version from src-tauri/Cargo.toml.');
}
return version;
}
function versionFromRef(ref) {
if (!ref || !ref.startsWith('v')) {
throw new Error(`Expected a semantic version tag such as v1.2.3, received ${ref || 'nothing'}.`);
}
const version = ref.slice(1);
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Release tag ${ref} does not contain a valid semantic version.`);
}
return version;
}
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
const expected = versionFromRef(tag);
const versions = {
'package.json': readPackageVersion(repositoryRoot),
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
};
const mismatches = Object.entries(versions)
.filter(([, version]) => version !== expected)
.map(([file, version]) => `${file}=${version}`);
if (mismatches.length > 0) {
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
for (const mismatch of mismatches) console.error(` ${mismatch}`);
process.exit(1);
}
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1) {
console.error('Application version manifests do not agree:');
for (const [file, version] of Object.entries(versions)) console.error(` ${file}=${version}`);
process.exit(1);
}
console.log(`Release version ${expected} matches ${Object.keys(versions).length} manifests.`);
@@ -1,37 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const verifier = path.join(repositoryRoot, 'scripts', 'verify-release-version.js');
const currentVersion = JSON.parse(
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
).version;
function runVerifier(tag) {
return spawnSync(process.execPath, [verifier, '--tag', tag], {
cwd: repositoryRoot,
encoding: 'utf8',
});
}
test('release version verifier accepts the aligned current version', () => {
const result = runVerifier(`v${currentVersion}`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`Release version ${currentVersion} matches`));
});
test('release version verifier rejects the already-published prior tag', () => {
const result = runVerifier('v1.1.1');
assert.equal(result.status, 1);
assert.match(result.stderr, /does not match the application manifests/);
});
test('release version verifier rejects non-semver tag names', () => {
const result = runVerifier('release-candidate');
assert.equal(result.status, 1);
assert.match(result.stderr, /semantic version tag/);
});
-26
View File
@@ -1,26 +0,0 @@
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`
);
}
}
});
-7
View File
@@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
-7070
View File
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
[package]
name = "firelink"
version = "1.2.0"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
license = "MIT"
repository = "https://github.com/nimbold/Firelink"
homepage = "https://github.com/nimbold/Firelink"
keywords = ["download-manager", "tauri", "aria2", "yt-dlp", "ffmpeg"]
categories = ["network-programming"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "firelink_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3"
tauri-plugin-clipboard-manager = "2.3.2"
sysinfo = "0.39.3"
hmac = "0.13"
sha2 = "0.11"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.19"
axum = "0.8.9"
tower-http = { version = "0.7", features = ["cors", "limit"] }
sysproxy = "0.3.0"
semver = "1.0.28"
keepawake = "0.6.0"
system_shutdown = "4.1.0"
tokio-tungstenite = "0.30.0"
futures-util = { version = "0.3.33", features = ["sink"] }
chrono = "0.4.38"
url = "2"
rusqlite = { version = "0.40.1", features = ["bundled"] }
log = "0.4.32"
tauri-plugin-log = "2.9.0"
trash = "5"
async-trait = "0.1"
keyring-core = "1.0.0"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
objc = "0.2.7"
[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }

Some files were not shown because too many files have changed in this diff Show More