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:
NimBold
2026-06-11 03:13:47 +03:30
parent facab68237
commit ae3476293e
14 changed files with 276 additions and 83 deletions
+10
View File
@@ -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) {
+7 -5
View File
@@ -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 = ""
+67 -15
View File
@@ -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()
}
}
+2 -2
View File
@@ -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
+6
View File
@@ -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")
}
}
}
+13 -1
View File
@@ -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
+24 -5
View File
@@ -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)
+30 -12
View File
@@ -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()
+14
View File
@@ -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? {
+74 -27
View File
@@ -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)
}
}
}
+11 -4
View File
@@ -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()
}
}
)
+14 -10
View File
@@ -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
}
}
}
}
}