mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(security): address v0.7.0 security audit vulnerabilities
- fix(media): use temp config file for yt-dlp credentials - fix(rpc): exclude rpcSecret and rpcPort from serialization - fix(ssrf): validate private hosts for all fetches - fix(uri): add rate limiting to firelink scheme - fix(auth): implement HMAC-SHA256 request signing for LocalExtensionServer - fix(fs): prevent path traversal during file deletion - fix(fs): apply strict 0o600 permissions to aria2.conf - fix(crypto): remove insecure UUID fallback for pairing token - fix(http): strip CRLF characters from custom headers - fix(network): use POSIX socket to eliminate port-finding race window - fix(ui): limit text input lengths in Add Downloads view - fix(concurrency): implement thread-safe temp directory cleanup - feat(ui): modernize sidebar and download table components
This commit is contained in:
@@ -425,6 +425,9 @@ struct AddDownloadsView: View {
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
.onChange(of: headerText) { _, newValue in
|
||||
if newValue.count > 65536 { headerText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -432,6 +435,9 @@ struct AddDownloadsView: View {
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.onChange(of: cookieText) { _, newValue in
|
||||
if newValue.count > 65536 { cookieText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -442,6 +448,9 @@ struct AddDownloadsView: View {
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
.onChange(of: mirrorText) { _, newValue in
|
||||
if newValue.count > 65536 { mirrorText = String(newValue.prefix(65536)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@@ -709,6 +718,7 @@ struct AddDownloadsView: View {
|
||||
case .replace:
|
||||
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
|
||||
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
|
||||
guard URL(fileURLWithPath: path).standardized.path.hasPrefix(destURL.standardized.path) else { continue }
|
||||
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
|
||||
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
|
||||
} else if FileManager.default.fileExists(atPath: path) {
|
||||
|
||||
@@ -298,13 +298,15 @@ final class AppSettings: ObservableObject {
|
||||
}
|
||||
|
||||
if granted {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
if needsPrimer {
|
||||
showKeychainPrimer = true
|
||||
extensionPairingToken = ""
|
||||
} else {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
extensionPairingToken = ""
|
||||
|
||||
@@ -10,18 +10,43 @@ final class Aria2DownloadEngine: Sendable {
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> UInt16 {
|
||||
var port: UInt16 = 6800
|
||||
let parameters = NWParameters.tcp
|
||||
for p in 6800...6900 {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
|
||||
if let listener = try? NWListener(using: parameters) {
|
||||
listener.cancel()
|
||||
port = UInt16(p)
|
||||
break
|
||||
static func findFreePort() -> (UInt16, Int32)? {
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
addr.sin_port = 0
|
||||
|
||||
let sock = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard sock >= 0 else { return nil }
|
||||
|
||||
var addrPtr = addr
|
||||
let bindResult = withUnsafePointer(to: &addrPtr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
bind(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
return port
|
||||
|
||||
guard bindResult == 0 else {
|
||||
close(sock)
|
||||
return nil
|
||||
}
|
||||
|
||||
var boundAddr = sockaddr_in()
|
||||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getsockname(sock, $0, &len)
|
||||
}
|
||||
}
|
||||
|
||||
guard getsocknameResult == 0 else {
|
||||
close(sock)
|
||||
return nil
|
||||
}
|
||||
|
||||
let port = UInt16(bigEndian: boundAddr.sin_port)
|
||||
return (port, sock)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
@@ -120,13 +145,36 @@ final class Aria2DownloadEngine: Sendable {
|
||||
var lastError: Error?
|
||||
|
||||
for _ in 1...5 {
|
||||
let rpcPort = Int(Self.findFreePort())
|
||||
guard let (rpcPortVal, portSocket) = Self.findFreePort() else {
|
||||
lastError = EngineError.launchFailed("Could not find free port")
|
||||
continue
|
||||
}
|
||||
let rpcPort = Int(rpcPortVal)
|
||||
let rpcSecret = UUID().uuidString
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
|
||||
|
||||
final class CleanupState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didCleanup = false
|
||||
func cleanup(tempDir: URL) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if !didCleanup {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
didCleanup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupState = CleanupState()
|
||||
let cleanupTempDir: @Sendable () -> Void = {
|
||||
cleanupState.cleanup(tempDir: tempDir)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
close(portSocket)
|
||||
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
@@ -135,7 +183,9 @@ final class Aria2DownloadEngine: Sendable {
|
||||
do {
|
||||
let confContent = "rpc-secret=\(rpcSecret)\n"
|
||||
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
|
||||
} catch {
|
||||
close(portSocket)
|
||||
lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
@@ -152,6 +202,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
confURL: confURL
|
||||
)
|
||||
} catch {
|
||||
close(portSocket)
|
||||
lastError = error
|
||||
break
|
||||
}
|
||||
@@ -187,7 +238,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
cleanupTempDir()
|
||||
completionMonitor.cancel()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
@@ -210,6 +261,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
close(portSocket)
|
||||
try process.run()
|
||||
if let input = inputFileContent(for: item).data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(input)
|
||||
@@ -223,7 +275,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
if didThrow {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
cleanupTempDir()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -232,7 +284,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
if !process.isRunning {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
|
||||
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
cleanupTempDir()
|
||||
continue
|
||||
}
|
||||
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
|
||||
@@ -253,7 +305,7 @@ final class Aria2DownloadEngine: Sendable {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
cleanupTempDir()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ final class DownloadController: ObservableObject {
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.5 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
@@ -603,7 +603,7 @@ final class DownloadController: ObservableObject {
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.5 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
|
||||
@@ -54,7 +54,7 @@ enum DownloadMetadataFetcher {
|
||||
return pending
|
||||
}
|
||||
|
||||
if isAutoFetch, let host = url.host {
|
||||
if let host = url.host {
|
||||
let isPrivate = await Task.detached {
|
||||
isPrivateHost(host)
|
||||
}.value
|
||||
|
||||
@@ -363,6 +363,12 @@ struct DownloadTable: View {
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
openWindow(id: "add-downloads")
|
||||
} label: {
|
||||
Label("Add Download", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ struct FirelinkApp: App {
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
@State private var lastURLSchemeInvocation: Date = .distantPast
|
||||
@State private var urlSchemeInvocationCount: Int = 0
|
||||
|
||||
// Server must be retained to keep listening
|
||||
private let extensionServer: LocalExtensionServer?
|
||||
@@ -38,11 +40,20 @@ struct FirelinkApp: App {
|
||||
updateChecker.checkAutomaticallyIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastURLSchemeInvocation) > 5 {
|
||||
urlSchemeInvocationCount = 0
|
||||
}
|
||||
guard urlSchemeInvocationCount < 3 else { return }
|
||||
urlSchemeInvocationCount += 1
|
||||
lastURLSchemeInvocation = now
|
||||
|
||||
if url.scheme == "firelink" {
|
||||
if url.host == "add",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let link = queryItems.first(where: { $0.name == "url" })?.value {
|
||||
let link = queryItems.first(where: { $0.name == "url" })?.value,
|
||||
link.count < 65536 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = link
|
||||
controller.pendingReferer = nil
|
||||
@@ -52,6 +63,7 @@ struct FirelinkApp: App {
|
||||
return
|
||||
}
|
||||
|
||||
guard url.absoluteString.count < 65536 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
|
||||
@@ -2,13 +2,13 @@ import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
@@ -156,8 +156,25 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let expectedToken = currentPairingToken
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
token == expectedToken else {
|
||||
let bodyString = String(data: request.body, encoding: .utf8) ?? ""
|
||||
guard let signatureHex = request.header(named: "x-firelink-signature"),
|
||||
let timestampStr = request.header(named: "x-firelink-timestamp"),
|
||||
let timestamp = TimeInterval(timestampStr) else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
guard abs(now - timestamp) < 60000 else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
let keyData = expectedToken.data(using: .utf8) ?? Data()
|
||||
let key = SymmetricKey(data: keyData)
|
||||
let messageData = (timestampStr + bodyString).data(using: .utf8) ?? Data()
|
||||
let hmac = HMAC<SHA256>.authenticationCode(for: messageData, using: key)
|
||||
let expectedSignature = hmac.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
guard signatureHex.lowercased() == expectedSignature else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
@@ -180,6 +197,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
struct Payload: Decodable {
|
||||
let urls: [String]
|
||||
let referer: String?
|
||||
let silent: Bool?
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -201,7 +219,8 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
if self.settings.askWhereToSaveEachFile {
|
||||
let shouldAsk = self.settings.askWhereToSaveEachFile || payload.silent != true
|
||||
if shouldAsk {
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
|
||||
@@ -44,9 +44,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
var arguments = [
|
||||
"--newline",
|
||||
"--ffmpeg-location", ffmpegURL.path,
|
||||
"--force-ipv4",
|
||||
"--live-from-start",
|
||||
"--extractor-args", "youtube:player_client=ios,web",
|
||||
"--no-check-formats",
|
||||
"--fragment-retries", "10",
|
||||
"--retry-sleep", "0",
|
||||
"--skip-unavailable-fragments",
|
||||
"--extractor-args", "youtube:player_client=tv,web",
|
||||
"--extractor-args", "youtube:skip=webpage",
|
||||
"--compat-options", "no-youtube-unavailable-videos",
|
||||
"-o", item.destinationPath
|
||||
]
|
||||
@@ -64,7 +67,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
MediaExtractionEngine.appendCommonArguments(
|
||||
let tempConfigDir = MediaExtractionEngine.appendCommonArguments(
|
||||
to: &arguments,
|
||||
cookieSource: cookieSource,
|
||||
credentials: item.credentials,
|
||||
@@ -100,19 +103,25 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
messageUpdate: messageUpdate
|
||||
)
|
||||
|
||||
let readGroup = DispatchGroup()
|
||||
|
||||
readGroup.enter()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
|
||||
readGroup.enter()
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
@@ -122,18 +131,27 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
}
|
||||
|
||||
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))))
|
||||
if let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
readGroup.notify(queue: .global()) {
|
||||
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))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var didRun = false
|
||||
defer {
|
||||
if !didRun, let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
}
|
||||
try process.run()
|
||||
didRun = true
|
||||
messageUpdate("Fetching media data...")
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
errorPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
@@ -28,6 +28,20 @@ final class MediaEngineManager: ObservableObject {
|
||||
|
||||
private init() {
|
||||
checkLocalInstallation()
|
||||
Task.detached { [weak self] in
|
||||
await self?.prewarmYtDlp()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func prewarmYtDlp() async {
|
||||
guard let path = await MainActor.run(body: { binaryPath(for: .ytDlp) }) else { return }
|
||||
let process = Process()
|
||||
process.executableURL = path
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = nil
|
||||
process.standardError = nil
|
||||
try? process.run()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
|
||||
@@ -47,7 +47,19 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
enum MediaExtractionEngine {
|
||||
private static let metadataTimeoutSeconds: UInt64 = 75
|
||||
private final class CacheEntry {
|
||||
let metadata: MediaMetadata
|
||||
let options: [CleanFormatOption]
|
||||
let date: Date
|
||||
init(metadata: MediaMetadata, options: [CleanFormatOption], date: Date) {
|
||||
self.metadata = metadata
|
||||
self.options = options
|
||||
self.date = date
|
||||
}
|
||||
}
|
||||
nonisolated(unsafe) private static let metadataCache = NSCache<NSURL, CacheEntry>()
|
||||
|
||||
private static let metadataTimeoutSeconds: UInt64 = 30
|
||||
|
||||
enum ExtractionError: Error, LocalizedError {
|
||||
case processFailed(String)
|
||||
@@ -72,6 +84,9 @@ enum MediaExtractionEngine {
|
||||
transferOptions: DownloadTransferOptions,
|
||||
proxyConfiguration: DownloadProxyConfiguration
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
|
||||
return (cached.metadata, cached.options)
|
||||
}
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
@@ -83,16 +98,27 @@ enum MediaExtractionEngine {
|
||||
"--no-warnings",
|
||||
"--ignore-no-formats-error",
|
||||
"--no-playlist",
|
||||
"--force-ipv4",
|
||||
"--extractor-args", "youtube:player_client=ios,web",
|
||||
"--no-check-formats",
|
||||
"--extractor-args", "youtube:player_client=tv,web",
|
||||
"--extractor-args", "youtube:skip=webpage",
|
||||
"--compat-options", "no-youtube-unavailable-videos"
|
||||
]
|
||||
|
||||
if let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg),
|
||||
FileManager.default.isExecutableFile(atPath: ffmpegURL.path) {
|
||||
args.append(contentsOf: ["--ffmpeg-location", ffmpegURL.path])
|
||||
}
|
||||
|
||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||
args.append(contentsOf: ["--proxy", proxyURI])
|
||||
}
|
||||
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
let tempConfigDir = appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
defer {
|
||||
if let tempConfigDir {
|
||||
try? FileManager.default.removeItem(at: tempConfigDir)
|
||||
}
|
||||
}
|
||||
args.append(url.absoluteString)
|
||||
|
||||
let data = try await YTDLPMetadataProcess(
|
||||
@@ -107,6 +133,7 @@ enum MediaExtractionEngine {
|
||||
do {
|
||||
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
|
||||
let options = extractOptions(from: metadata)
|
||||
metadataCache.setObject(CacheEntry(metadata: metadata, options: options, date: Date()), forKey: url as NSURL)
|
||||
return (metadata, options)
|
||||
} catch {
|
||||
throw ExtractionError.parsingFailed(error)
|
||||
@@ -118,7 +145,7 @@ enum MediaExtractionEngine {
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) {
|
||||
) -> URL? {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
||||
}
|
||||
@@ -134,9 +161,18 @@ enum MediaExtractionEngine {
|
||||
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
|
||||
}
|
||||
|
||||
var tempConfigDir: URL?
|
||||
if let credentials, !credentials.isEmpty {
|
||||
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
|
||||
let configContent = "--username \"\(credentials.username)\"\n--password \"\(credentials.password)\"\n"
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-yt-dlp-\(UUID().uuidString)")
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
let fileURL = tempDir.appendingPathComponent("yt-dlp.conf")
|
||||
try? configContent.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
||||
args.append(contentsOf: ["--config-locations", fileURL.path])
|
||||
tempConfigDir = tempDir
|
||||
}
|
||||
return tempConfigDir
|
||||
}
|
||||
|
||||
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
|
||||
@@ -416,19 +452,25 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = nil
|
||||
|
||||
let readGroup = DispatchGroup()
|
||||
|
||||
readGroup.enter()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
readGroup.enter()
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readGroup.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
@@ -439,25 +481,24 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
||||
readGroup.notify(queue: .global()) {
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,9 +516,15 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func terminate() {
|
||||
lock.withLock {
|
||||
if let process, process.isRunning {
|
||||
process.terminate()
|
||||
let p = lock.withLock { self.process }
|
||||
guard let p, p.isRunning else { return }
|
||||
|
||||
p.terminate()
|
||||
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if p.isRunning {
|
||||
kill(p.processIdentifier, SIGKILL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +93,9 @@ struct DownloadRequestHeader: Codable, Equatable, Sendable {
|
||||
var value: String
|
||||
|
||||
var normalized: DownloadRequestHeader {
|
||||
DownloadRequestHeader(
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
||||
let cleanValue = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
||||
return DownloadRequestHeader(name: cleanName, value: cleanValue)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
@@ -191,6 +190,14 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var mediaFormatSelector: String?
|
||||
var isAudioOnlyMedia: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, url, fileName, category, destinationDirectory, connectionsPerServer
|
||||
case credentials, checksum, requestHeaders, cookieHeader, mirrorURLs, speedLimitKiBPerSecond
|
||||
case status, progress, speedText, etaText, connectionCount, sizeBytes, bytesText, message
|
||||
case createdAt, lastTryAt, autoResumeOnLaunch, queueID
|
||||
case mediaFormatSelector, isAudioOnlyMedia
|
||||
}
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ struct IntegrationSettingsPane: View {
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
if status == errSecSuccess {
|
||||
settings.extensionPairingToken = Data(bytes).base64EncodedString()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -158,8 +158,9 @@ struct SidebarView: View {
|
||||
.tag(SidebarSelection.downloads(.category(category)))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func queueRow(for queue: DownloadQueue) -> some View {
|
||||
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
|
||||
let row = Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
|
||||
.badge(controller.queueCount(for: queue.id))
|
||||
.tag(SidebarSelection.queue(queue.id))
|
||||
.onDrop(
|
||||
@@ -170,17 +171,20 @@ struct SidebarView: View {
|
||||
controller: controller
|
||||
)
|
||||
)
|
||||
.contextMenu {
|
||||
if !queue.isMain {
|
||||
Button("Rename") {
|
||||
queueBeingRenamed = queue
|
||||
queueName = queue.name
|
||||
}
|
||||
Button("Delete", role: .destructive) {
|
||||
queueBeingRemoved = queue
|
||||
}
|
||||
|
||||
if queue.isMain {
|
||||
row
|
||||
} else {
|
||||
row.contextMenu {
|
||||
Button("Rename") {
|
||||
queueBeingRenamed = queue
|
||||
queueName = queue.name
|
||||
}
|
||||
Button("Delete", role: .destructive) {
|
||||
queueBeingRemoved = queue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user