mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 00:48:05 +00:00
264 lines
9.1 KiB
Swift
264 lines
9.1 KiB
Swift
import Foundation
|
|
import Combine
|
|
import Darwin
|
|
|
|
enum AddonState: Equatable, Sendable {
|
|
case notInstalled
|
|
case installed(version: String)
|
|
case failed(error: String)
|
|
}
|
|
|
|
enum AddonType: String, CaseIterable, Sendable {
|
|
case ytDlp = "yt-dlp"
|
|
case ffmpeg
|
|
case deno
|
|
|
|
var binaryName: String {
|
|
switch self {
|
|
case .ytDlp: return "yt-dlp"
|
|
case .ffmpeg: return "ffmpeg"
|
|
case .deno: return "deno"
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MediaEngineManager: ObservableObject {
|
|
static let shared = MediaEngineManager()
|
|
|
|
@Published var ytDlpState: AddonState = .notInstalled
|
|
@Published var ffmpegState: AddonState = .notInstalled
|
|
@Published var denoState: AddonState = .notInstalled
|
|
private var ytDlpPreparationTask: Task<URL?, Never>?
|
|
|
|
private init() {
|
|
checkLocalInstallation()
|
|
Task { [weak self] in
|
|
_ = await self?.preparedBinaryPath(for: .ytDlp)
|
|
}
|
|
}
|
|
|
|
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? {
|
|
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
|
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
|
return bundled
|
|
}
|
|
|
|
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
|
|
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
|
#if SWIFT_PACKAGE
|
|
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
|
|
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
|
return bundled
|
|
}
|
|
#endif
|
|
}
|
|
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 {
|
|
setState(for: addon, to: .installed(version: "Bundled"))
|
|
} else {
|
|
setState(for: addon, to: .notInstalled)
|
|
}
|
|
}
|
|
}
|
|
|
|
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
|
|
checkLocalInstallation()
|
|
let missingAddons = requiredAddons.filter { addon in
|
|
switch state(for: addon) {
|
|
case .installed:
|
|
return false
|
|
case .notInstalled, .failed:
|
|
return true
|
|
}
|
|
}
|
|
|
|
guard !missingAddons.isEmpty else { return }
|
|
|
|
for missing in missingAddons {
|
|
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
|
}
|
|
|
|
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
|
}
|
|
|
|
private func state(for addon: AddonType) -> AddonState {
|
|
switch addon {
|
|
case .ytDlp: return ytDlpState
|
|
case .ffmpeg: return ffmpegState
|
|
case .deno: return denoState
|
|
}
|
|
}
|
|
|
|
private func setState(for addon: AddonType, to state: AddonState) {
|
|
switch addon {
|
|
case .ytDlp: ytDlpState = state
|
|
case .ffmpeg: ffmpegState = state
|
|
case .deno: denoState = state
|
|
}
|
|
}
|
|
}
|