feat: implement gatekeeper architecture for on-demand media engine binaries

This commit is contained in:
nimbold
2026-06-07 09:57:27 +03:30
parent bf645898a9
commit 0799b08000
3 changed files with 254 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import Foundation
enum BinaryDownloaderError: Error {
case invalidResponse
case httpError(statusCode: Int)
case downloadFailed(Error?)
case moveFailed(Error)
case permissionFailed(Error)
case unzipFailed
}
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let url: URL
private let destination: URL
private let onProgress: @Sendable (Double) -> Void
private let session: URLSession
private let continuation: CheckedContinuation<Void, Error>
init(url: URL, destination: URL, onProgress: @escaping @Sendable (Double) -> Void, continuation: CheckedContinuation<Void, Error>) {
self.url = url
self.destination = destination
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, onProgress: @escaping @Sendable (Double) -> Void) async throws {
try await withCheckedThrowingContinuation { continuation in
let downloader = BinaryDownloader(url: url, destination: destination, 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 {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: location, to: destination)
// Make executable
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path)
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))
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import Foundation
struct AddonConfig: Codable, Equatable, Sendable {
let version: String
let macArm64: URL?
let macX64: URL?
enum CodingKeys: String, CodingKey {
case version
case macArm64 = "mac-arm64"
case macX64 = "mac-x64"
}
/// 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
}
}
struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig?
let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
}
+142
View File
@@ -0,0 +1,142 @@
import Foundation
import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case downloading(progress: Double)
case installed(version: String)
case failed(error: String)
}
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"
case .ffmpeg: return "ffmpeg"
}
}
}
@MainActor
final class MediaEngineManager: ObservableObject {
static let shared = MediaEngineManager()
@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 init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName)
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
let path = binaryPath(for: addon)
if FileManager.default.fileExists(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"))
}
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
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() async throws {
// Simple helper for the "Extract" button flow
// Fetches config and installs if not installed or out of date
let config = try await fetchLatestConfig()
// Use task group to download both if needed
await withTaskGroup(of: Void.self) { group in
if case .notInstalled = ytDlpState {
group.addTask { await self.install(addon: .ytDlp, from: config) }
} else if case let .installed(version) = ytDlpState, let configVersion = config.ytDlp?.version, version != configVersion {
group.addTask { await self.install(addon: .ytDlp, from: config) }
}
if case .notInstalled = ffmpegState {
group.addTask { await self.install(addon: .ffmpeg, from: config) }
} else if case let .installed(version) = ffmpegState, let configVersion = config.ffmpeg?.version, version != configVersion {
group.addTask { await self.install(addon: .ffmpeg, from: config) }
}
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async {
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)"))
return
}
guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
return
}
do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon)
try await BinaryDownloader.download(from: downloadURL, to: destination) { 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))
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
case .ffmpeg: ffmpegState = state
}
}
}