diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 704e8ff..d78e700 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -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) { diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index fba94df..9e3810a 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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 = "" diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 9ed1766..34fa6b1 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -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.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.size)) } } - return port + + guard bindResult == 0 else { + close(sock) + return nil + } + + var boundAddr = sockaddr_in() + var len = socklen_t(MemoryLayout.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() } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index e5f3773..3fc6d47 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -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 diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift index 2adc4f1..6521639 100644 --- a/Sources/Firelink/DownloadMetadataFetcher.swift +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -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 diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 3ed7aab..9980652 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -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") + } } } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index ceb9365..ae4c45a 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -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 diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 9b581b9..b5e443c 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -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.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) diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index 96051b8..bcb304b 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -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() diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 82b825e..68b8351 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -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? { diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index 799e4ae..1c07d92 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -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() + + 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) } } } diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index b6440f8..41386c5 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -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 : "-" } diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index 9c017a5..f1d70e8 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -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() + } } ) diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index 5323c6f..420de40 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -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 } } + } } }