fix: harden media download flow

This commit is contained in:
nimbold
2026-06-08 01:06:00 +03:30
parent 2e5b4ae7c3
commit 4e48f1e42c
36 changed files with 576 additions and 530 deletions
+6 -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/"
-23
View File
@@ -1,23 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Remove doubleClickableCell block
content = re.sub(r' private func doubleClickableCell.*? }\n\n', '', content, flags=re.DOTALL)
# Remove doubleClickableCell wrappers from TableColumn
content = re.sub(r'doubleClickableCell\(for: item\) \{\n\s*(.*?)\n\s*\}', r'\1', content, flags=re.DOTALL)
# Add simultaneousGesture to Table
table_end = content.find(' .environment(\\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)')
gesture = ''' .simultaneousGesture(TapGesture(count: 2).onEnded {
if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) {
performPrimaryAction(for: item)
}
})
'''
content = content[:table_end] + gesture + content[table_end:]
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
-62
View File
@@ -1,62 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Fix size to use bytesText if sizeBytes is nil or 0
old_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}'''
new_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}'''
content = content.replace(old_size, new_size)
# Add allowsHitTesting(false) to text to fix single click interception
old_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)'''
new_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)'''
content = content.replace(old_text, new_text)
# Change performPrimaryAction to use id
old_action = 'openWindow(value: item.id)'
new_action = 'openWindow(id: "download-properties", value: item.id)'
content = content.replace(old_action, new_action)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", for: UUID.self)',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self)'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
-86
View File
@@ -1,86 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in\n if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in\n if let downloadID {'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
# Fix openWindow in performPrimaryAction
table_content = table_content.replace(
'openWindow(id: "download-properties", value: item.id.uuidString)',
'openWindow(id: "download-properties", value: item.id)'
)
# Fix openWindow in rowContextMenu
table_content = table_content.replace(
'openWindow(value: target.id)',
'openWindow(id: "download-properties", value: target.id)'
)
# Remove simultaneousGesture from Table
import re
table_content = re.sub(
r'\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*if let id = selection\.first, let item = controller\.downloads\.first\(where: \{ \$0\.id == id \}\) \{\s*performPrimaryAction\(for: item\)\s*\}\s*\}\)',
'',
table_content
)
# Replace the File Name TableColumn to use doubleClickableCell
old_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
.draggable(item.id.uuidString)
}
}'''
new_column = ''' 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)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
table_content = table_content.replace(old_column, new_column)
# Add doubleClickableCell helper
helper = ''' private func performPrimaryAction(for item: DownloadItem) {'''
helper_new = ''' private func doubleClickableCell<Content: View>(for item: DownloadItem, @ViewBuilder content: () -> Content) -> some View {
content()
.contentShape(Rectangle())
.simultaneousGesture(TapGesture(count: 2).onEnded {
performPrimaryAction(for: item)
})
}
private func performPrimaryAction(for item: DownloadItem) {'''
table_content = table_content.replace(helper, helper_new)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-21
View File
@@ -1,21 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
old_group = ''' WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {'''
new_group = ''' WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in
if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {'''
app_content = app_content.replace(old_group, new_group)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
old_open1 = 'openWindow(id: "download-properties", value: item.id)'
new_open1 = 'openWindow(id: "download-properties", value: item.id.uuidString)'
table_content = table_content.replace(old_open1, new_open1)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-45
View File
@@ -1,45 +0,0 @@
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
old_column = ''' 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)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
new_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}'''
content = content.replace(old_column, new_column)
import re
# Remove doubleClickableCell function block
pattern = r'\s*private func doubleClickableCell<Content: View>\(for item: DownloadItem, @ViewBuilder content: \(\) -> Content\) -> some View \{\s*content\(\)\s*\.contentShape\(Rectangle\(\)\)\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*performPrimaryAction\(for: item\)\s*\}\)\s*\}'
content = re.sub(pattern, '', content)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
+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
+15 -2
View File
@@ -493,9 +493,16 @@ struct AddDownloadsView: View {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
if let first = urls.first, MediaDetector.isSupportedMedia(url: first) {
let mediaURL: URL? = {
guard urls.count == 1, let first = urls.first, MediaDetector.isSupportedMedia(url: first) else {
return nil
}
return first
}()
if let mediaURL {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
detectedMediaURL = first
detectedMediaURL = mediaURL
}
} else {
withAnimation {
@@ -522,6 +529,12 @@ struct AddDownloadsView: View {
saveLogin = false
}
guard mediaURL == nil else {
pendingDownloads = []
metadataTask = nil
return
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
+3 -3
View File
@@ -116,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() }
}
@@ -342,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)
}
+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 {
+3
View File
@@ -9,6 +9,7 @@ enum BinaryDownloaderError: LocalizedError {
case permissionFailed(Error)
case unzipFailed
case unsupportedDownloadURL
case missingChecksum
case checksumMismatch
var errorDescription: String? {
@@ -27,6 +28,8 @@ enum BinaryDownloaderError: LocalizedError {
"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."
}
+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 }
+121 -21
View File
@@ -61,7 +61,7 @@ final class DownloadController: ObservableObject {
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
@@ -69,7 +69,7 @@ final class DownloadController: ObservableObject {
self?.saveDownloads()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
@@ -535,15 +535,17 @@ final class DownloadController: ObservableObject {
$0.message = "Checking media add-ons..."
}
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..."
}
let handle = try await mediaEngine.start(
item: item,
item: liveItem,
cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
@@ -569,12 +571,20 @@ final class DownloadController: ObservableObject {
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: true)
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()
@@ -602,7 +612,7 @@ final class DownloadController: ObservableObject {
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: false)
self?.handleCompletion(item: item, result: result)
}
}
)
@@ -624,12 +634,20 @@ final class DownloadController: ObservableObject {
}
}
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>, isMedia: Bool) {
if isMedia {
activeMediaHandles[item.id] = nil
} else {
activeHandles[item.id] = nil
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:
@@ -641,7 +659,7 @@ final class DownloadController: ObservableObject {
$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
@@ -663,6 +681,45 @@ final class DownloadController: ObservableObject {
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])
@@ -747,6 +804,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 {
@@ -779,16 +844,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 {
@@ -897,7 +997,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 {
@@ -937,7 +1037,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
}
+2 -10
View File
@@ -156,14 +156,6 @@ struct DownloadTable: View {
}
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(id: "download-properties", value: item.id)
}
}
@ViewBuilder
private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -311,12 +303,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)
+7 -7
View File
@@ -4,7 +4,7 @@ import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _controller: SPUStandardUpdaterController?
var controller: SPUStandardUpdaterController { _controller! }
@Published var isChecking = false
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@@ -13,7 +13,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
super.init()
self._controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
}
func checkForUpdates() {
guard controller.updater.canCheckForUpdates else {
isChecking = false
@@ -41,7 +41,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
self.updateStatus = "You're up to date!"
}
}
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
DispatchQueue.main.async {
let nsError = error as NSError
@@ -49,7 +49,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
// SUNoUpdateError (1001), handled by updaterDidNotFindUpdate
return
}
self.isChecking = false
self.updateStatus = "Update check failed: \(error.localizedDescription)"
}
@@ -70,7 +70,7 @@ struct FirelinkApp: App {
@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?
@@ -82,7 +82,7 @@ struct FirelinkApp: App {
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
@@ -136,7 +136,7 @@ struct FirelinkApp: App {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
+3 -3
View File
@@ -6,7 +6,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
let macX64: URL?
let macArm64SHA256: String?
let macX64SHA256: String?
enum CodingKeys: String, CodingKey {
case version
case macArm64 = "mac-arm64"
@@ -14,7 +14,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
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)
@@ -40,7 +40,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig?
let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
+3 -3
View File
@@ -20,7 +20,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 +33,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
}
+3 -3
View File
@@ -12,13 +12,13 @@ enum MediaDetector {
"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,
// Ignore raw files that happen to be hosted on these domains, if any,
// though usually these domains serve web pages for media.
return true
}
+108 -20
View File
@@ -4,11 +4,11 @@ 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
@@ -16,7 +16,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
}
}
}
func start(
item: DownloadItem,
cookieSource: BrowserCookieSource,
@@ -24,34 +24,34 @@ final class MediaDownloadEngine: @unchecked Sendable {
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> 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"])
@@ -80,7 +80,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
arguments.append(item.url.absoluteString)
process.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
@@ -88,9 +88,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer()
let outputPathTracker = YTDLPOutputPathTracker()
let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler(
parser: parser,
outputPathTracker: outputPathTracker,
progress: progress,
messageUpdate: messageUpdate
)
@@ -109,24 +111,24 @@ final class MediaDownloadEngine: @unchecked Sendable {
outputHandler.handle(text)
}
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(()))
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()
@@ -134,6 +136,34 @@ final class MediaDownloadEngine: @unchecked Sendable {
})
}
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)"
@@ -164,15 +194,18 @@ final class MediaDownloadEngine: @unchecked Sendable {
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
}
@@ -180,6 +213,7 @@ final class YTDLPOutputHandler: @unchecked Sendable {
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")
@@ -215,6 +249,60 @@ final class YTDLPOutputHandler: @unchecked Sendable {
}
}
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()
@@ -227,14 +315,14 @@ final class YTDLPProgressParser: @unchecked Sendable {
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,
@@ -248,7 +336,7 @@ final class YTDLPProgressParser: @unchecked Sendable {
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,
@@ -259,7 +347,7 @@ final class YTDLPProgressParser: @unchecked Sendable {
}
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)
+28 -6
View File
@@ -38,6 +38,7 @@ final class MediaEngineManager: ObservableObject {
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()
@@ -49,6 +50,7 @@ final class MediaEngineManager: ObservableObject {
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) {
@@ -79,9 +81,10 @@ final class MediaEngineManager: ObservableObject {
let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) {
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask {
try await self.install(addon: addon, from: config)
try await task.value
}
}
@@ -93,9 +96,9 @@ final class MediaEngineManager: ObservableObject {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed, .downloading:
case .installed:
return false
case .notInstalled, .failed:
case .downloading, .notInstalled, .failed:
return true
}
}
@@ -120,13 +123,26 @@ final class MediaEngineManager: ObservableObject {
case .notInstalled, .failed:
return true
case .downloading:
return false
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
@@ -159,6 +175,12 @@ final class MediaEngineManager: ObservableObject {
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)
@@ -166,7 +188,7 @@ final class MediaEngineManager: ObservableObject {
try await BinaryDownloader.download(
from: downloadURL,
to: destination,
expectedSHA256: addonConfig.currentArchSHA256
expectedSHA256: expectedSHA256
) { progress in
Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress))
+124 -32
View File
@@ -20,7 +20,7 @@ struct MediaMetadata: Decodable, Sendable {
let thumbnail: URL?
let duration: Double?
let formats: [RawMediaFormat]?
var displayUploader: String? {
channel ?? uploader
}
@@ -34,6 +34,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let symbol: String
let outputExtension: String
let detail: String
let estimatedBytes: Int64?
}
enum MediaExtractionEngine {
@@ -44,7 +45,7 @@ enum MediaExtractionEngine {
case invalidOutput
case parsingFailed(Error)
case timedOut
var errorDescription: String? {
switch self {
case .processFailed(let msg): return "Extraction failed: \(msg)"
@@ -54,7 +55,7 @@ enum MediaExtractionEngine {
}
}
}
static func fetchMetadata(
for url: URL,
cookieSource: BrowserCookieSource,
@@ -151,9 +152,6 @@ enum MediaExtractionEngine {
var options: [CleanFormatOption] = []
let rawFormats = metadata.formats ?? []
let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 }
let maxHeight = heights.max() ?? 0
let standardResolutions = [
(2160, "4K"),
(1440, "1440p"),
@@ -164,7 +162,9 @@ enum MediaExtractionEngine {
]
let availableResolutions = standardResolutions.filter { resolution, _ in
maxHeight == 0 || maxHeight >= resolution - 100
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 = [
@@ -175,47 +175,139 @@ enum MediaExtractionEngine {
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: height == nil ? "Best available video" : "Up to \(qualityName)"
detail: optionDetail(
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes
))
}
}
options.append(CleanFormatOption(
name: "Audio MP3",
formatSelector: "bestaudio/best",
isAudioOnly: true,
symbol: "music.note",
outputExtension: "mp3",
detail: "Converted with ffmpeg"
))
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
))
}
options.append(CleanFormatOption(
name: "Audio M4A",
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "m4a",
detail: "Prefer native M4A"
))
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
))
}
options.append(CleanFormatOption(
name: "Audio Opus",
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "opus",
detail: "Efficient audio"
))
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 {
@@ -33,7 +33,7 @@ struct MediaInspectorInlineView: View {
if isLoading {
ProgressView()
.controlSize(.regular)
let ytState = engineManager.ytDlpState
let ffState = engineManager.ffmpegState
@@ -117,6 +117,13 @@ struct MediaInspectorInlineView: View {
.frame(width: 90)
}
}
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 16)
@@ -147,6 +154,10 @@ struct MediaInspectorInlineView: View {
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() }
}
@@ -216,6 +227,8 @@ struct MediaInspectorInlineView: View {
loadTask?.cancel()
isLoading = true
errorMessage = nil
metadata = nil
options = []
loadTask = Task {
do {
@@ -233,9 +246,13 @@ struct MediaInspectorInlineView: View {
guard !Task.isCancelled else { return }
await MainActor.run {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
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 }
}
+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
@@ -66,7 +66,7 @@ struct AboutSettingsPane: View {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(sparkleUpdater.isChecking)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
@@ -24,7 +24,7 @@ struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
LabeledContent("Global speed limit") {
HStack {
@@ -42,25 +42,25 @@ struct EngineSettingsPane: View {
.foregroundStyle(.secondary)
}
}
Section("Media Engine (yt-dlp & ffmpeg)") {
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState)
HStack(spacing: 12) {
Button("Check for Updates") {
Task {
isCheckingForUpdates = true
updateCheckResult = nil
try? await Task.sleep(nanoseconds: 800_000_000)
do {
try await engineManager.ensureInstalled()
updateCheckResult = "Up to date."
} catch {
updateCheckResult = "Update failed."
}
isCheckingForUpdates = false
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !isCheckingForUpdates {
@@ -69,7 +69,7 @@ struct EngineSettingsPane: View {
}
}
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
if isCheckingForUpdates {
ProgressView().controlSize(.small)
Text("Checking...")
@@ -80,10 +80,10 @@ struct EngineSettingsPane: View {
.foregroundStyle(.secondary)
.font(.subheadline)
}
Spacer()
}
Picker("Browser Cookies", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
@@ -110,13 +110,13 @@ struct EngineSettingsPane: View {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
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) {
@@ -23,12 +23,12 @@ struct IntegrationSettingsPane: View {
}
.padding(.vertical, 4)
}
Section("Installation") {
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)
@@ -57,7 +57,7 @@ struct IntegrationSettingsPane: View {
}
}
}
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)
@@ -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
}
-63
View File
@@ -1,63 +0,0 @@
import sys
from PIL import Image, ImageDraw
def generate_gradient_preview(src_path, dest_path):
img = Image.open(src_path).convert("RGBA")
width, height = img.size
pixels = img.load()
# Background color from an inner point
bg_color = pixels[100, 100]
# Define gradient colors
# Top color: slightly lighter/richer blue (e.g. #1E2541)
# Bottom color: darker navy (e.g. #0A0D1A)
top_color = (30, 37, 65, 255)
bottom_color = (10, 13, 26, 255)
# Create new image
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
# Interpolate background color for this row
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]
# distance from original background color
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
if dist < 15:
# purely background
new_pixels[x, y] = grad_color
elif dist < 60:
# anti-aliased edge, blend
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:
# purely logo
new_pixels[x, y] = p
# Apply rounded rectangle mask
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)
new_img.putalpha(mask)
# Resize to something reasonable for preview
preview = new_img.resize((512, 512), Image.Resampling.LANCZOS)
preview.save(dest_path)
print("Preview saved to", dest_path)
if __name__ == '__main__':
generate_gradient_preview(sys.argv[1], sys.argv[2])
+14 -14
View File
@@ -3,32 +3,32 @@ from PIL import Image, ImageDraw
def process_images(src_path):
img = Image.open(src_path).convert("RGBA")
# Crop the 28px black padding
img = img.crop((28, 28, 1226, 1226))
width, height = img.size
pixels = img.load()
# Lighter color (+1) at top, original color (0) at bottom
bg_color = pixels[100, 100]
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width):
p = pixels[x, y]
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
# Replace pure black corners or background with gradient
if p[0] < 15 and p[1] < 15 and p[2] < 15:
new_pixels[x, y] = grad_color
@@ -42,7 +42,7 @@ def process_images(src_path):
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)
@@ -53,17 +53,17 @@ def process_images(src_path):
# 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:
@@ -71,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])
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB