Compare commits

...

38 Commits

Author SHA1 Message Date
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
42 changed files with 3110 additions and 502 deletions
+17
View File
@@ -2,7 +2,24 @@
.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
+42
View File
@@ -5,6 +5,48 @@ 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.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
+36 -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 securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
- 🎨 **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,12 @@ 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.
---
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

+17 -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"
@@ -35,12 +37,12 @@ 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/"
@@ -97,6 +99,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
+190 -40
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
}
@@ -44,6 +51,18 @@ struct AddDownloadsView: View {
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
onConfirm: {
showingDuplicates = false
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
},
onCancel: {
showingDuplicates = false
}
)
}
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
@@ -76,14 +95,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 +188,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 +223,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 +235,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 +295,7 @@ struct AddDownloadsView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
@@ -422,7 +473,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 +496,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 +509,8 @@ struct AddDownloadsView: View {
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
state: .loading,
isMedia: isMedia
)
}
@@ -468,19 +521,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 +598,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 +672,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)
+50 -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,10 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var mediaCookieSource: BrowserCookieSource {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -161,6 +203,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,6 +215,7 @@ final class AppSettings: ObservableObject {
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
}
@@ -287,7 +331,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 +342,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)
}
@@ -358,4 +403,5 @@ private struct StoredSettings: Codable {
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
}
+7 -7
View File
@@ -91,10 +91,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 +230,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 +527,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

+192
View File
@@ -0,0 +1,192 @@
import Foundation
import CryptoKit
enum BinaryDownloaderError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case downloadFailed(Error?)
case moveFailed(Error)
case permissionFailed(Error)
case unzipFailed
case unsupportedDownloadURL
case missingChecksum
case checksumMismatch
var errorDescription: String? {
switch self {
case .invalidResponse:
"The add-on server returned an invalid response."
case .httpError(let statusCode):
"The add-on download failed with HTTP \(statusCode)."
case .downloadFailed(let error):
error?.localizedDescription ?? "The add-on download failed."
case .moveFailed(let error):
error.localizedDescription
case .permissionFailed(let error):
"Could not mark the add-on executable: \(error.localizedDescription)"
case .unzipFailed:
"Could not extract the downloaded add-on archive."
case .unsupportedDownloadURL:
"The add-on URL must be HTTP or HTTPS."
case .missingChecksum:
"The add-on configuration is missing a SHA-256 checksum."
case .checksumMismatch:
"The downloaded add-on did not match the expected SHA-256 checksum."
}
}
}
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let url: URL
private let destination: URL
private let expectedSHA256: String?
private let onProgress: @Sendable (Double) -> Void
private let session: URLSession
private let continuation: CheckedContinuation<Void, Error>
init(
url: URL,
destination: URL,
expectedSHA256: String?,
onProgress: @escaping @Sendable (Double) -> Void,
continuation: CheckedContinuation<Void, Error>
) {
self.url = url
self.destination = destination
self.expectedSHA256 = expectedSHA256
self.onProgress = onProgress
self.continuation = continuation
let config = URLSessionConfiguration.ephemeral
self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below
super.init()
}
static func download(
from url: URL,
to destination: URL,
expectedSHA256: String? = nil,
onProgress: @escaping @Sendable (Double) -> Void
) async throws {
try await withCheckedThrowingContinuation { continuation in
let downloader = BinaryDownloader(
url: url,
destination: destination,
expectedSHA256: expectedSHA256,
onProgress: onProgress,
continuation: continuation
)
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
let task = session.downloadTask(with: url)
task.resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
defer { session.finishTasksAndInvalidate() }
guard let response = downloadTask.response as? HTTPURLResponse else {
continuation.resume(throwing: BinaryDownloaderError.invalidResponse)
return
}
guard (200...299).contains(response.statusCode) else {
continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode))
return
}
do {
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
throw BinaryDownloaderError.unsupportedDownloadURL
}
let isZip = url.pathExtension.lowercased() == "zip"
let stagingURL = destination
.deletingLastPathComponent()
.appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged")
var cleanupURLs: [URL] = [stagingURL]
defer {
for cleanupURL in cleanupURLs {
try? FileManager.default.removeItem(at: cleanupURL)
}
}
if isZip {
let tempZip = location.appendingPathExtension("zip")
try FileManager.default.moveItem(at: location, to: tempZip)
cleanupURLs.append(tempZip)
let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
cleanupURLs.append(extractDir)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-q", tempZip.path, "-d", extractDir.path]
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw BinaryDownloaderError.unzipFailed
}
let expectedName = destination.lastPathComponent
var foundBinary: URL?
if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) {
for case let fileURL as URL in enumerator {
if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" {
foundBinary = fileURL
break
}
}
}
guard let foundBinary = foundBinary else {
throw BinaryDownloaderError.unzipFailed
}
try FileManager.default.moveItem(at: foundBinary, to: stagingURL)
} else {
try FileManager.default.moveItem(at: location, to: stagingURL)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path)
if let expectedSHA256 {
let actualSHA256 = try Self.sha256Hex(for: stagingURL)
guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else {
throw BinaryDownloaderError.checksumMismatch
}
}
try installStagedBinary(stagingURL, at: destination)
continuation.resume()
} catch {
continuation.resume(throwing: BinaryDownloaderError.moveFailed(error))
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
onProgress(progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
session.finishTasksAndInvalidate()
continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error))
}
}
private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws {
if FileManager.default.fileExists(atPath: destination.path) {
_ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL)
} else {
try FileManager.default.moveItem(at: stagedURL, to: destination)
}
}
private static func sha256Hex(for url: URL) throws -> String {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
+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 }
+297 -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
)
}
@@ -187,6 +208,26 @@ final class DownloadController: ObservableObject {
}
}
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
var item = item
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
item.category = FileClassifier.category(forFileName: item.fileName)
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
saveDownloads()
if startImmediately {
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
}
}
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
@@ -211,6 +252,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 +277,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 +297,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 +374,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 +398,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 +488,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 +502,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 +529,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 media add-ons..."
}
},
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 = "Process \(handle.processIdentifier)"
}
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 +806,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 +846,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 +999,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 +1039,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
}
+33 -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,32 @@ 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 }
let parts = h.split(separator: ".")
if parts.count == 4, let first = Int(parts[0]), let second = Int(parts[1]) {
if first == 127 || first == 10 || (first == 192 && second == 168) {
return true
}
if first == 172 && (16...31).contains(second) {
return true
}
if first == 169 && second == 254 {
return true
}
}
if h.contains(":") {
if h == "[::1]" || h.hasPrefix("[fc") || h.hasPrefix("[fd") || h.hasPrefix("[fe8") || h.hasPrefix("[fe9") || h.hasPrefix("[fea") || h.hasPrefix("[feb") {
return true
}
}
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
}
}
}
+86 -16
View File
@@ -1,38 +1,92 @@
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: String?
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()
} 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?.start()
controller.extensionServerPort = extensionServer?.port
@@ -48,9 +102,25 @@ 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)
}
@@ -65,7 +135,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 +156,7 @@ struct FirelinkApp: App {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
+48
View File
@@ -0,0 +1,48 @@
import Foundation
struct AddonConfig: Codable, Equatable, Sendable {
let version: String
let macArm64: URL?
let macX64: URL?
let macArm64SHA256: String?
let macX64SHA256: String?
enum CodingKeys: String, CodingKey {
case version
case macArm64 = "mac-arm64"
case macX64 = "mac-x64"
case macArm64SHA256 = "mac-arm64-sha256"
case macX64SHA256 = "mac-x64-sha256"
}
/// Returns the appropriate download URL for the current system architecture
var currentArchURL: URL? {
#if arch(arm64)
return macArm64
#elseif arch(x86_64)
return macX64
#else
return nil
#endif
}
var currentArchSHA256: String? {
#if arch(arm64)
return macArm64SHA256
#elseif arch(x86_64)
return macX64SHA256
#else
return nil
#endif
}
}
struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig?
let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
}
+15 -6
View File
@@ -8,7 +8,15 @@ 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"
// IMPORTANT(Backward Compatibility):
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
// without maintaining backward compatibility for older extensions.
// If you need to update the API (e.g., changing the payload structure), add the new
// token here and handle both versions in `processRequest`.
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
@@ -20,7 +28,7 @@ final class LocalExtensionServer: @unchecked Sendable {
init?(downloadController: DownloadController) {
self.downloadController = downloadController
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
}
@@ -125,7 +133,7 @@ final class LocalExtensionServer: @unchecked Sendable {
}
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
}
@@ -138,7 +146,8 @@ final class LocalExtensionServer: @unchecked Sendable {
return .methodNotAllowed
}
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
guard let token = request.header(named: Constants.extensionRequestHeader),
Constants.supportedExtensionTokens.contains(token) else {
return .forbidden
}
+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
}
}
+362
View File
@@ -0,0 +1,362 @@
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 FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
}
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
}
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
let process = Process()
process.executableURL = ytDlpURL
var arguments = [
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--extractor-args", "youtube:player_client=ios,tv",
"-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
)
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
outputHandler.handle(text)
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
}
}
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])
}
}
+213
View File
@@ -0,0 +1,213 @@
import Foundation
import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case downloading(progress: Double)
case installed(version: String)
case failed(error: String)
}
enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var defaultsKey: String {
return "Firelink.AddonVersion.\(self.rawValue)"
}
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 let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
private var addonsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
}
private var installTasks: [AddonType: Task<Void, Error>] = [:]
private init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName)
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
guard installTasks[addon] == nil else { continue }
let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(atPath: path.path) {
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
setState(for: addon, to: .installed(version: version))
} else {
setState(for: addon, to: .installed(version: "Unknown"))
}
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
}
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask {
try await task.value
}
}
try await group.waitForAll()
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed:
return false
case .downloading, .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
try await ensureInstalled(addons: missingAddons)
}
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
let state: AddonState
let configVersion: String?
switch addon {
case .ytDlp:
state = ytDlpState
configVersion = config.ytDlp?.version
case .ffmpeg:
state = ffmpegState
configVersion = config.ffmpeg?.version
}
switch state {
case .notInstalled, .failed:
return true
case .downloading:
return true
case .installed(let version):
guard let configVersion else { return false }
return version != configVersion
}
}
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
if let task = installTasks[addon] {
return task
}
let task = Task { @MainActor in
defer { self.installTasks[addon] = nil }
try await self.install(addon: addon, from: config)
}
installTasks[addon] = task
return task
}
private func state(for addon: AddonType) -> AddonState {
switch addon {
case .ytDlp: return ytDlpState
case .ffmpeg: return ffmpegState
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
setState(for: addon, to: .downloading(progress: 0))
let addonConfig: AddonConfig? = {
switch addon {
case .ytDlp: return config.ytDlp
case .ffmpeg: return config.ffmpeg
}
}()
guard let addonConfig = addonConfig else {
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
throw URLError(.badURL)
}
guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
throw URLError(.badURL)
}
guard downloadURL.scheme?.lowercased() == "https" else {
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
throw URLError(.badURL)
}
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSHA256.isEmpty else {
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
throw BinaryDownloaderError.missingChecksum
}
do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon)
try await BinaryDownloader.download(
from: downloadURL,
to: destination,
expectedSHA256: expectedSHA256
) { progress in
Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress))
}
}
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
setState(for: addon, to: .installed(version: addonConfig.version))
} catch {
setState(for: addon, to: .failed(error: error.localizedDescription))
throw error
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
case .ffmpeg: ffmpegState = state
}
}
}
@@ -0,0 +1,435 @@
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, update yt-dlp, or change the selected browser cookie source."
}
}
}
static func fetchMetadata(
for url: URL,
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) async throws -> (MediaMetadata, [CleanFormatOption]) {
let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path
guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else {
throw ExtractionError.processFailed("yt-dlp binary not found.")
}
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,tv"]
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]) {
if let denoPath = executablePath(named: "deno", candidates: [
"/opt/homebrew/bin/deno",
"/usr/local/bin/deno"
]) {
args.append(contentsOf: ["--js-runtimes", "deno:\(denoPath)"])
}
if let nodePath = executablePath(named: "node", candidates: [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",
"/usr/bin/node"
]) {
args.append(contentsOf: ["--js-runtimes", "node:\(nodePath)"])
}
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return path
}
let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? ""
for directory in pathEnvironment.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path
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
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
outputBuffer.append(data)
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
}
lock.withLock {
self.process = process
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
continuation.resume(returning: outputBuffer.data)
return
}
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
continuation.resume(
throwing: MediaExtractionEngine.ExtractionError.processFailed(
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
)
)
}
do {
try process.run()
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
} catch {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
}
}
}
private func terminate() {
lock.withLock {
if let process, process.isRunning {
process.terminate()
}
}
}
}
@@ -0,0 +1,268 @@
import SwiftUI
struct MediaInspectorInlineView: View {
let url: URL
let cookieSource: BrowserCookieSource
let credentials: DownloadCredentials?
let transferOptions: DownloadTransferOptions
let onCancel: () -> Void
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
@ObservedObject private var engineManager = MediaEngineManager.shared
@State private var isLoading = true
@State private var statusText = "Checking Media Engine..."
@State private var metadata: MediaMetadata?
@State private var options: [CleanFormatOption] = []
@State private var errorMessage: String?
@State private var loadTask: Task<Void, Never>?
enum MediaType: String, CaseIterable, Identifiable {
case video = "Video"
case audio = "Audio"
var id: String { rawValue }
}
@State private var selectedType: MediaType = .video
@State private var selectedVideoQuality: String = "Best"
@State private var selectedVideoFormat: String = "MP4"
@State private var selectedAudioFormat: String = "MP3"
var body: some View {
HStack(spacing: 16) {
if isLoading {
ProgressView()
.controlSize(.regular)
let ytState = engineManager.ytDlpState
let ffState = engineManager.ffmpegState
if case let .downloading(p) = ytState, p > 0 {
Text("Downloading yt-dlp: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else if case let .downloading(p) = ffState, p > 0 {
Text("Downloading ffmpeg: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
Text(statusText)
.foregroundStyle(.secondary)
cookieStatusLabel
}
}
Spacer()
} else if let errorMessage {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(errorMessage)
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer()
Button("Retry") {
loadMetadata()
}
} else if let metadata {
if let thumbnail = metadata.thumbnail {
AsyncImage(url: thumbnail) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 80, height: 50)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(metadata.title ?? "Unknown Title")
.font(.subheadline.weight(.medium))
.lineLimit(1)
HStack(spacing: 12) {
Picker("Type", selection: $selectedType) {
ForEach(availableTypes) { type in
Text(type.rawValue).tag(type)
}
}
.labelsHidden()
.frame(width: 80)
if selectedType == .video {
Picker("Quality", selection: $selectedVideoQuality) {
ForEach(availableVideoQualities, id: \.self) { q in
Text(q).tag(q)
}
}
.labelsHidden()
.frame(width: 90)
Picker("Format", selection: $selectedVideoFormat) {
ForEach(availableVideoFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 80)
} else {
Picker("Format", selection: $selectedAudioFormat) {
ForEach(availableAudioFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 90)
}
}
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 16)
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
Button("Extract") {
if let selected = resolveSelectedOption() {
onDownload(selected, metadata)
}
}
.buttonStyle(.borderedProminent)
.disabled(resolveSelectedOption() == nil)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.quaternary.opacity(0.35))
)
.onAppear {
loadMetadata()
}
.onDisappear {
loadTask?.cancel()
loadTask = nil
}
.onChange(of: url) { _, _ in loadMetadata() }
.onChange(of: cookieSource) { _, _ in loadMetadata() }
.onChange(of: credentials) { _, _ in loadMetadata() }
.onChange(of: transferOptions) { _, _ in loadMetadata() }
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
.onChange(of: options) { _, _ in ensureValidSelection() }
}
private var availableTypes: [MediaType] {
var types: [MediaType] = []
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
return types
}
private var availableVideoQualities: [String] {
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
return NSOrderedSet(array: qualities).array as? [String] ?? []
}
private var availableVideoFormats: [String] {
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private var availableAudioFormats: [String] {
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private func ensureValidSelection() {
if !availableTypes.contains(selectedType), let first = availableTypes.first {
selectedType = first
}
if selectedType == .video {
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
selectedVideoQuality = first
}
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
selectedVideoFormat = first
}
} else {
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
selectedAudioFormat = first
}
}
}
private func resolveSelectedOption() -> CleanFormatOption? {
if selectedType == .video {
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
} else {
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
}
}
@ViewBuilder
private var cookieStatusLabel: some View {
if let browserName = cookieSource.ytDlpBrowserName {
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else {
Label("Browser cookies off", systemImage: "circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func loadMetadata() {
loadTask?.cancel()
isLoading = true
errorMessage = nil
metadata = nil
options = []
loadTask = Task {
do {
await MainActor.run { statusText = "Checking yt-dlp..." }
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
guard !Task.isCancelled else { return }
await MainActor.run { statusText = "Fetching Metadata..." }
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
for: url,
cookieSource: cookieSource,
credentials: credentials,
transferOptions: transferOptions
)
guard !Task.isCancelled else { return }
await MainActor.run {
if fetchedOptions.isEmpty {
self.errorMessage = "No downloadable media formats were found."
} else {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
}
self.loadTask = nil
withAnimation { self.isLoading = false }
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.loadTask = nil
withAnimation { self.isLoading = false }
}
}
}
}
}
+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
+167 -12
View File
@@ -40,22 +40,177 @@ 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 {
sparkleUpdater.updateChoiceReply?(.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, !notes.isEmpty {
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 {
sparkleUpdater.updateChoiceReply?(.install)
} label: {
Text("Download & Install")
}
.buttonStyle(.borderedProminent)
Button("Skip This Version") {
sparkleUpdater.updateChoiceReply?(.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")
}
}
}
}
}
.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 {
@@ -24,7 +24,7 @@ struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
LabeledContent("Global speed limit") {
HStack {
@@ -2,8 +2,13 @@ import SwiftUI
import AppKit
struct EngineSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@StateObject private var engineManager = MediaEngineManager.shared
@State private var version = "Checking..."
@State private var isCheckingForUpdates = false
@State private var updateCheckResult: String?
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
@@ -12,29 +17,97 @@ struct EngineSettingsPane: View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
if executableURL != nil {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Missing", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
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 {
LabeledContent("Updates") {
HStack(spacing: 8) {
Button {
checkMediaEngineUpdates()
} label: {
Text("Check for Updates")
}
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
if isCheckingForUpdates {
ProgressView().controlSize(.small)
Text("Checking...")
.foregroundStyle(.secondary)
.font(.subheadline)
} else if let result = updateCheckResult {
if result == "Up to date" || result == "Updated successfully" {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(result)
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(result)
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
}
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
LabeledContent("Browser Cookies") {
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
}
}
.labelsHidden()
.frame(maxWidth: 200)
}
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState)
} 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 +116,65 @@ struct EngineSettingsPane: View {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
private func checkMediaEngineUpdates() {
Task {
isCheckingForUpdates = true
updateCheckResult = nil
// Brief visual feedback delay
try? await Task.sleep(nanoseconds: 800_000_000)
do {
let wasDownloading = isDownloadingMediaEngines
try await engineManager.ensureInstalled()
if wasDownloading || isDownloadingMediaEngines {
updateCheckResult = "Updated successfully"
} else {
updateCheckResult = "Up to date"
}
} catch {
updateCheckResult = "Update failed: \(error.localizedDescription)"
}
isCheckingForUpdates = false
try? await Task.sleep(nanoseconds: 4_000_000_000)
if !isCheckingForUpdates {
withAnimation {
updateCheckResult = nil
}
}
}
}
private var isDownloadingMediaEngines: Bool {
if case .downloading = engineManager.ytDlpState { return true }
if case .downloading = engineManager.ffmpegState { return true }
return false
}
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState) -> some View {
LabeledContent(title) {
switch state {
case .notInstalled:
Label("Missing", systemImage: "xmark.circle.fill")
.foregroundStyle(.orange)
case .downloading(let progress):
HStack(spacing: 6) {
ProgressView(value: progress)
.frame(width: 60)
Text("\(Int(progress * 100))%")
.monospacedDigit()
}
case .installed(let version):
Label("v\(version)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Label("Failed", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.help(error)
}
}
}
}
@@ -0,0 +1,148 @@
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 {
if let htmlString = String(data: downloadData.data, encoding: .utf8) {
self.updater?.releaseNotes = self.stripHTML(htmlString)
}
}
}
private func stripHTML(_ string: String) -> String {
guard let data = string.data(using: .utf8) else { return string }
if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) {
return attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
}
return string
}
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,8 +3,6 @@ import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
@@ -25,39 +23,27 @@ struct IntegrationSettingsPane: View {
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
VStack(alignment: .leading, spacing: 16) {
Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.")
.foregroundStyle(.secondary)
Button {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
} label: {
Label("Install on Firefox", systemImage: "arrow.down.app")
.font(.headline)
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange
.controlSize(.large)
}
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)
.padding(.vertical, 8)
}
Section("Diagnostics") {
@@ -70,18 +56,8 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.orange)
}
}
LabeledContent("Copied folder") {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not copied", systemImage: "folder.badge.questionmark")
.foregroundStyle(.secondary)
}
}
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
@@ -89,99 +65,5 @@ struct IntegrationSettingsPane: View {
}
}
.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)
}
}
}
@@ -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 {
+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
}
+4 -3
View File
@@ -8,14 +8,15 @@
<item>
<title>Version 0.5.7</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.5.7</sparkle:releaseNotesLink>
<pubDate>Sat, 06 Jun 2026 11:36:06 +0000</pubDate>
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.5.7/Firelink-0.5.7-mac-arm64.dmg"
sparkle:version="0.5.7"
sparkle:version="22"
sparkle:shortVersionString="0.5.7"
length="7855846"
length="8871979"
type="application/octet-stream"
sparkle:edSignature="WQPg1Pv0syLjVehJSn/39gmYxAPwAeHdUgJQxCXnXG4wKpqKWt9F9/UqeMilcxK4Dba0S0cn3GIk1KZJ7vR8Cw==" />
sparkle:edSignature="Y4QEJqGw6IeXL5JmaRqt5U22Bmc8JD02+oLRIFoKgOJO4v6p6+AmBP9VoM3yp/MAzj+gJMWiI/Gf4aPx4x3dAg==" />
</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])