fix(media): stabilize bundled download engines

This commit is contained in:
NimBold
2026-06-11 13:15:19 +03:30
parent 7b3d620efa
commit e165366820
16 changed files with 787 additions and 133 deletions
+28 -14
View File
@@ -57,7 +57,7 @@ final class Aria2DownloadEngine: Sendable {
var errorDescription: String? {
switch self {
case .executableNotFound:
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
"The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines."
case .launchFailed(let details):
"Could not start aria2c: \(details)"
case .unsupportedProxy(let details):
@@ -73,20 +73,30 @@ final class Aria2DownloadEngine: Sendable {
}
static func findExecutable() -> URL? {
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
bundledResource(named: "aria2c", executable: true)
}
static func certificateBundleURL() -> URL? {
bundledResource(named: "aria2-cacert.pem", executable: false)
}
private static func bundledResource(named name: String, executable: Bool) -> URL? {
func validResource(in bundle: Bundle) -> URL? {
guard let url = bundle.resourceURL?.appendingPathComponent(name) else { return nil }
let isValid = executable
? FileManager.default.isExecutableFile(atPath: url.path)
: FileManager.default.fileExists(atPath: url.path)
return isValid ? url : nil
}
if let bundled = validResource(in: .main) {
return bundled
}
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c",
"/opt/local/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
#if SWIFT_PACKAGE
return validResource(in: .module)
#endif
}
return nil
@@ -303,7 +313,7 @@ final class Aria2DownloadEngine: Sendable {
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel()
if process.isRunning {
process.terminate()
ProcessTreeTerminator.terminate(process)
}
cleanupTempDir()
}
@@ -323,7 +333,7 @@ final class Aria2DownloadEngine: Sendable {
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionGate.complete(.success(()))
if process.isRunning {
process.terminate()
ProcessTreeTerminator.terminate(process)
}
return
}
@@ -449,6 +459,10 @@ final class Aria2DownloadEngine: Sendable {
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
}
if let certificateBundleURL = Self.certificateBundleURL() {
arguments.append("--ca-certificate=\(certificateBundleURL.path)")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
+1 -1
View File
@@ -472,7 +472,7 @@ final class DownloadController: ObservableObject {
}
guard hasRunnableQueuedDownload else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
engineMessage = "The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines."
return
}
+1
View File
@@ -38,6 +38,7 @@ struct FirelinkApp: App {
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.task {
updateChecker.checkAutomaticallyIfNeeded()
_ = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
}
.onOpenURL { url in
let now = Date()
+47 -10
View File
@@ -26,7 +26,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<URL, Error>) -> Void
) async throws -> Handle {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
@@ -45,11 +45,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--no-check-formats",
"--socket-timeout", "20",
"--retries", "3",
"--extractor-retries", "3",
"--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
]
@@ -71,7 +72,8 @@ final class MediaDownloadEngine: @unchecked Sendable {
to: &arguments,
cookieSource: cookieSource,
credentials: item.credentials,
transferOptions: item.transferOptions
transferOptions: item.transferOptions,
preferredDenoURL: ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
)
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
@@ -82,7 +84,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
}
appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer)
appendParallelDownloadArguments(
to: &arguments,
item: item,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
)
arguments.append(item.url.absoluteString)
process.arguments = arguments
@@ -134,7 +140,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
if let tempConfigDir {
try? FileManager.default.removeItem(at: tempConfigDir)
}
readGroup.notify(queue: .global()) {
let complete: @Sendable () -> Void = {
if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else {
@@ -142,6 +148,12 @@ final class MediaDownloadEngine: @unchecked Sendable {
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
}
}
readGroup.notify(queue: .global(), execute: complete)
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
complete()
}
}
var didRun = false
@@ -158,7 +170,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
return Handle(cancel: {
if process.isRunning {
process.terminate()
ProcessTreeTerminator.terminate(process)
}
})
}
@@ -212,12 +224,37 @@ final class MediaDownloadEngine: @unchecked Sendable {
return message
}
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
let connections = min(max(connectionsPerServer, 1), 16)
private func appendParallelDownloadArguments(
to arguments: inout [String],
item: DownloadItem,
speedLimitKiBPerSecond: Int?
) {
let connections = min(max(item.connectionsPerServer, 1), 16)
guard connections > 1 else { return }
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
// Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout
let largeDirectDownloadThreshold: Int64 = 128 * 1024 * 1024
guard item.isAudioOnlyMedia != true,
(item.sizeBytes ?? 0) >= largeDirectDownloadThreshold,
speedLimitKiBPerSecond == nil,
let aria2URL = Aria2DownloadEngine.findExecutable() else {
return
}
let aria2Connections = min(connections, 8)
let certificateArgument = Aria2DownloadEngine.certificateBundleURL().map {
" --ca-certificate=\(Self.shellQuoted($0.path))"
} ?? ""
arguments.append(contentsOf: [
"--downloader", aria2URL.path,
"--downloader", "dash,m3u8:native",
"--downloader-args",
"aria2c:-x\(aria2Connections) -s\(aria2Connections) -k1M --file-allocation=none --summary-interval=1\(certificateArgument)"
])
}
private static func shellQuoted(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
}
+161 -11
View File
@@ -1,5 +1,6 @@
import Foundation
import Combine
import Darwin
enum AddonState: Equatable, Sendable {
case notInstalled
@@ -25,23 +26,34 @@ final class MediaEngineManager: ObservableObject {
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private var ytDlpPreparationTask: Task<URL?, Never>?
private init() {
checkLocalInstallation()
Task.detached { [weak self] in
await self?.prewarmYtDlp()
Task { [weak self] in
_ = await self?.preparedBinaryPath(for: .ytDlp)
}
}
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 preparedBinaryPath(for addon: AddonType) async -> URL? {
guard addon == .ytDlp else { return binaryPath(for: addon) }
if let ytDlpPreparationTask {
return await ytDlpPreparationTask.value
}
guard let bundledURL = binaryPath(for: .ytDlp) else { return nil }
let runtimeVersion = bundledRuntimeVersion(near: bundledURL)
let task = Task<URL?, Never>.detached(priority: .userInitiated) {
let executableURL = Self.installStableYtDlpRuntime(
bundledExecutableURL: bundledURL,
version: runtimeVersion
) ?? bundledURL
Self.prewarm(executableURL)
return executableURL
}
ytDlpPreparationTask = task
return await task.value
}
func binaryPath(for addon: AddonType) -> URL? {
@@ -62,6 +74,144 @@ final class MediaEngineManager: ObservableObject {
return nil
}
private func bundledRuntimeVersion(near executableURL: URL) -> String {
let versionURL = executableURL.deletingLastPathComponent()
.appendingPathComponent("yt-dlp-version.txt")
if let version = try? String(contentsOf: versionURL, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines),
!version.isEmpty {
return version
}
return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
}
nonisolated private static func installStableYtDlpRuntime(
bundledExecutableURL: URL,
version: String
) -> URL? {
let fileManager = FileManager.default
let bundledDirectory = bundledExecutableURL.deletingLastPathComponent()
let bundledInternalURL = bundledDirectory.appendingPathComponent("_internal", isDirectory: true)
let bundledDenoURL = bundledDirectory.appendingPathComponent("deno")
let hasBundledDeno = fileManager.isExecutableFile(atPath: bundledDenoURL.path)
guard fileManager.fileExists(atPath: bundledInternalURL.path) else {
return bundledExecutableURL
}
guard let applicationSupportURL = fileManager.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return bundledExecutableURL
}
let safeVersion = version.replacingOccurrences(
of: #"[^A-Za-z0-9._-]"#,
with: "_",
options: .regularExpression
)
let enginesURL = applicationSupportURL
.appendingPathComponent("Firelink", isDirectory: true)
.appendingPathComponent("MediaEngines", isDirectory: true)
.appendingPathComponent("yt-dlp", isDirectory: true)
let runtimeURL = enginesURL.appendingPathComponent(safeVersion, isDirectory: true)
let executableURL = runtimeURL.appendingPathComponent("yt-dlp")
let internalURL = runtimeURL.appendingPathComponent("_internal", isDirectory: true)
let denoURL = runtimeURL.appendingPathComponent("deno")
if fileManager.isExecutableFile(atPath: executableURL.path),
fileManager.fileExists(atPath: internalURL.path),
!hasBundledDeno || fileManager.isExecutableFile(atPath: denoURL.path) {
return executableURL
}
let temporaryURL = enginesURL.appendingPathComponent(
".install-\(UUID().uuidString)",
isDirectory: true
)
do {
try fileManager.createDirectory(at: enginesURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true)
try fileManager.copyItem(
at: bundledExecutableURL,
to: temporaryURL.appendingPathComponent("yt-dlp")
)
try fileManager.copyItem(
at: bundledInternalURL,
to: temporaryURL.appendingPathComponent("_internal", isDirectory: true)
)
if hasBundledDeno {
try fileManager.copyItem(
at: bundledDenoURL,
to: temporaryURL.appendingPathComponent("deno")
)
}
removeTransportAttributesRecursively(at: temporaryURL)
if fileManager.fileExists(atPath: runtimeURL.path) {
try fileManager.removeItem(at: runtimeURL)
}
try fileManager.moveItem(at: temporaryURL, to: runtimeURL)
return executableURL
} catch {
try? fileManager.removeItem(at: temporaryURL)
return bundledExecutableURL
}
}
nonisolated private static func removeTransportAttributesRecursively(at rootURL: URL) {
removeTransportAttributes(at: rootURL)
guard let enumerator = FileManager.default.enumerator(
at: rootURL,
includingPropertiesForKeys: nil,
options: [],
errorHandler: nil
) else {
return
}
for case let itemURL as URL in enumerator {
removeTransportAttributes(at: itemURL)
}
}
nonisolated private static func removeTransportAttributes(at url: URL) {
url.withUnsafeFileSystemRepresentation { path in
guard let path else { return }
removexattr(path, "com.apple.quarantine", 0)
removexattr(path, "com.apple.provenance", 0)
}
}
nonisolated private static func prewarm(_ executableURL: URL) {
runVersionCommand(executableURL)
let denoURL = executableURL.deletingLastPathComponent().appendingPathComponent("deno")
if FileManager.default.isExecutableFile(atPath: denoURL.path) {
runVersionCommand(denoURL)
}
}
nonisolated private static func runVersionCommand(_ executableURL: URL) {
let process = Process()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = nil
process.standardError = nil
process.standardInput = nil
do {
try process.run()
process.waitUntilExit()
} catch {
return
}
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
if binaryPath(for: addon) != nil {
+58 -21
View File
@@ -87,20 +87,20 @@ enum MediaExtractionEngine {
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),
guard let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp),
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw ExtractionError.processFailed("yt-dlp binary not found.")
}
let ytDlpPath = ytDlpURL.path
var args = [
"-J",
"--no-warnings",
"--ignore-no-formats-error",
"--no-playlist",
"-J",
"--no-warnings",
"--no-playlist",
"--no-check-formats",
"--extractor-args", "youtube:player_client=tv,web",
"--extractor-args", "youtube:skip=webpage",
"--socket-timeout", "20",
"--retries", "3",
"--extractor-retries", "3",
"--compat-options", "no-youtube-unavailable-videos"
]
@@ -113,7 +113,13 @@ enum MediaExtractionEngine {
args.append(contentsOf: ["--proxy", proxyURI])
}
let tempConfigDir = appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
let tempConfigDir = appendCommonArguments(
to: &args,
cookieSource: cookieSource,
credentials: credentials,
transferOptions: transferOptions,
preferredDenoURL: cachedDenoURL(near: ytDlpURL)
)
defer {
if let tempConfigDir {
try? FileManager.default.removeItem(at: tempConfigDir)
@@ -144,13 +150,14 @@ enum MediaExtractionEngine {
to args: inout [String],
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
transferOptions: DownloadTransferOptions,
preferredDenoURL: URL? = nil
) -> URL? {
if let browserName = cookieSource.ytDlpBrowserName {
args.append(contentsOf: ["--cookies-from-browser", browserName])
}
appendJavaScriptRuntimeArguments(to: &args)
appendJavaScriptRuntimeArguments(to: &args, preferredDenoURL: preferredDenoURL)
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
args.append(contentsOf: ["--add-header", header.headerLine])
@@ -175,9 +182,14 @@ enum MediaExtractionEngine {
return tempConfigDir
}
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
private static func appendJavaScriptRuntimeArguments(
to args: inout [String],
preferredDenoURL: URL?
) {
var runtimes: [String] = []
if let denoPath = executablePath(named: "deno", candidates: [
if let denoPath = executablePath(at: preferredDenoURL) ??
bundledExecutablePath(named: "deno") ??
executablePath(named: "deno", candidates: [
"/opt/homebrew/bin/deno",
"/usr/local/bin/deno"
]) {
@@ -197,6 +209,36 @@ enum MediaExtractionEngine {
}
}
private static func cachedDenoURL(near ytDlpURL: URL) -> URL? {
let denoURL = ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
return FileManager.default.isExecutableFile(atPath: denoURL.path) ? denoURL : nil
}
private static func executablePath(at url: URL?) -> String? {
guard let url, FileManager.default.isExecutableFile(atPath: url.path) else {
return nil
}
return url.path
}
private static func bundledExecutablePath(named name: String) -> String? {
if let bundled = Bundle.main.url(forResource: name, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled.path
}
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
#if SWIFT_PACKAGE
if let bundled = Bundle.module.url(forResource: name, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled.path
}
#endif
}
return nil
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
var safeCandidates = candidates
safeCandidates.append(contentsOf: [
@@ -501,6 +543,9 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
do {
try process.run()
if Task.isCancelled {
self.terminate()
}
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
} catch {
@@ -515,14 +560,6 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
private func 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)
}
}
ProcessTreeTerminator.terminate(p)
}
}
@@ -0,0 +1,53 @@
import Foundation
import Darwin
enum ProcessTreeTerminator {
static func terminate(_ process: Process, forceAfter delay: TimeInterval = 0.5) {
let rootPID = process.processIdentifier
guard rootPID > 0 else { return }
let processIDs = descendants(of: rootPID) + [rootPID]
signal(processIDs.reversed(), with: SIGTERM)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) {
signal(processIDs.reversed(), with: SIGKILL)
}
}
private static func descendants(of rootPID: pid_t) -> [pid_t] {
var result: [pid_t] = []
var pending = directChildren(of: rootPID)
while let processID = pending.popLast() {
result.append(processID)
pending.append(contentsOf: directChildren(of: processID))
}
return result
}
private static func directChildren(of processID: pid_t) -> [pid_t] {
var capacity = 32
while capacity <= 4096 {
var processIDs = [pid_t](repeating: 0, count: capacity)
let count = processIDs.withUnsafeMutableBytes { buffer in
proc_listchildpids(processID, buffer.baseAddress, Int32(buffer.count))
}
guard count > 0 else { return [] }
if count < capacity {
return Array(processIDs.prefix(Int(count))).filter { $0 > 0 }
}
capacity *= 2
}
return []
}
private static func signal<S: Sequence>(_ processIDs: S, with signal: Int32) where S.Element == pid_t {
for processID in processIDs where processID > 0 {
kill(processID, signal)
}
}
}
@@ -34,7 +34,7 @@ struct EngineSettingsPane: View {
Text("Core Downloader (Aria2)")
} footer: {
if executableURL == nil {
Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.")
Text("The bundled aria2 runtime is missing. Reinstall Firelink or rebuild its media engines.")
.foregroundStyle(.red)
} else {
Text("Handles core HTTP/FTP and BitTorrent downloads.")