diff --git a/Sources/Firelink/BinaryDownloader.swift b/Sources/Firelink/BinaryDownloader.swift new file mode 100644 index 0000000..457ddd6 --- /dev/null +++ b/Sources/Firelink/BinaryDownloader.swift @@ -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 + + init(url: URL, destination: URL, onProgress: @escaping @Sendable (Double) -> Void, continuation: CheckedContinuation) { + 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)) + } + } +} diff --git a/Sources/Firelink/GatekeeperConfig.swift b/Sources/Firelink/GatekeeperConfig.swift new file mode 100644 index 0000000..cff59a1 --- /dev/null +++ b/Sources/Firelink/GatekeeperConfig.swift @@ -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 + } +} diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift new file mode 100644 index 0000000..639e965 --- /dev/null +++ b/Sources/Firelink/MediaEngineManager.swift @@ -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 + } + } +}