feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle

- Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources.
- Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift.
- Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners.
- Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window.
- Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling.
This commit is contained in:
nimbold
2026-06-08 14:12:40 +03:30
parent b58df6b660
commit 02abef1443
9 changed files with 49 additions and 490 deletions
+3 -1
View File
@@ -21,7 +21,9 @@ let package = Package(
],
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
.process("Assets.xcassets"),
.copy("yt-dlp"),
.copy("ffmpeg")
]
)
]
+1 -1
View File
@@ -50,7 +50,7 @@ struct AddDownloadsView: View {
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
-192
View File
@@ -1,192 +0,0 @@
import Foundation
import CryptoKit
enum BinaryDownloaderError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case downloadFailed(Error?)
case moveFailed(Error)
case permissionFailed(Error)
case unzipFailed
case unsupportedDownloadURL
case missingChecksum
case checksumMismatch
var errorDescription: String? {
switch self {
case .invalidResponse:
"The add-on server returned an invalid response."
case .httpError(let statusCode):
"The add-on download failed with HTTP \(statusCode)."
case .downloadFailed(let error):
error?.localizedDescription ?? "The add-on download failed."
case .moveFailed(let error):
error.localizedDescription
case .permissionFailed(let error):
"Could not mark the add-on executable: \(error.localizedDescription)"
case .unzipFailed:
"Could not extract the downloaded add-on archive."
case .unsupportedDownloadURL:
"The add-on URL must be HTTP or HTTPS."
case .missingChecksum:
"The add-on configuration is missing a SHA-256 checksum."
case .checksumMismatch:
"The downloaded add-on did not match the expected SHA-256 checksum."
}
}
}
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let url: URL
private let destination: URL
private let expectedSHA256: String?
private let onProgress: @Sendable (Double) -> Void
private let session: URLSession
private let continuation: CheckedContinuation<Void, Error>
init(
url: URL,
destination: URL,
expectedSHA256: String?,
onProgress: @escaping @Sendable (Double) -> Void,
continuation: CheckedContinuation<Void, Error>
) {
self.url = url
self.destination = destination
self.expectedSHA256 = expectedSHA256
self.onProgress = onProgress
self.continuation = continuation
let config = URLSessionConfiguration.ephemeral
self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below
super.init()
}
static func download(
from url: URL,
to destination: URL,
expectedSHA256: String? = nil,
onProgress: @escaping @Sendable (Double) -> Void
) async throws {
try await withCheckedThrowingContinuation { continuation in
let downloader = BinaryDownloader(
url: url,
destination: destination,
expectedSHA256: expectedSHA256,
onProgress: onProgress,
continuation: continuation
)
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
let task = session.downloadTask(with: url)
task.resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
defer { session.finishTasksAndInvalidate() }
guard let response = downloadTask.response as? HTTPURLResponse else {
continuation.resume(throwing: BinaryDownloaderError.invalidResponse)
return
}
guard (200...299).contains(response.statusCode) else {
continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode))
return
}
do {
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
throw BinaryDownloaderError.unsupportedDownloadURL
}
let isZip = url.pathExtension.lowercased() == "zip"
let stagingURL = destination
.deletingLastPathComponent()
.appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged")
var cleanupURLs: [URL] = [stagingURL]
defer {
for cleanupURL in cleanupURLs {
try? FileManager.default.removeItem(at: cleanupURL)
}
}
if isZip {
let tempZip = location.appendingPathExtension("zip")
try FileManager.default.moveItem(at: location, to: tempZip)
cleanupURLs.append(tempZip)
let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
cleanupURLs.append(extractDir)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-q", tempZip.path, "-d", extractDir.path]
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw BinaryDownloaderError.unzipFailed
}
let expectedName = destination.lastPathComponent
var foundBinary: URL?
if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) {
for case let fileURL as URL in enumerator {
if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" {
foundBinary = fileURL
break
}
}
}
guard let foundBinary = foundBinary else {
throw BinaryDownloaderError.unzipFailed
}
try FileManager.default.moveItem(at: foundBinary, to: stagingURL)
} else {
try FileManager.default.moveItem(at: location, to: stagingURL)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path)
if let expectedSHA256 {
let actualSHA256 = try Self.sha256Hex(for: stagingURL)
guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else {
throw BinaryDownloaderError.checksumMismatch
}
}
try installStagedBinary(stagingURL, at: destination)
continuation.resume()
} catch {
continuation.resume(throwing: BinaryDownloaderError.moveFailed(error))
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
onProgress(progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
session.finishTasksAndInvalidate()
continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error))
}
}
private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws {
if FileManager.default.fileExists(atPath: destination.path) {
_ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL)
} else {
try FileManager.default.moveItem(at: stagedURL, to: destination)
}
}
private static func sha256Hex(for url: URL) throws -> String {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ struct FirelinkApp: App {
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
Window("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
-48
View File
@@ -1,48 +0,0 @@
import Foundation
struct AddonConfig: Codable, Equatable, Sendable {
let version: String
let macArm64: URL?
let macX64: URL?
let macArm64SHA256: String?
let macX64SHA256: String?
enum CodingKeys: String, CodingKey {
case version
case macArm64 = "mac-arm64"
case macX64 = "mac-x64"
case macArm64SHA256 = "mac-arm64-sha256"
case macX64SHA256 = "mac-x64-sha256"
}
/// Returns the appropriate download URL for the current system architecture
var currentArchURL: URL? {
#if arch(arm64)
return macArm64
#elseif arch(x86_64)
return macX64
#else
return nil
#endif
}
var currentArchSHA256: String? {
#if arch(arm64)
return macArm64SHA256
#elseif arch(x86_64)
return macX64SHA256
#else
return nil
#endif
}
}
struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig?
let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
}
+2 -2
View File
@@ -29,10 +29,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
}
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
}
+13 -141
View File
@@ -12,10 +12,6 @@ enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var defaultsKey: String {
return "Firelink.AddonVersion.\(self.rawValue)"
}
var binaryName: String {
switch self {
case .ytDlp: return "yt-dlp"
@@ -31,67 +27,28 @@ final class MediaEngineManager: ObservableObject {
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
private var addonsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
}
private var installTasks: [AddonType: Task<Void, Error>] = [:]
private init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName)
func binaryPath(for addon: AddonType) -> URL? {
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
return nil
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
guard installTasks[addon] == nil else { continue }
let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(atPath: path.path) {
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
setState(for: addon, to: .installed(version: version))
} else {
setState(for: addon, to: .installed(version: "Unknown"))
}
if binaryPath(for: addon) != nil {
setState(for: addon, to: .installed(version: "Bundled"))
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
}
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask {
try await task.value
}
}
try await group.waitForAll()
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
@@ -104,43 +61,12 @@ final class MediaEngineManager: ObservableObject {
}
guard !missingAddons.isEmpty else { return }
try await ensureInstalled(addons: missingAddons)
}
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
let state: AddonState
let configVersion: String?
switch addon {
case .ytDlp:
state = ytDlpState
configVersion = config.ytDlp?.version
case .ffmpeg:
state = ffmpegState
configVersion = config.ffmpeg?.version
for missing in missingAddons {
setState(for: missing, to: .failed(error: "Bundled executable missing"))
}
switch state {
case .notInstalled, .failed:
return true
case .downloading:
return true
case .installed(let version):
guard let configVersion else { return false }
return version != configVersion
}
}
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
if let task = installTasks[addon] {
return task
}
let task = Task { @MainActor in
defer { self.installTasks[addon] = nil }
try await self.install(addon: addon, from: config)
}
installTasks[addon] = task
return task
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle."])
}
private func state(for addon: AddonType) -> AddonState {
@@ -150,60 +76,6 @@ final class MediaEngineManager: ObservableObject {
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
setState(for: addon, to: .downloading(progress: 0))
let addonConfig: AddonConfig? = {
switch addon {
case .ytDlp: return config.ytDlp
case .ffmpeg: return config.ffmpeg
}
}()
guard let addonConfig = addonConfig else {
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
throw URLError(.badURL)
}
guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
throw URLError(.badURL)
}
guard downloadURL.scheme?.lowercased() == "https" else {
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
throw URLError(.badURL)
}
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSHA256.isEmpty else {
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
throw BinaryDownloaderError.missingChecksum
}
do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon)
try await BinaryDownloader.download(
from: downloadURL,
to: destination,
expectedSHA256: expectedSHA256
) { progress in
Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress))
}
}
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
setState(for: addon, to: .installed(version: addonConfig.version))
} catch {
setState(for: addon, to: .failed(error: error.localizedDescription))
throw error
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
+3 -2
View File
@@ -62,10 +62,11 @@ enum MediaExtractionEngine {
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) async throws -> (MediaMetadata, [CleanFormatOption]) {
let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path
guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else {
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(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", "--extractor-args", "youtube:player_client=ios,tv"]
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
@@ -6,9 +6,6 @@ struct EngineSettingsPane: View {
@StateObject private var engineManager = MediaEngineManager.shared
@State private var version = "Checking..."
@State private var isCheckingForUpdates = false
@State private var updateCheckResult: String?
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
@@ -16,16 +13,6 @@ struct EngineSettingsPane: View {
var body: some View {
Form {
Section {
LabeledContent("Status") {
if executableURL != nil {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Missing", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
@@ -54,39 +41,9 @@ struct EngineSettingsPane: View {
}
Section {
LabeledContent("Updates") {
HStack(spacing: 8) {
Button {
checkMediaEngineUpdates()
} label: {
Text("Check for Updates")
}
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
if isCheckingForUpdates {
ProgressView().controlSize(.small)
Text("Checking...")
.foregroundStyle(.secondary)
.font(.subheadline)
} else if let result = updateCheckResult {
if result == "Up to date" || result == "Updated successfully" {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(result)
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(result)
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
}
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
LabeledContent("Browser Cookies") {
Picker("", selection: $settings.mediaCookieSource) {
@@ -97,8 +54,6 @@ struct EngineSettingsPane: View {
.labelsHidden()
.frame(maxWidth: 200)
}
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState)
} header: {
Text("Media Extractors")
} footer: {
@@ -117,63 +72,32 @@ struct EngineSettingsPane: View {
}
}
private func checkMediaEngineUpdates() {
Task {
isCheckingForUpdates = true
updateCheckResult = nil
// Brief visual feedback delay
try? await Task.sleep(nanoseconds: 800_000_000)
do {
let wasDownloading = isDownloadingMediaEngines
try await engineManager.ensureInstalled()
if wasDownloading || isDownloadingMediaEngines {
updateCheckResult = "Updated successfully"
} else {
updateCheckResult = "Up to date"
}
} catch {
updateCheckResult = "Update failed: \(error.localizedDescription)"
}
isCheckingForUpdates = false
try? await Task.sleep(nanoseconds: 4_000_000_000)
if !isCheckingForUpdates {
withAnimation {
updateCheckResult = nil
}
}
}
}
private var isDownloadingMediaEngines: Bool {
if case .downloading = engineManager.ytDlpState { return true }
if case .downloading = engineManager.ffmpegState { return true }
return false
}
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState) -> some View {
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
LabeledContent(title) {
switch state {
case .notInstalled:
Label("Missing", systemImage: "xmark.circle.fill")
.foregroundStyle(.orange)
case .downloading(let progress):
HStack(spacing: 6) {
ProgressView(value: progress)
.frame(width: 60)
Text("\(Int(progress * 100))%")
.monospacedDigit()
VStack(alignment: .trailing) {
switch state {
case .notInstalled:
Text("Missing")
.foregroundStyle(.red)
case .downloading:
Text("Unavailable")
case .installed(let version):
Text(version)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Text("Error")
.foregroundStyle(.red)
.help(error)
}
case .installed(let version):
Label("v\(version)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Label("Failed", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.help(error)
Text(path?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}
}