Compare commits

...

57 Commits

Author SHA1 Message Date
nimbold db2b1f6516 chore(release): bump version to 0.6.3 and update changelog 2026-06-09 06:58:22 +03:30
nimbold 336a50ed6c fix(integration): resolve CORS preflight bug and secure pairing token storage
- Allow GET method in LocalExtensionServer CORS preflight response
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore
- Upgrade token generation to use SecRandomCopyBytes
- Update IntegrationSettingsPane UI to be browser-agnostic with a Regenerate token action
2026-06-09 06:48:06 +03:30
nimbold f887c62195 feat: enforce dynamic browser extension pairing security
- Generate random pairing token in AppSettings

- Update LocalExtensionServer to strict check token on non-OPTIONS endpoints

- Add GET /ping endpoint for extension connection verification

- Redesign Integration settings pane with modern step-by-step UI and Toast notifications

- Harden media extraction and aria2 engine paths

- Update IP resolution with getaddrinfo
2026-06-09 06:30:39 +03:30
nimbold 109059e10c fix: resolve pipe race condition and optimize yt-dlp arguments
- Used DispatchGroup to fix stdout/stderr truncation race condition
- Added --force-ipv4 to prevent metadata fetching hang
- Removed hardcoded youtube:player_client to fix throttling issues
- Combined --js-runtimes flags as a comma-separated list
2026-06-08 21:42:14 +03:30
nimbold 81b3e0877b docs: add missing credits for yt-dlp, ffmpeg, and sparkle in README and About page 2026-06-08 21:24:56 +03:30
nimbold 6b2901bd50 feat(updater): upgrade sparkle to 2.9.3 and enhance integration
- Update Sparkle dependency to 2.9.3

- Replace brittle HTML parsing with native NSAttributedString parsing

- Fix dangling updater callbacks by handling view disappear events

- Add 'Remind Me Later' button in update prompt

- Add toggle for automatic background update checks
2026-06-08 21:18:36 +03:30
nimbold 9261385d59 fix(ui): clear add downloads view state on disappear to prevent old link auto-pasting 2026-06-08 21:18:36 +03:30
github-actions[bot] ef0ad42df3 chore(release): update appcast for 0.6.2 2026-06-08 13:15:06 +00:00
nimbold b71a58ae06 fix: resolve duplicate resolution window not closing
- Dispatch executeAddDownloads asynchronously after closing the sheet to avoid swallowing dismiss()
2026-06-08 16:42:11 +03:30
github-actions[bot] 6dcb0e33e8 chore(release): update appcast for 0.6.2 2026-06-08 12:54:49 +00:00
nimbold 34847b6234 fix: resolve duplicate resolution shortcuts, update freeze, and process id flashing
- Fix keyboard shortcut collision causing main window to intercept Enter/Escape keys when duplicate resolution sheet is open
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously
- Improve Sparkle changelog formatting by converting HTML tags to clean Markdown
- Change internal Process ID status message to Starting... when queueing downloads
- Fix EXC_BREAKPOINT crash on app launch by prioritizing Bundle.main over Bundle.module
- Update CHANGELOG.md for 0.6.2 release
2026-06-08 16:22:05 +03:30
github-actions[bot] ac7963e353 chore(release): update appcast for 0.6.1 2026-06-08 12:05:49 +00:00
nimbold a4936fa141 ci: automate Sparkle appcast updates 2026-06-08 15:33:43 +03:30
nimbold 9af9edbfd4 chore(release): prepare 0.6.1 2026-06-08 15:08:16 +03:30
nimbold 9f8e01839f fix: package bundled media engines 2026-06-08 14:57:07 +03:30
nimbold c1d97f31fb ci: automate fetching yt-dlp and ffmpeg binaries during release build 2026-06-08 14:25:18 +03:30
nimbold 02abef1443 feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle
- Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources.
- Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift.
- Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners.
- Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window.
- Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling.
2026-06-08 14:16:38 +03:30
nimbold b58df6b660 chore(release): prepare 0.6.0 release 2026-06-08 07:17:17 +03:30
nimbold 5ed9b42c62 fix: cap max height of download links text editor 2026-06-08 06:54:25 +03:30
nimbold 72ef99d211 refactor: add backward compatibility support for extension tokens 2026-06-08 05:46:24 +03:30
nimbold baddf0da6d feat: enhance mixed media support and add duplicate resolution 2026-06-08 05:37:27 +03:30
nimbold 34ca209e09 chore: update Firelink-Extension submodule to latest 2026-06-08 05:13:08 +03:30
nimbold 410059e3de feat(ui): redesign settings panes and enhance update flows
- Implemented `InlineUpdateUserDriver` to handle inline update checks inside Settings panes.
- Redesigned `AboutSettingsPane` with a modern inline update UI, release notes DisclosureGroup, and clear visual feedback states.
- Rebuilt `EngineSettingsPane` using native macOS layouts, removing custom stacks for better native alignment.
- Relocated browser cookie selection and media engine updates in `EngineSettingsPane` for better contextual grouping.
2026-06-08 02:18:56 +03:30
nimbold 4e48f1e42c fix: harden media download flow 2026-06-08 01:06:00 +03:30
nimbold 2e5b4ae7c3 fix: pass extractor arguments to yt-dlp download process
- Fix: Append --extractor-args youtube:player_client=ios,tv to the MediaDownloadEngine to prevent yt-dlp from falling back to web client and failing with bot detection during download.
2026-06-08 00:38:32 +03:30
nimbold 2d0c05e4e4 fix: restore single click selection by removing simultaneousGesture 2026-06-08 00:24:12 +03:30
nimbold 8d5eb74fba fix: restore Download Properties routing and gestures
- Fix: Revert WindowGroup to expect UUID and update ALL openWindow calls to explicitly use id: 'download-properties' to prevent routing poisoning
- Fix: Wrap the File Name cell in doubleClickableCell using .simultaneousGesture so double clicks trigger the action without eating single click row selection
2026-06-08 00:17:32 +03:30
nimbold 01ba9c4c9b fix: pass UUID as String for download properties WindowGroup to prevent routing failures 2026-06-08 00:11:23 +03:30
nimbold bd6aa867c7 fix: size column fallback and table row interactions
- Fix: Size column falls back to 'bytesText' if sizeBytes is unknown
- Fix: Prevent filename text from intercepting row selection taps
- Fix: Use explicit window ID to ensure download properties opens reliably on double click
2026-06-07 23:59:03 +03:30
nimbold fbae92aae9 fix: media download UX and table row selection
- Fix: Remove aria2c from yt-dlp to allow native concurrent downloader to print progress
- Fix: Use generic 'Fetching media data...' message instead of hardcoding YouTube
- Fix: Remove onTapGesture from Table cells to restore native macOS SwiftUI row selection and use simultaneousGesture on the Table itself for double clicks
2026-06-07 23:39:18 +03:30
nimbold 49003665ef feat: improve yt-dlp fetching speed and redesign media detection UI
- Bypass YouTube web bot detection by using iOS/Android player clients in yt-dlp
- Redesign media extraction UX to use a clean inline layout instead of replacing the entire Add window
- Use dropdown Pickers for Type, Quality, and Format selection
- Remove the obsolete MediaInspectorCard
2026-06-07 22:53:38 +03:30
nimbold 1a2c59d243 fix: media downloads connections, progress parsing, file size, and selection highlight
- Removed hardcoded 1 connection limit for media downloads
- Added support for aria2c progress parsing in media downloads
- Fixed missing file size after download completion by reading from disk
- Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture
2026-06-07 22:28:23 +03:30
nimbold c139ac50f2 fix(media): stabilize yt-dlp metadata and add-on updates 2026-06-07 12:48:00 +03:30
nimbold a900d97a5c feat: enhance media engine settings with cookie extraction and update checks
- Added `mediaCookieSource` to `AppSettings` to let users choose the browser for cookie extraction.
- Refactored `EngineSettingsPane` to display `yt-dlp` and `ffmpeg` statuses.
- Added a manual 'Check for Updates' button with UI loading feedback.
- Passed `--cookies-from-browser` to both metadata extraction and download engines if configured.
- Added `--ignore-no-formats-error` to `yt-dlp` metadata extraction to prevent crashes on restricted videos.
2026-06-07 12:15:50 +03:30
nimbold 8831318839 feat: modernize Integration settings UI and add official install button 2026-06-07 10:43:10 +03:30
nimbold 94180ddff9 docs: add Install on Firefox badge to README 2026-06-07 10:36:09 +03:30
nimbold e91414c478 docs: update README with new media features, credits, and live browser integration 2026-06-07 10:32:23 +03:30
nimbold eaea5bd908 feat: integrate yt-dlp to DownloadController and add global queue support 2026-06-07 10:23:55 +03:30
nimbold af05180ee6 feat: implement smart progressive disclosure UI and media extraction engine 2026-06-07 10:05:54 +03:30
nimbold 0799b08000 feat: implement gatekeeper architecture for on-demand media engine binaries 2026-06-07 09:57:27 +03:30
nimbold bf645898a9 chore(assets): update app icons and icon generation scripts 2026-06-07 09:08:52 +03:30
nimbold 6552bde261 fix(security): block automatic metadata fetch for private IP addresses 2026-06-07 09:08:11 +03:30
nimbold 125a8b9e6d fix: actually update extension icons with the 1.9x gradient icon (submodule bump) 2026-06-07 08:37:45 +03:30
nimbold 2589d878ea style: tone down icon gradient to 1.9x for modern subtle look 2026-06-07 08:11:26 +03:30
nimbold 2f7a1f52e7 style: increase gradient contrast for stronger lighting effect 2026-06-07 08:06:43 +03:30
nimbold 30d4d741af style: switch to lighter gradient (+1 to 0) 2026-06-07 08:01:04 +03:30
nimbold f98e6b8246 style: revert to plain mode without gradient 2026-06-06 16:13:38 +03:30
nimbold 387ada1afc fix: correctly remove black padding and mask corners 2026-06-06 16:09:27 +03:30
nimbold 37be7b14f0 style: apply premium gradient to the correct new icon 2026-06-06 16:04:26 +03:30
nimbold 1e064502db style: apply premium gradient to app icon 2026-06-06 16:00:38 +03:30
nimbold 09cea67041 fix(updates): harden Sparkle release metadata 2026-06-06 15:54:44 +03:30
nimbold 6a20250d22 style: remove redundant version string from up-to-date message 2026-06-06 15:31:05 +03:30
nimbold 99b8f66bcf fix: correct Sparkle SUNoUpdateError code to prevent false error messages 2026-06-06 15:28:53 +03:30
nimbold 890b6935b9 feat: inline Sparkle update checks to avoid unnecessary modals 2026-06-06 15:22:58 +03:30
nimbold b1802c718a chore: update appcast.xml with valid signature for new framework-embedded dmg 2026-06-06 15:12:48 +03:30
nimbold d0021e16f5 fix: embed Sparkle dynamic frameworks into app bundle to resolve crash 2026-06-06 15:10:37 +03:30
nimbold a42c7d9f74 chore: update appcast.xml for v0.5.7 Sparkle release 2026-06-06 15:07:29 +03:30
44 changed files with 3015 additions and 554 deletions
+98 -6
View File
@@ -31,11 +31,14 @@ jobs:
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
TAG_NAME="${GITHUB_REF_NAME}"
else
VERSION="${{ inputs.version }}"
TAG_NAME="v${VERSION}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Show build environment
@@ -60,6 +63,22 @@ jobs:
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Fetch media engines
run: |
mkdir -p Sources/Firelink
# Download latest yt-dlp for macOS
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
chmod +x Sources/Firelink/yt-dlp
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
unzip -q -o ffmpeg.zip -d Sources/Firelink
rm ffmpeg.zip
chmod +x Sources/Firelink/ffmpeg
test -x Sources/Firelink/yt-dlp
test -x Sources/Firelink/ffmpeg
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
@@ -86,15 +105,88 @@ jobs:
if-no-files-found: error
- name: Publish GitHub release
if: github.ref_type == 'tag'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
test -s release_notes.md
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
gh release upload "$TAG_NAME" dist/*.dmg --clobber
gh release edit "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
fi
- name: Update Sparkle appcast
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
if [[ -z "${SPARKLE_ED_PRIVATE_KEY}" ]]; then
echo "Missing SPARKLE_ED_PRIVATE_KEY repository secret; cannot update appcast.xml" >&2
exit 1
fi
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
BUILD_NUMBER="${{ github.run_number }}"
DMG_PATH="dist/Firelink-${VERSION}-mac-arm64.dmg"
DMG_URL="https://github.com/nimbold/Firelink/releases/download/${TAG_NAME}/Firelink-${VERSION}-mac-arm64.dmg"
RELEASE_NOTES_URL="https://github.com/nimbold/Firelink/releases/tag/${TAG_NAME}"
PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
SIGN_OUTPUT="$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | .build/artifacts/sparkle/Sparkle/bin/sign_update --ed-key-file - "$DMG_PATH")"
ED_SIGNATURE="$(printf '%s\n' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' | head -1)"
LENGTH="$(stat -f%z "$DMG_PATH" 2>/dev/null || stat -c%s "$DMG_PATH")"
if [[ -z "$ED_SIGNATURE" || -z "$LENGTH" ]]; then
echo "Failed to extract Sparkle signature or DMG length" >&2
printf '%s\n' "$SIGN_OUTPUT" >&2
exit 1
fi
git fetch origin main
git checkout main
git pull --ff-only origin main
cat > appcast.xml <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Firelink Updates</title>
<link>https://github.com/nimbold/Firelink</link>
<description>Most recent updates for Firelink</description>
<language>en</language>
<item>
<title>Version ${VERSION}</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>${RELEASE_NOTES_URL}</sparkle:releaseNotesLink>
<pubDate>${PUB_DATE}</pubDate>
<enclosure url="${DMG_URL}"
sparkle:version="${BUILD_NUMBER}"
sparkle:shortVersionString="${VERSION}"
length="${LENGTH}"
type="application/octet-stream"
sparkle:edSignature="${ED_SIGNATURE}" />
</item>
</channel>
</rss>
XML
xmllint --noout appcast.xml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add appcast.xml
if git diff --cached --quiet; then
echo "appcast.xml already up to date"
else
git commit -m "chore(release): update appcast for ${VERSION}"
git push origin main
fi
+18
View File
@@ -2,7 +2,25 @@
.build/
build/
dist/
*.dmg
DerivedData/
.vscode/
*.xcuserdata/
*.xcuserstate
# Local secrets and exported signing keys
.env
.env.*
*.key
*.pem
*.p8
*.p12
*.cer
*.der
*.mobileprovision
sparkle_private_key*
SparklePrivateKey*
private-key*
private_key*
yt-dlp
ffmpeg
+84
View File
@@ -5,6 +5,90 @@ 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).
## [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 Sparkle changelog formatting by converting HTML tags to clean Markdown instead of stripping them 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 Sparkle 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 appcast.xml with valid signature for new 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 Sparkle release metadata.
- Correct Sparkle SUNoUpdateError code to prevent false error messages.
## [0.5.7] - 2026-06-06
### New features
+3 -3
View File
@@ -1,13 +1,13 @@
{
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
"originHash" : "c1cb50a392a5949f6fb77fb4800ef2ea6c811268af19d41dd83b3be29f0321a8",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
}
],
+4 -2
View File
@@ -11,7 +11,7 @@ let package = Package(
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3")
],
targets: [
.executableTarget(
@@ -21,7 +21,9 @@ let package = Package(
],
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
.process("Assets.xcassets"),
.copy("yt-dlp"),
.copy("ffmpeg")
]
)
]
+37 -43
View File
@@ -1,58 +1,65 @@
<div align="center">
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
<p><strong>The modern, blazing-fast download manager built natively for Apple Silicon macOS.</strong></p>
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="https://github.com/yt-dlp/yt-dlp"><img src="https://img.shields.io/badge/Engine-yt--dlp-red?logo=youtube&logoColor=white" alt="yt-dlp Engine" /></a>
<a href="https://ffmpeg.org/"><img src="https://img.shields.io/badge/Engine-ffmpeg-red?logo=ffmpeg&logoColor=white" alt="ffmpeg Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
</div>
---
**Firelink** brings the efficiency of multi-segmented download managers (like IDM or FDM) to macOS with a modern, native SwiftUI interface. Designed specifically for Apple Silicon, it delivers high-speed concurrent transfers, drag-and-drop queue control, automated file organization, and Keychain-secured authentication—all in a lightweight native package.
**Firelink** reimagines macOS file downloading by wrapping legendary open-source engines (`aria2c`, `yt-dlp`, and `ffmpeg`) in a beautifully crafted, highly polished SwiftUI interface. Enjoy high-speed segmented downloads, native media extraction, seamless browser integration, and intelligent file organization without compromising on the aesthetics of your Mac.
---
### 📸 Screenshots
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
## 📸 Screenshots
<div align="center">
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Settings" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup</sub>
<sub>A premium native experience, from batch linking to advanced settings.</sub>
</div>
<details>
<summary><b>☀️ View Light Theme Screenshots</b></summary>
<summary><b>Light Theme Screenshots</b></summary>
<br/>
<div align="center">
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window light theme" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Settings light theme" width="32%" />
</div>
</details>
---
## ✨ Features
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
- 🧾 **Download Properties:** Inspect progress and tune per-download transfer settings.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱 **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔄 **Sparkle Updates:** Secure, automatic in-app updates using EdDSA cryptographic signatures.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
- 🗂 **Smart Organization:** Auto-categorizes files into `Musics`, `Movies`, `Compressed`, and more.
- 🛡️ **Reliable & Secure:** Deep Keychain integration for authenticated downloads, zero-configuration setup, and automatic recovery.
---
## 🧩 Browser Integration
We are live! Send downloads directly from your browser to the Firelink app with zero friction.
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20on%20Firefox-FF7139?logo=firefox-browser&logoColor=white&style=for-the-badge" alt="Install on Firefox" /></a>
*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
---
@@ -72,26 +79,13 @@ make app && open build/Firelink.app
---
## 🧩 Browser Extension
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
---
## 🗺️ Roadmap
- [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.
Firelink stands on the shoulders of giants. A massive thank you to the contributors of these phenomenal open-source projects:
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
- **[Sparkle](https://sparkle-project.org/)** - A secure and reliable software update framework for macOS.
---
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

After

Width:  |  Height:  |  Size: 896 KiB

+40 -4
View File
@@ -4,8 +4,10 @@ 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}"
DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
@@ -22,6 +24,16 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
for media_engine in yt-dlp ffmpeg; do
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
if [[ -x "$media_engine_path" ]]; then
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
chmod +x "$RESOURCES_DIR/$media_engine"
else
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
fi
done
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
@@ -35,18 +47,31 @@ ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
if [ -d ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" ]; then
cp -R ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
fi
if [ -d ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" ]; then
cp -R ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" "$FRAMEWORKS_DIR/SparkleCore.framework"
fi
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -84,6 +109,17 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>local.firelink.swiftui</string>
<key>CFBundleURLSchemes</key>
<array>
<string>firelink</string>
</array>
</dict>
</array>
</dict>
</plist>
PLIST
+1
View File
@@ -5,4 +5,5 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
swift build
git diff --check
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+205 -43
View File
@@ -26,13 +26,20 @@ struct AddDownloadsView: View {
@State private var authPassword = ""
@State private var saveLogin = false
@State private var conflictingDownloads: [DuplicateDownloadItem] = []
@State private var showingDuplicates = false
@State private var pendingStartFlag = false
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
@@ -43,7 +50,21 @@ struct AddDownloadsView: View {
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
onConfirm: {
showingDuplicates = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
}
},
onCancel: {
showingDuplicates = false
}
)
}
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
@@ -63,6 +84,16 @@ struct AddDownloadsView: View {
}
.onDisappear {
metadataTask?.cancel()
linkText = ""
pendingDownloads = []
headerText = ""
cookieText = ""
mirrorText = ""
useAuthorization = false
authUsername = ""
authPassword = ""
checksumEnabled = false
checksumValue = ""
}
}
@@ -76,14 +107,14 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 72)
.frame(height: 72)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
.foregroundStyle(.secondary)
Spacer()
Button {
refreshMetadata(for: linkText)
refreshMetadata(for: linkText, isAutoFetch: false)
} label: {
Label("Refresh Metadata", systemImage: "arrow.clockwise")
}
@@ -169,7 +200,7 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
@@ -204,8 +235,8 @@ struct AddDownloadsView: View {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.subheadline.weight(.semibold))
Table(pendingDownloads) {
TableColumn("File") { item in
Table($pendingDownloads) {
TableColumn("File") { $item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
@@ -216,23 +247,55 @@ struct AddDownloadsView: View {
}
}
TableColumn("Size") { item in
TableColumn("Size") { $item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
TableColumn("Save To") { $item in
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
TableColumn("Status") { $item in
if item.isMedia {
if !item.mediaOptions.isEmpty {
Menu {
ForEach(item.mediaOptions) { option in
Button {
item.selectedMediaOption = option
if let metadata = item.mediaMetadata {
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
item.fileName = "\(cleanTitle).\(option.outputExtension)"
item.category = FileClassifier.category(forFileName: item.fileName)
}
} label: {
Text(option.name)
}
}
} label: {
Text(item.selectedMediaOption?.name ?? "Select Format")
.lineLimit(1)
}
.menuStyle(.borderlessButton)
.buttonStyle(.plain)
.fixedSize()
} else if case .loading = item.state {
HStack {
ProgressView().controlSize(.small)
Text("Checking")
}.foregroundStyle(.secondary)
} else {
MetadataStatusView(state: item.state)
}
} else {
MetadataStatusView(state: item.state)
}
}
.width(110)
.width(min: 110, ideal: 140, max: 200)
}
.frame(minHeight: 160)
}
@@ -244,7 +307,7 @@ struct AddDownloadsView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
@@ -261,7 +324,7 @@ struct AddDownloadsView: View {
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
@@ -273,7 +336,7 @@ struct AddDownloadsView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
}
}
@@ -422,7 +485,7 @@ struct AddDownloadsView: View {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await MainActor.run {
refreshMetadata(for: text)
refreshMetadata(for: text, isAutoFetch: true)
}
}
}
@@ -445,11 +508,12 @@ struct AddDownloadsView: View {
controller.pendingReferer = nil
}
private func refreshMetadata(for text: String) {
private func refreshMetadata(for text: String, isAutoFetch: Bool) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
pendingDownloads = urls.map { url in
let isMedia = MediaDetector.isSupportedMedia(url: url)
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
@@ -457,7 +521,8 @@ struct AddDownloadsView: View {
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
state: .loading,
isMedia: isMedia
)
}
@@ -468,19 +533,53 @@ struct AddDownloadsView: View {
saveLogin = false
}
guard !urls.isEmpty else {
metadataTask = nil
return
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(
for: url,
settings: settings,
credentials: metadataCredentials(for: url),
transferOptions: transferOptions
)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
await withTaskGroup(of: PendingDownload.self) { group in
for item in pendingDownloads {
group.addTask {
if item.isMedia {
var fetchedItem = item
do {
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
for: item.url,
cookieSource: settings.mediaCookieSource,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions
)
fetchedItem.mediaMetadata = metadata
fetchedItem.mediaOptions = options
if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
fetchedItem.selectedMediaOption = bestVideo
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
}
fetchedItem.state = .loaded
} catch {
fetchedItem.state = .failed(error.localizedDescription)
}
return fetchedItem
} else {
return await DownloadMetadataFetcher.fetch(
for: item.url,
settings: settings,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions,
isAutoFetch: isAutoFetch
)
}
}
}
for await loadedItem in group {
guard !Task.isCancelled else { break }
await MainActor.run {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
@@ -511,25 +610,69 @@ struct AddDownloadsView: View {
}
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)
}
var conflicts: [DuplicateDownloadItem] = []
for pending in pendingDownloads {
let destURL = overrideDirectory ?? pending.defaultDirectory
let destPath = destURL.appendingPathComponent(pending.fileName).path
if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
} else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
}
}
if !conflicts.isEmpty {
conflictingDownloads = conflicts
pendingStartFlag = start
showingDuplicates = true
return
}
executeAddDownloads(start: start)
}
private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
var finalDownloads = pendingDownloads
if let conflicts {
for conflict in conflicts {
guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
switch conflict.resolution {
case .skip:
finalDownloads.remove(at: index)
case .rename:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
var newName = finalDownloads[index].fileName
var count = 1
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
let ext = URL(fileURLWithPath: newName).pathExtension
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) {
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
count += 1
}
finalDownloads[index].fileName = newName
case .replace:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
} else if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
}
}
}
guard !finalDownloads.isEmpty else {
dismiss()
return
}
let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
controller.addPendingDownloads(
pendingDownloads,
finalDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
@@ -541,6 +684,25 @@ struct AddDownloadsView: View {
dismiss()
}
private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? {
guard useAuthorization else { return nil }
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanUsername.isEmpty else { return nil }
if saveLogin {
var savedHosts = Set<String>()
for url in urls {
if let host = url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
return DownloadCredentials(username: cleanUsername, password: authPassword)
}
private var overrideDirectory: URL? {
guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
+72 -4
View File
@@ -20,6 +20,44 @@ enum ProxyMode: String, Codable, CaseIterable, Sendable {
}
}
enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
case none = "None"
case safari = "Safari"
case chrome = "Chrome"
case firefox = "Firefox"
case edge = "Edge"
case brave = "Brave"
var ytDlpBrowserName: String? {
switch self {
case .none:
nil
case .safari, .chrome, .firefox, .edge, .brave:
rawValue.lowercased()
}
}
var statusTitle: String {
switch self {
case .none:
"Not using browser cookies"
case .safari, .chrome, .firefox, .edge, .brave:
"Using \(rawValue) cookies"
}
}
var statusDetail: String {
switch self {
case .none:
"Restricted media may fail if the site requires login."
case .safari:
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
case .chrome, .firefox, .edge, .brave:
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
@@ -78,11 +116,11 @@ 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() }
}
@@ -141,6 +179,16 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var mediaCookieSource: BrowserCookieSource {
didSet { save() }
}
@Published var extensionPairingToken: String {
didSet {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
}
@Published var message = ""
private let defaults: UserDefaults
@@ -161,6 +209,7 @@ final class AppSettings: ObservableObject {
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
@@ -172,9 +221,17 @@ final class AppSettings: ObservableObject {
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
}
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
downloadDirectories[category] = Self.defaultDirectory(for: category).path
}
@@ -287,7 +344,8 @@ final class AppSettings: ObservableObject {
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -297,7 +355,7 @@ final class AppSettings: ObservableObject {
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)
}
@@ -329,6 +387,15 @@ final class AppSettings: ObservableObject {
return host == normalizedPattern
}
private static func generateSecureToken() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
return UUID().uuidString
}
return Data(bytes).base64EncodedString()
}
private static func defaultDirectories() -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
}
@@ -358,4 +425,5 @@ private struct StoredSettings: Codable {
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
}
+9 -16
View File
@@ -56,21 +56,14 @@ final class Aria2DownloadEngine {
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
"/usr/bin/aria2c",
"/opt/local/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
}
@@ -91,10 +84,10 @@ final class Aria2DownloadEngine {
// 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) ?? ""
@@ -230,7 +223,7 @@ final class Aria2DownloadEngine {
rpcPort: Int,
rpcSecret: String,
process: Process,
completionGate: CompletionGate
completionGate: CompletionGate<Void>
) -> Task<Void, Never> {
Task.detached {
while !Task.isCancelled && process.isRunning {
@@ -527,16 +520,16 @@ final class LockedDataBuffer: @unchecked Sendable {
}
}
final class CompletionGate: @unchecked Sendable {
final class CompletionGate<Success>: @unchecked Sendable {
private let lock = NSLock()
private var didComplete = false
private let completion: @Sendable (Result<Void, Error>) -> Void
private let completion: @Sendable (Result<Success, Error>) -> Void
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
self.completion = completion
}
func complete(_ result: Result<Void, Error>) {
func complete(_ result: Result<Success, Error>) {
lock.lock()
let shouldComplete = !didComplete
if shouldComplete {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+16 -16
View File
@@ -2,12 +2,12 @@ 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 {
@@ -34,11 +34,11 @@ struct ChunkMapView: View {
}
}
}
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)
@@ -50,23 +50,23 @@ struct ChunkMapView: View {
}
}
}
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],
@@ -74,11 +74,11 @@ struct ChunkMapView: View {
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
@@ -92,7 +92,7 @@ struct ChunkMapView: View {
struct ChunkGrid: View {
let bitfield: String
let numPieces: Int
private var pieces: [Bool] {
var result = [Bool]()
result.reserveCapacity(numPieces)
@@ -110,7 +110,7 @@ struct ChunkGrid: View {
}
return result
}
var body: some View {
let itemPieces = pieces
Canvas { context, size in
@@ -118,10 +118,10 @@ struct ChunkGrid: View {
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
@@ -136,7 +136,7 @@ struct ChunkGrid: View {
}
}
}
let pendingPath = Path { p in
var cx: CGFloat = 0
var cy: CGFloat = 0
@@ -151,7 +151,7 @@ struct ChunkGrid: View {
}
}
}
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
context.fill(completedPath, with: .color(Color.accentColor))
}
+17 -1
View File
@@ -154,6 +154,14 @@ struct ContentView: View {
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
Button("") {
if let item = selectedItems.first {
performPrimaryAction(for: item)
}
}
.keyboardShortcut(.return, modifiers: [])
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
@@ -181,7 +189,7 @@ struct ContentView: View {
}
selection.removeAll()
}
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
@@ -194,6 +202,14 @@ struct ContentView: View {
selection = Set(items.map { $0.id })
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
} else {
openWindow(id: "download-properties", value: item.id)
}
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
+277 -72
View File
@@ -15,7 +15,9 @@ final class DownloadController: ObservableObject {
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private let mediaEngine = MediaDownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var activeMediaHandles: [UUID: MediaDownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
@@ -59,7 +61,7 @@ final class DownloadController: ObservableObject {
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
@@ -67,7 +69,7 @@ final class DownloadController: ObservableObject {
self?.saveDownloads()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
@@ -108,6 +110,23 @@ final class DownloadController: ObservableObject {
Aria2DownloadEngine.findExecutable() != nil
}
private var hasStartableQueuedDownloadIgnoringEngine: Bool {
downloads.contains { item in
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}
}
private var hasRunnableQueuedDownload: Bool {
downloads.contains { item in
item.status == .queued &&
(item.mediaFormatSelector != nil || hasAria2) &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}
}
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(),
@@ -168,7 +187,9 @@ final class DownloadController: ObservableObject {
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
queueID: targetQueueID,
mediaFormatSelector: pending.selectedMediaOption?.formatSelector,
isAudioOnlyMedia: pending.selectedMediaOption?.isAudioOnly
)
}
@@ -211,6 +232,8 @@ final class DownloadController: ObservableObject {
func pause(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
@@ -234,6 +257,8 @@ final class DownloadController: ObservableObject {
for item in activeItems {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
@@ -252,6 +277,8 @@ final class DownloadController: ObservableObject {
func queue(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .queued
if item.status != .paused {
@@ -327,6 +354,8 @@ final class DownloadController: ObservableObject {
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
@@ -349,6 +378,8 @@ final class DownloadController: ObservableObject {
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
if deleteFiles {
trashFiles(for: item)
} else if item.status != .completed {
@@ -437,7 +468,11 @@ final class DownloadController: ObservableObject {
}
private func pumpQueue() {
guard hasAria2 else {
guard hasStartableQueuedDownloadIgnoringEngine else {
return
}
guard hasRunnableQueuedDownload else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return
}
@@ -447,6 +482,7 @@ final class DownloadController: ObservableObject {
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in
item.status == .queued &&
(item.mediaFormatSelector != nil || hasAria2) &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
@@ -473,73 +509,199 @@ final class DownloadController: ObservableObject {
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
if item.mediaFormatSelector != nil {
Task {
do {
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Checking bundled media engines..."
}
},
completion: { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.activeHandles[item.id] = nil
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..."
}
let handle = try await mediaEngine.start(
item: liveItem,
cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
if $0.message == "Starting" {
$0.message = "Downloading Media"
}
}
}
},
messageUpdate: { [weak self] message in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.message = message
}
}
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleMediaCompletion(item: item, result: result)
}
}
)
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeMediaHandles[item.id] = handle
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
guard shouldHandleStartFailure(for: item.id) else { return }
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
} else {
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result)
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Starting..."
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
}
}
private func activeDownloadItem(id: UUID) -> DownloadItem? {
downloads.first { $0.id == id && $0.status == .downloading }
}
private func shouldHandleStartFailure(for id: UUID) -> Bool {
guard let item = downloads.first(where: { $0.id == id }) else {
automaticRetryCounts[id] = nil
return false
}
return item.status != .paused && item.status != .canceled
}
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>) {
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
if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func handleMediaCompletion(item: DownloadItem, result: Result<URL, Error>) {
activeMediaHandles[item.id] = nil
switch result {
case .success(let outputURL):
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.destinationDirectory = outputURL.deletingLastPathComponent()
$0.fileName = outputURL.lastPathComponent
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.message = "Saved to \(outputURL.path)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
@@ -624,6 +786,14 @@ final class DownloadController: ObservableObject {
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
guard let currentItem = downloads.first(where: { $0.id == itemID }) else {
automaticRetryCounts[itemID] = nil
return
}
guard currentItem.status != .paused, currentItem.status != .canceled else {
return
}
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
@@ -656,16 +826,51 @@ final class DownloadController: ObservableObject {
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
if let engineError = error as? Aria2DownloadEngine.EngineError {
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
if let mediaError = error as? MediaDownloadEngine.EngineError {
switch mediaError {
case .missingEngine:
return false
case .launchFailed(let message):
return isRecoverableMediaFailure(message)
}
}
return true
}
private func isRecoverableMediaFailure(_ message: String) -> Bool {
let lowercased = message.lowercased()
let permanentMarkers = [
"requires browser cookies",
"choose a browser",
"challenge solving failed",
"install deno or node",
"requested format is not available",
"unsupported url",
"private video",
"sign in",
"not a bot",
"video unavailable",
"no video formats found",
"no audio formats found",
"ffmpeg is not installed",
"yt-dlp is not installed"
]
if permanentMarkers.contains(where: { lowercased.contains($0) }) {
return false
}
return true
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
@@ -774,7 +979,7 @@ final class DownloadController: ObservableObject {
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 {
@@ -814,7 +1019,7 @@ final class DownloadController: ObservableObject {
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
}
+90 -1
View File
@@ -35,7 +35,8 @@ enum DownloadMetadataFetcher {
for url: URL,
settings: AppSettings,
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
isAutoFetch: Bool = false
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
@@ -53,6 +54,11 @@ enum DownloadMetadataFetcher {
return pending
}
if isAutoFetch, let host = url.host, isPrivateHost(host) {
pending.state = .loaded
return pending
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 12
@@ -122,6 +128,89 @@ enum DownloadMetadataFetcher {
}
return nil
}
private static func isPrivateHost(_ host: String) -> Bool {
let h = host.lowercased()
if h == "localhost" || h.hasSuffix(".local") { return true }
if !h.contains(".") && !h.contains(":") { return true }
var hints = addrinfo(
ai_flags: 0,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
var res: UnsafeMutablePointer<addrinfo>?
if getaddrinfo(host, nil, &hints, &res) == 0 {
var current = res
while let info = current {
let family = info.pointee.ai_family
if family == AF_INET {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
let first = (ip >> 24) & 0xFF
let second = (ip >> 16) & 0xFF
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
} else if family == AF_INET6 {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
let isULA = (bytes.0 & 0xFE) == 0xFC
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
if isLoopback || isULA || isLinkLocal {
freeaddrinfo(res)
return true
}
if isIPv4Mapped {
let first = bytes.12
let second = bytes.13
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
}
}
current = info.pointee.ai_next
}
freeaddrinfo(res)
}
return false
}
}
enum ByteFormatter {
+49 -49
View File
@@ -37,25 +37,34 @@ struct DownloadTable: View {
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)
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
doubleClickableCell(for: item) {
Text(ByteFormatter.string(item.sizeBytes))
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
@@ -64,49 +73,46 @@ struct DownloadTable: View {
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
doubleClickableCell(for: item) {
progressBarCell(for: item)
}
progressBarCell(for: item)
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
doubleClickableCell(for: item) {
Text(item.status.rawValue)
}
statusCell(for: item)
}
.width(min: 80, ideal: 105)
.width(min: 115, ideal: 170)
TableColumn("Speed", value: \.displaySpeedText) { item in
doubleClickableCell(for: item) {
Text(item.displaySpeedText)
Text(item.displaySpeedText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.displayETAText) { item in
doubleClickableCell(for: item) {
Text(item.displayETAText)
Text(item.displayETAText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
doubleClickableCell(for: item) {
Text(formatted(item.createdAt))
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 100, ideal: 155)
}
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
for target in targetItems {
if target.status == .completed {
openFile(target)
}
}
}
.overlay {
if items.isEmpty {
@@ -157,23 +163,17 @@ struct DownloadTable: View {
}
}
private func doubleClickableCell<Content: View>(
for item: DownloadItem,
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture(count: 2).onEnded {
performPrimaryAction(for: item)
})
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
@ViewBuilder
private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
if item.status == .downloading, !message.isEmpty {
Text(message)
.lineLimit(1)
.truncationMode(.tail)
} else {
openWindow(value: item.id)
Text(item.status.rawValue)
.lineLimit(1)
.truncationMode(.tail)
}
}
@@ -260,7 +260,7 @@ struct DownloadTable: View {
Button {
for target in targetItems {
openWindow(value: target.id)
openWindow(id: "download-properties", value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
@@ -310,12 +310,12 @@ struct DownloadTable: View {
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)
@@ -0,0 +1,87 @@
import SwiftUI
struct DuplicateDownloadItem: Identifiable, Equatable {
let id: UUID
var pendingItem: PendingDownload
var resolution: DuplicateResolutionAction = .rename
let reason: DuplicateReason
enum DuplicateReason: Equatable {
case existingURL(String)
case existingFile(String)
}
}
enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
case rename = "Rename"
case replace = "Replace"
case skip = "Skip"
var id: String { rawValue }
}
struct DuplicateResolutionView: View {
@Binding var conflicts: [DuplicateDownloadItem]
let onConfirm: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text("Duplicate Downloads Detected")
.font(.headline)
Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
List($conflicts) { $conflict in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(conflict.pendingItem.fileName)
.lineLimit(1)
.font(.body.weight(.medium))
Text(reasonText(for: conflict.reason))
.font(.caption)
.foregroundStyle(.orange)
}
Spacer()
Picker("", selection: $conflict.resolution) {
ForEach(DuplicateResolutionAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.frame(width: 100)
}
.padding(.vertical, 4)
}
.frame(minHeight: 200)
Divider()
HStack {
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Spacer()
Button("Continue", action: onConfirm)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
.padding(16)
}
.frame(width: 500)
}
private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
switch reason {
case .existingURL(let msg): return msg
case .existingFile(let msg): return msg
}
}
}
+94 -18
View File
@@ -1,39 +1,99 @@
import SwiftUI
import Sparkle
final class SparkleUpdater: ObservableObject {
let controller: SPUStandardUpdaterController
init(controller: SPUStandardUpdaterController) {
self.controller = controller
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _updater: SPUUpdater?
var updater: SPUUpdater { _updater! }
@Published var isChecking = false
@Published var isDownloading = false
@Published var isExtracting = false
@Published var isReadyToInstall = false
@Published var downloadProgress: Double = 0.0
@Published var extractionProgress: Double = 0.0
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: AttributedString?
@Published var automaticallyChecksForUpdates: Bool = true {
didSet {
_updater?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
}
}
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
var cancellation: (() -> Void)?
var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
override init() {
super.init()
let driver = InlineUpdateUserDriver(updater: self)
let hostBundle = Bundle.main
self._updater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: driver, delegate: self)
do {
try self._updater?.start()
self.automaticallyChecksForUpdates = self._updater?.automaticallyChecksForUpdates ?? true
} catch {
print("Failed to start Sparkle updater: \(error)")
}
}
func checkForUpdates() {
guard updater.canCheckForUpdates else {
isChecking = false
updateStatus = "Update check is already in progress."
return
}
updater.checkForUpdates()
}
func resetState() {
isChecking = false
isDownloading = false
isExtracting = false
isReadyToInstall = false
downloadProgress = 0.0
extractionProgress = 0.0
updateStatus = nil
foundUpdateItem = nil
releaseNotes = nil
expectedContentLength = 0
receivedContentLength = 0
cancellation = nil
updateChoiceReply = nil
}
// Delegate methods can be left mostly empty or minimal since the UserDriver handles the UI state now.
func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
DispatchQueue.main.async {
self.isChecking = false
}
}
}
@main
struct FirelinkApp: App {
private let updaterController: SPUStandardUpdaterController
@StateObject private var sparkleUpdater: SparkleUpdater
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
// Server must be retained to keep listening
private let extensionServer: LocalExtensionServer?
init() {
// Initialize Sparkle updater
let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
self.updaterController = updaterController
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater(controller: updaterController))
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater())
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 = LocalExtensionServer(downloadController: controller, settings: settings)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
@@ -48,15 +108,31 @@ struct FirelinkApp: App {
.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)
if url.scheme == "firelink" {
if url.host == "add",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let link = queryItems.first(where: { $0.name == "url" })?.value {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = link
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
Window("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
@@ -65,7 +141,7 @@ struct FirelinkApp: App {
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
@@ -86,7 +162,7 @@ struct FirelinkApp: App {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
+49 -1
View File
@@ -30,7 +30,8 @@ enum KeychainCredentialStore {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
@@ -44,6 +45,53 @@ enum KeychainCredentialStore {
kSecAttrAccount as String: id.uuidString
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
private static let extensionTokenService = "local.firelink.extension-token"
private static let extensionTokenAccount = "pairing-token"
static func extensionToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
@discardableResult
static func setExtensionToken(_ token: String) -> Bool {
deleteExtensionToken()
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount,
kSecValueData as String: Data(token.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
@discardableResult
static func deleteExtensionToken() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
+29 -15
View File
@@ -8,19 +8,27 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
static let extensionRequestToken = "firelink-extension-v1"
// Firelink Companion sends this token.
// We now use a dynamic token generated in AppSettings, but fallback to this
// for backward compatibility during the extension rollout if needed, though
// we'll enforce the dynamic token strictly in the processRequest method.
static let legacyExtensionToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController
self.settings = settings
let parameters = NWParameters.tcp
var createdListener: NWListener?
var selectedPort: UInt16?
for portValue in Constants.portRange {
@@ -33,12 +41,12 @@ final class LocalExtensionServer: @unchecked Sendable {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
self.port = selectedPort ?? 6412
}
@@ -100,7 +108,7 @@ final class LocalExtensionServer: @unchecked Sendable {
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-Methods: GET, POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
}
@@ -120,12 +128,8 @@ final class LocalExtensionServer: @unchecked Sendable {
}
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"
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
@@ -134,12 +138,22 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
guard request.method == "POST" else {
return .methodNotAllowed
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else {
return .forbidden
}
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
return .forbidden
if request.path == "/ping" {
return request.method == "GET" ? .ok : .methodNotAllowed
}
guard request.path == "/download" else {
return .notFound
}
guard request.method == "POST" else {
return .methodNotAllowed
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
+28
View File
@@ -0,0 +1,28 @@
import Foundation
enum MediaDetector {
private static let supportedDomains: Set<String> = [
"youtube.com", "youtu.be",
"twitter.com", "x.com",
"vimeo.com",
"twitch.tv",
"instagram.com",
"tiktok.com",
"facebook.com", "fb.watch",
"reddit.com", "v.redd.it",
"soundcloud.com"
]
static func isSupportedMedia(url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
for domain in supportedDomains {
if host == domain || host.hasSuffix(".\(domain)") {
// Ignore raw files that happen to be hosted on these domains, if any,
// though usually these domains serve web pages for media.
return true
}
}
return false
}
}
+376
View File
@@ -0,0 +1,376 @@
import Foundation
final class MediaDownloadEngine: @unchecked Sendable {
struct Handle {
let cancel: @Sendable () -> Void
}
enum EngineError: LocalizedError {
case missingEngine(String)
case launchFailed(String)
var errorDescription: String? {
switch self {
case .missingEngine(let msg): return msg
case .launchFailed(let msg): return msg
}
}
}
func start(
item: DownloadItem,
cookieSource: BrowserCookieSource,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<URL, Error>) -> Void
) async throws -> Handle {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
let process = Process()
process.executableURL = ytDlpURL
var arguments = [
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--force-ipv4",
"-o", item.destinationPath
]
if let format = item.mediaFormatSelector {
arguments.append("-f")
arguments.append(format)
if item.isAudioOnlyMedia == true {
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
} else {
let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
}
}
MediaExtractionEngine.appendCommonArguments(
to: &arguments,
cookieSource: cookieSource,
credentials: item.credentials,
transferOptions: item.transferOptions
)
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
arguments.append(contentsOf: ["--proxy", proxyURI])
}
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
}
appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer)
arguments.append(item.url.absoluteString)
process.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer()
let outputPathTracker = YTDLPOutputPathTracker()
let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler(
parser: parser,
outputPathTracker: outputPathTracker,
progress: progress,
messageUpdate: messageUpdate
)
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
}
process.terminationHandler = { _ in
group.leave()
}
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
}
}
try process.run()
messageUpdate("Fetching media data...")
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
return Handle(cancel: {
if process.isRunning {
process.terminate()
}
})
}
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
let expectedURL = URL(fileURLWithPath: item.destinationPath)
if FileManager.default.fileExists(atPath: expectedURL.path) {
return expectedURL
}
if let observedURL = tracker.lastExistingOutputURL {
return observedURL
}
let baseName = expectedURL.deletingPathExtension().lastPathComponent
guard let contents = try? FileManager.default.contentsOfDirectory(
at: item.destinationDirectory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return expectedURL
}
return contents
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
.max { lhs, rhs in
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return lhsDate < rhsDate
} ?? expectedURL
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
guard !message.isEmpty else {
return "Exit code \(status)"
}
if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
message.localizedCaseInsensitiveContains("not a bot") ||
message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
}
if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solving failed. Install Deno or Node, then retry."
}
return message
}
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
let connections = min(max(connectionsPerServer, 1), 16)
guard connections > 1 else { return }
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
// Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout
}
}
final class YTDLPOutputHandler: @unchecked Sendable {
private let parser: YTDLPProgressParser
private let outputPathTracker: YTDLPOutputPathTracker
private let progress: @Sendable (DownloadProgress) -> Void
private let messageUpdate: @Sendable (String) -> Void
init(
parser: YTDLPProgressParser,
outputPathTracker: YTDLPOutputPathTracker,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void
) {
self.parser = parser
self.outputPathTracker = outputPathTracker
self.progress = progress
self.messageUpdate = messageUpdate
}
func handle(_ text: String) {
for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line)
outputPathTracker.observe(stringLine)
if let update = parser.parse(stringLine) {
progress(update)
messageUpdate("Downloading Media")
} else if let message = statusMessage(for: stringLine) {
messageUpdate(message)
}
}
}
private func statusMessage(for line: String) -> String? {
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
return "Processing Media..."
}
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Fetching YouTube data..."
}
if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Preparing media stream..."
}
if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
line.localizedCaseInsensitiveContains("not a bot") ||
line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies"
}
if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solver unavailable"
}
if line.contains("Destination:") {
return "Starting media download..."
}
return nil
}
}
final class YTDLPOutputPathTracker: @unchecked Sendable {
private let lock = NSLock()
private var observedPaths: [String] = []
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
var lastExistingOutputURL: URL? {
lock.withLock {
observedPaths
.reversed()
.map { URL(fileURLWithPath: $0) }
.first { FileManager.default.fileExists(atPath: $0.path) }
}
}
func observe(_ line: String) {
let candidates = pathCandidates(from: line)
guard !candidates.isEmpty else { return }
lock.withLock {
for candidate in candidates where !observedPaths.contains(candidate) {
observedPaths.append(candidate)
}
}
}
private func pathCandidates(from line: String) -> [String] {
var paths: [String] = []
if line.contains("Destination:"),
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
destination.hasPrefix("/") {
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
}
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
paths.append(quoted)
}
return paths
}
private func quotedCaptures(in text: String) -> [String] {
guard let quotedPathRegex else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
guard match.numberOfRanges > 1,
let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
}
private extension String {
func fileExtension(defaultValue: String) -> String {
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return ext.isEmpty ? defaultValue : ext
}
}
final class YTDLPProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
func parse(_ line: String) -> DownloadProgress? {
if line.contains("[download]") && line.contains("%") {
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: 1
)
} else if line.contains("[#") && line.contains("DL:") {
let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: cn
)
}
return nil
}
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
guard let regex else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
return nil
}
guard let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case installed(version: String)
case failed(error: String)
}
enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var binaryName: String {
switch self {
case .ytDlp: return "yt-dlp"
case .ffmpeg: return "ffmpeg"
}
}
}
@MainActor
final class MediaEngineManager: ObservableObject {
static let shared = MediaEngineManager()
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL? {
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
#if SWIFT_PACKAGE
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
#endif
}
return nil
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
if binaryPath(for: addon) != nil {
setState(for: addon, to: .installed(version: "Bundled"))
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed:
return false
case .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
for missing in missingAddons {
setState(for: missing, to: .failed(error: "Bundled executable missing"))
}
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
}
private func state(for addon: AddonType) -> AddonState {
switch addon {
case .ytDlp: return ytDlpState
case .ffmpeg: return ffmpegState
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
case .ffmpeg: ffmpegState = state
}
}
}
@@ -0,0 +1,457 @@
import Foundation
struct RawMediaFormat: Decodable, Sendable, Equatable {
let format_id: String?
let ext: String?
let resolution: String?
let format_note: String?
let vcodec: String?
let acodec: String?
let height: Int?
let filesize: Int64?
let filesize_approx: Int64?
}
struct MediaMetadata: Decodable, Sendable, Equatable {
let id: String?
let title: String?
let uploader: String?
let channel: String?
let thumbnail: URL?
let duration: Double?
let formats: [RawMediaFormat]?
var displayUploader: String? {
channel ?? uploader
}
}
struct CleanFormatOption: Identifiable, Equatable, Sendable {
var id: String { name }
let name: String
let formatSelector: String
let isAudioOnly: Bool
let symbol: String
let outputExtension: String
let detail: String
let estimatedBytes: Int64?
}
enum MediaExtractionEngine {
private static let metadataTimeoutSeconds: UInt64 = 75
enum ExtractionError: Error, LocalizedError {
case processFailed(String)
case invalidOutput
case parsingFailed(Error)
case timedOut
var errorDescription: String? {
switch self {
case .processFailed(let msg): return "Extraction failed: \(msg)"
case .invalidOutput: return "Invalid output from media engine."
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
}
}
}
static func fetchMetadata(
for url: URL,
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) async throws -> (MediaMetadata, [CleanFormatOption]) {
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw ExtractionError.processFailed("yt-dlp binary not found.")
}
let ytDlpPath = ytDlpURL.path
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
args.append(url.absoluteString)
let data = try await YTDLPMetadataProcess(
executableURL: URL(fileURLWithPath: ytDlpPath),
arguments: args
).run(timeoutSeconds: metadataTimeoutSeconds)
guard !data.isEmpty else {
throw ExtractionError.invalidOutput
}
do {
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
let options = extractOptions(from: metadata)
return (metadata, options)
} catch {
throw ExtractionError.parsingFailed(error)
}
}
static func appendCommonArguments(
to args: inout [String],
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) {
if let browserName = cookieSource.ytDlpBrowserName {
args.append(contentsOf: ["--cookies-from-browser", browserName])
}
appendJavaScriptRuntimeArguments(to: &args)
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
args.append(contentsOf: ["--add-header", header.headerLine])
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
!cookieHeader.isEmpty {
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
}
if let credentials, !credentials.isEmpty {
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
}
}
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
var runtimes: [String] = []
if let denoPath = executablePath(named: "deno", candidates: [
"/opt/homebrew/bin/deno",
"/usr/local/bin/deno"
]) {
runtimes.append("deno:\(denoPath)")
}
if let nodePath = executablePath(named: "node", candidates: [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",
"/usr/bin/node"
]) {
runtimes.append("node:\(nodePath)")
}
if !runtimes.isEmpty {
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
}
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
var safeCandidates = candidates
safeCandidates.append(contentsOf: [
"/opt/homebrew/bin/\(name)",
"/usr/local/bin/\(name)",
"/usr/bin/\(name)",
"/opt/local/bin/\(name)"
])
for candidate in safeCandidates {
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
var options: [CleanFormatOption] = []
let rawFormats = metadata.formats ?? []
let standardResolutions = [
(2160, "4K"),
(1440, "1440p"),
(1080, "1080p"),
(720, "720p"),
(480, "480p"),
(360, "360p")
]
let availableResolutions = standardResolutions.filter { resolution, _ in
rawFormats.contains { format in
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
}
}
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
let videoContainers = [
("mp4", "MP4"),
("mkv", "MKV"),
("webm", "WebM")
]
for (height, qualityName) in videoQualities {
for (container, containerName) in videoContainers {
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
options.append(CleanFormatOption(
name: "\(qualityName) \(containerName)",
formatSelector: videoSelector(height: height, container: container),
isAudioOnly: false,
symbol: "play.tv.fill",
outputExtension: container,
detail: optionDetail(
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes
))
}
}
if hasAudioFormat(rawFormats, preferredExtension: nil) {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
options.append(CleanFormatOption(
name: "Audio MP3",
formatSelector: "bestaudio/best",
isAudioOnly: true,
symbol: "music.note",
outputExtension: "mp3",
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
options.append(CleanFormatOption(
name: "Audio M4A",
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "m4a",
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
if hasAudioFormat(rawFormats, preferredExtension: "webm") {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
options.append(CleanFormatOption(
name: "Audio Opus",
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "opus",
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
return options
}
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
formats.contains { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
}
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
formats.contains { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
}
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
let videoBytes = formats
.filter { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
guard let videoBytes else { return nil }
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
estimatedAudioBytes(formats, preferredExtension: nil) ??
0
return videoBytes + audioBytes
}
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
let preferred = formats
.filter { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
if preferred != nil || preferredExtension == nil {
return preferred
}
return estimatedAudioBytes(formats, preferredExtension: nil)
}
private static func isVideo(_ format: RawMediaFormat) -> Bool {
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
return true
}
private static func isAudio(_ format: RawMediaFormat) -> Bool {
let acodec = format.acodec?.lowercased()
let vcodec = format.vcodec?.lowercased()
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
}
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
guard let height else { return true }
guard let formatHeight = format.height else { return false }
return formatHeight <= height && formatHeight >= height - 100
}
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
format.filesize ?? format.filesize_approx
}
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
guard let estimatedBytes, estimatedBytes > 0 else { return base }
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
}
private static func videoSelector(height: Int?, container: String) -> String {
let filter = heightFilter(height)
switch container {
case "mp4":
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
case "webm":
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
default:
return "bestvideo\(filter)+bestaudio/best\(filter)"
}
}
private static func heightFilter(_ height: Int?) -> String {
guard let height else { return "" }
return "[height<=\(height)]"
}
}
private final class YTDLPMetadataProcess: @unchecked Sendable {
private let executableURL: URL
private let arguments: [String]
private let lock = NSLock()
private var process: Process?
init(executableURL: URL, arguments: [String]) {
self.executableURL = executableURL
self.arguments = arguments
}
func run(timeoutSeconds: UInt64) async throws -> Data {
try await withTaskCancellationHandler {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
try await self.runProcess()
}
group.addTask {
try await Task.sleep(for: .seconds(timeoutSeconds))
self.terminate()
throw MediaExtractionEngine.ExtractionError.timedOut
}
guard let result = try await group.next() else {
throw MediaExtractionEngine.ExtractionError.invalidOutput
}
group.cancelAll()
return result
}
} onCancel: {
self.terminate()
}
}
private func runProcess() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
let outputPipe = Pipe()
let errorPipe = Pipe()
let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
let errorBuffer = LockedDataBuffer()
process.executableURL = executableURL
process.arguments = arguments
process.standardOutput = outputPipe
process.standardError = errorPipe
process.standardInput = nil
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
outputBuffer.append(data)
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
}
}
lock.withLock {
self.process = process
}
process.terminationHandler = { _ in
group.leave()
}
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
continuation.resume(returning: outputBuffer.data)
} else {
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
continuation.resume(
throwing: MediaExtractionEngine.ExtractionError.processFailed(
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
)
)
}
}
do {
try process.run()
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
} catch {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
// We do not care about the DispatchGroup if we throw immediately here
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
}
}
}
private func terminate() {
lock.withLock {
if let process, process.isRunning {
process.terminate()
}
}
}
}
+7
View File
@@ -188,6 +188,8 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var queueID: UUID?
var rpcPort: Int?
var rpcSecret: String?
var mediaFormatSelector: String?
var isAudioOnlyMedia: Bool?
var displaySpeedText: String {
status == .downloading ? speedText : "-"
@@ -267,6 +269,11 @@ struct PendingDownload: Identifiable, Equatable, Sendable {
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var isMedia: Bool = false
var mediaOptions: [CleanFormatOption] = []
var selectedMediaOption: CleanFormatOption?
var mediaMetadata: MediaMetadata?
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path
+13 -13
View File
@@ -49,7 +49,7 @@ final class SchedulerController: ObservableObject {
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?
@@ -68,14 +68,14 @@ final class SchedulerController: ObservableObject {
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()
@@ -84,7 +84,7 @@ final class SchedulerController: ObservableObject {
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
@@ -112,7 +112,7 @@ final class SchedulerController: ObservableObject {
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)
@@ -141,26 +141,26 @@ final class SchedulerController: ObservableObject {
}
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()
@@ -173,7 +173,7 @@ final class SchedulerController: ObservableObject {
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
@@ -185,9 +185,9 @@ final class SchedulerController: ObservableObject {
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
+22 -22
View File
@@ -4,7 +4,7 @@ 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()
@@ -12,12 +12,12 @@ struct SchedulerView: View {
@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) {
@@ -27,7 +27,7 @@ struct SchedulerView: View {
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
@@ -49,7 +49,7 @@ struct SchedulerView: View {
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
@@ -57,15 +57,15 @@ struct SchedulerView: View {
.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
@@ -76,18 +76,18 @@ struct SchedulerView: View {
}
.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
@@ -107,12 +107,12 @@ struct SchedulerView: View {
}
}
}
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)
@@ -134,12 +134,12 @@ struct SchedulerView: View {
}
}
}
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)
@@ -149,17 +149,17 @@ struct SchedulerView: View {
.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()
@@ -173,7 +173,7 @@ struct SchedulerView: View {
}
}
}
private var toastView: some View {
VStack {
Spacer()
@@ -192,7 +192,7 @@ struct SchedulerView: View {
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
@@ -203,7 +203,7 @@ struct SchedulerView: View {
? [DownloadQueue.mainQueueID]
: schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
+202 -13
View File
@@ -7,6 +7,9 @@ struct AboutSettingsPane: View {
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 ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
private let sparkleURL = URL(string: "https://sparkle-project.org/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
@@ -40,22 +43,194 @@ struct AboutSettingsPane: View {
}
Section("Updates") {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button {
sparkleUpdater.controller.checkForUpdates(nil)
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
VStack(alignment: .leading, spacing: 16) {
if sparkleUpdater.isChecking {
HStack(spacing: 12) {
ProgressView()
.controlSize(.small)
Text("Checking for updates...")
.foregroundStyle(.secondary)
}
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
} else if sparkleUpdater.isDownloading || sparkleUpdater.isExtracting {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(sparkleUpdater.isDownloading ? "Downloading update..." : "Extracting update...")
.font(.subheadline)
.fontWeight(.medium)
Spacer()
if sparkleUpdater.isDownloading && sparkleUpdater.downloadProgress > 0 {
Text("\(Int(sparkleUpdater.downloadProgress * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
ProgressView(value: sparkleUpdater.isDownloading ? sparkleUpdater.downloadProgress : sparkleUpdater.extractionProgress)
.tint(.accentColor)
Button("Cancel") {
sparkleUpdater.cancellation?()
}
.controlSize(.small)
}
} else if sparkleUpdater.isReadyToInstall {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.down.app.fill")
.foregroundStyle(.green)
.font(.title2)
VStack(alignment: .leading) {
Text("Update Ready")
.font(.headline)
Text("The new version is ready to be installed.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Button {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Label("Install and Relaunch", systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else if let item = sparkleUpdater.foundUpdateItem {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("Update Available")
.font(.headline)
Text("Version \(item.displayVersionString) is available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let notes = sparkleUpdater.releaseNotes {
DisclosureGroup("What's New") {
ScrollView {
Text(notes)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(maxHeight: 150)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
}
}
HStack(spacing: 12) {
Button {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Text("Download & Install")
}
.buttonStyle(.borderedProminent)
Button("Remind Me Later") {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.dismiss)
}
Button("Skip This Version") {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.skip)
}
}
}
} else {
// Up to date or initial state
if let status = sparkleUpdater.updateStatus, status == "You're up to date!" {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("You're up to date!")
.font(.headline)
Text("Firelink \(appVersion) is the newest version available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
} else {
HStack(spacing: 12) {
if let status = sparkleUpdater.updateStatus {
if status.lowercased().contains("failed") || status.lowercased().contains("error") {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(.red)
} else {
Image(systemName: "info.circle.fill")
.foregroundStyle(.blue)
}
Text(status)
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("Keeping your app up to date ensures you have the latest features and security improvements.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
}
Divider()
.padding(.vertical, 4)
Toggle("Automatically check for updates", isOn: $sparkleUpdater.automaticallyChecksForUpdates)
}
.padding(.vertical, 4)
.padding(.vertical, 8)
.animation(.easeInOut, value: sparkleUpdater.isChecking)
.animation(.easeInOut, value: sparkleUpdater.isDownloading)
.animation(.easeInOut, value: sparkleUpdater.isExtracting)
.animation(.easeInOut, value: sparkleUpdater.isReadyToInstall)
.animation(.easeInOut, value: sparkleUpdater.foundUpdateItem != nil)
}
Section {
@@ -81,7 +256,15 @@ struct AboutSettingsPane: View {
HStack {
Text("Powered by")
Link("aria2", destination: aria2URL)
HStack(spacing: 4) {
Link("aria2", destination: aria2URL)
Text("").foregroundStyle(.secondary)
Link("yt-dlp", destination: ytDlpURL)
Text("").foregroundStyle(.secondary)
Link("ffmpeg", destination: ffmpegURL)
Text("").foregroundStyle(.secondary)
Link("Sparkle", destination: sparkleURL)
}
Spacer()
Link("MIT License", destination: licenseURL)
}
@@ -95,5 +278,11 @@ struct AboutSettingsPane: View {
}
}
.formStyle(.grouped)
.onDisappear {
if let reply = sparkleUpdater.updateChoiceReply {
sparkleUpdater.updateChoiceReply = nil
reply(.dismiss)
}
}
}
}
@@ -24,7 +24,7 @@ struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
LabeledContent("Global speed limit") {
HStack {
@@ -2,6 +2,8 @@ import SwiftUI
import AppKit
struct EngineSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@StateObject private var engineManager = MediaEngineManager.shared
@State private var version = "Checking..."
private var executableURL: URL? {
@@ -11,30 +13,56 @@ struct EngineSettingsPane: View {
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))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Binary Path") {
Text(executableURL?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
.help(executableURL?.path ?? "")
} header: {
Text("Core Downloader (Aria2)")
} footer: {
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.")
.foregroundStyle(.red)
} else {
Text("Handles core HTTP/FTP and BitTorrent downloads.")
}
}
Section {
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
LabeledContent("Browser Cookies") {
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
}
}
.labelsHidden()
.frame(maxWidth: 200)
}
} header: {
Text("Media Extractors")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Powers video and audio extraction from supported sites.")
if settings.mediaCookieSource != .none {
Text(settings.mediaCookieSource.statusDetail)
}
}
}
}
@@ -43,4 +71,32 @@ struct EngineSettingsPane: View {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
LabeledContent(title) {
VStack(alignment: .trailing) {
switch state {
case .notInstalled:
Text("Missing")
.foregroundStyle(.red)
case .installed(let version):
Text(version)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Text("Error")
.foregroundStyle(.red)
.help(error)
}
Text(path?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}
}
}
@@ -0,0 +1,150 @@
import Foundation
import AppKit
import Sparkle
class InlineUpdateUserDriver: NSObject, SPUUserDriver {
weak var updater: SparkleUpdater?
init(updater: SparkleUpdater) {
self.updater = updater
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.resetState()
self.updater?.isChecking = true
self.updater?.updateStatus = "Checking for updates..."
self.updater?.cancellation = cancellation
}
}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.foundUpdateItem = appcastItem
self.updater?.updateStatus = "Update available: Version \(appcastItem.displayVersionString)"
self.updater?.updateChoiceReply = reply
}
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
DispatchQueue.main.async {
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let nsAttrString = try? NSMutableAttributedString(data: downloadData.data, options: options, documentAttributes: nil) {
let range = NSRange(location: 0, length: nsAttrString.length)
nsAttrString.removeAttribute(.foregroundColor, range: range)
nsAttrString.removeAttribute(.font, range: range)
if let attrString = try? AttributedString(nsAttrString, including: \.appKit) {
self.updater?.releaseNotes = attrString
}
}
}
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
}
func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
let nsError = error as NSError
if nsError.domain == SUSparkleErrorDomain && nsError.code == 1001 {
self.updater?.updateStatus = "You're up to date!"
} else {
self.updater?.updateStatus = "Update check failed: \(error.localizedDescription)"
}
acknowledgement()
}
}
func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.updateStatus = "Updater error: \(error.localizedDescription)"
acknowledgement()
}
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isDownloading = true
self.updater?.downloadProgress = 0.0
self.updater?.cancellation = cancellation
self.updater?.updateStatus = "Downloading update..."
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
DispatchQueue.main.async {
self.updater?.expectedContentLength = expectedContentLength
self.updater?.receivedContentLength = 0
}
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
DispatchQueue.main.async {
if let updater = self.updater {
updater.receivedContentLength += length
if updater.expectedContentLength > 0 {
updater.downloadProgress = Double(updater.receivedContentLength) / Double(updater.expectedContentLength)
}
}
}
}
func showDownloadDidStartExtractingUpdate() {
DispatchQueue.main.async {
self.updater?.isDownloading = false
self.updater?.isExtracting = true
self.updater?.updateStatus = "Extracting update..."
self.updater?.downloadProgress = 1.0
}
}
func showExtractionReceivedProgress(_ progress: Double) {
DispatchQueue.main.async {
self.updater?.extractionProgress = progress
}
}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = true
self.updater?.updateStatus = "Ready to install"
self.updater?.updateChoiceReply = reply
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
acknowledgement()
}
func dismissUpdateInstallation() {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.isDownloading = false
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = false
self.updater?.downloadProgress = 0.0
self.updater?.extractionProgress = 0.0
self.updater?.foundUpdateItem = nil
self.updater?.releaseNotes = nil
// Do not clear updateStatus here so success/error messages remain visible.
}
}
func showUpdateInFocus() {
}
}
@@ -3,65 +3,94 @@ import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
@EnvironmentObject private var settings: AppSettings
@State private var showToast = false
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
ScrollView {
VStack(spacing: 24) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
Text("Connect Browser Extension")
.font(.title.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.body)
}
Spacer()
}
.padding(.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)
.padding(.bottom, 8)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
// Step 1: Copy Token
StepCardView(
stepNumber: 1,
title: "Copy Pairing Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
}
.buttonStyle(.borderedProminent)
)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
// Step 2: Get Extension
StepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your favorite browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
// Step 3: Paste and Save
StepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
Text("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the approved add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Diagnostics") {
LabeledContent("Local receiver") {
Divider()
// Diagnostics
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
@@ -70,118 +99,107 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.orange)
}
}
.font(.footnote)
.padding(.top, 8)
LabeledContent("Copied folder") {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not copied", systemImage: "folder.badge.questionmark")
.foregroundStyle(.secondary)
}
}
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(32)
}
.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)
}
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
struct StepCardView: View {
let stepNumber: Int
let title: String
let description: String
let icon: String
let iconColor: Color
let actionText: String?
let action: (() -> Void)?
var secondaryActionText: String? = nil
var secondaryAction: (() -> Void)? = nil
var body: some View {
HStack(spacing: 16) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 32, height: 32)
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
Text("\(stepNumber)")
.font(.system(.headline, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
// Icon
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(iconColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(iconColor)
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
// Action Button
HStack(spacing: 8) {
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -14,29 +14,29 @@ struct LookAndFeelSettingsPane: View {
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -31,7 +31,7 @@ struct SettingsPaneContainer: View {
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
@@ -0,0 +1,45 @@
import SwiftUI
struct ToastNotification: ViewModifier {
var message: String
@Binding var isShowing: Bool
func body(content: Content) -> some View {
ZStack(alignment: .bottom) {
content
if isShowing {
VStack {
Spacer()
Text(message)
.font(.subheadline.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.black.opacity(0.8))
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 24)
}
.zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isShowing = false
}
}
}
}
}
}
}
extension View {
func toast(isShowing: Binding<Bool>, message: String) -> some View {
self.modifier(ToastNotification(message: message, isShowing: isShowing))
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ struct SidebarView: View {
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
+18 -18
View File
@@ -3,16 +3,16 @@ 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
@@ -33,7 +33,7 @@ struct SpeedLimiterView: View {
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
@@ -41,15 +41,15 @@ struct SpeedLimiterView: View {
.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
@@ -60,29 +60,29 @@ struct SpeedLimiterView: View {
}
.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 }
@@ -93,7 +93,7 @@ struct SpeedLimiterView: View {
.padding(.top, 8)
}
}
private var toastView: some View {
VStack {
Spacer()
@@ -112,20 +112,20 @@ struct SpeedLimiterView: View {
}
.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
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Firelink Updates</title>
<link>https://github.com/nimbold/Firelink</link>
<description>Most recent updates for Firelink</description>
<language>en</language>
<item>
<title>Version 0.6.2</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.6.2</sparkle:releaseNotesLink>
<pubDate>Mon, 08 Jun 2026 13:15:04 +0000</pubDate>
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.6.2/Firelink-0.6.2-mac-arm64.dmg"
sparkle:version="27"
sparkle:shortVersionString="0.6.2"
length="75883565"
type="application/octet-stream"
sparkle:edSignature="ZxDVZryK1xisBGEsvhORR3B09LxWbrzXqo/IppR2CY694JvbKzWMKPHxaZk6x68V9YxeyiiyeyBAsV8oHdeQBQ==" />
</item>
</channel>
</rss>
+47 -13
View File
@@ -3,33 +3,67 @@ from PIL import Image, ImageDraw
def process_images(src_path):
img = Image.open(src_path).convert("RGBA")
# Crop the 28px black padding
img = img.crop((28, 28, 1226, 1226))
width, height = img.size
# Apply a standard macOS rounded rectangle mask
# macOS standard radius is approx 22.5% of the width
pixels = img.load()
# Lighter color (+1) at top, original color (0) at bottom
bg_color = pixels[100, 100]
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width):
p = pixels[x, y]
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
# Replace pure black corners or background with gradient
if p[0] < 15 and p[1] < 15 and p[2] < 15:
new_pixels[x, y] = grad_color
elif dist < 15:
new_pixels[x, y] = grad_color
elif dist < 60:
alpha = (dist - 15) / 45.0
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
new_pixels[x, y] = (r, g, b, 255)
else:
new_pixels[x, y] = p
img = new_img
radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
# Apply mask
img.putalpha(mask)
# Save standard png
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
img_1024.save("Resources/AppIcon.png")
# Save Firefox extension icons
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
img_48.save("Extensions/Firefox/icons/icon-48.png")
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
img_128.save("Extensions/Firefox/icons/icon-128.png")
# MenuBarIconTemplate (64x64 monochrome)
data = img.getdata()
new_data = []
for item in data:
r, g, b, a = item
if r > 100 and r > b * 1.5 and a > 0:
@@ -37,14 +71,14 @@ def process_images(src_path):
new_data.append((0, 0, 0, alpha))
else:
new_data.append((0, 0, 0, 0))
menu_bar_full = Image.new("RGBA", img.size)
menu_bar_full.putdata(new_data)
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
print("Done generating main PNGs")
if __name__ == '__main__':
process_images(sys.argv[1])