fix: package bundled media engines

This commit is contained in:
nimbold
2026-06-08 14:57:07 +03:30
parent c1d97f31fb
commit 9f8e01839f
12 changed files with 43 additions and 323 deletions
+8 -6
View File
@@ -63,16 +63,18 @@ jobs:
- name: Fetch media engines
run: |
mkdir -p Sources/Firelink
# Download latest yt-dlp for macOS
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
chmod +x Sources/Firelink/yt-dlp
# Download ffmpeg (Martin Riedl's static macOS ARM64 build)
curl -sL "https://ffmpeg.martin-riedl.de/download/macos/arm64/1779741465_9_1_1/ffmpeg.zip" -o ffmpeg.zip
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
unzip -q -o ffmpeg.zip -d Sources/Firelink
rm ffmpeg.zip
chmod +x Sources/Firelink/ffmpeg
test -x Sources/Firelink/yt-dlp
test -x Sources/Firelink/ffmpeg
- name: Build app bundle
env:
@@ -106,7 +108,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
+1
View File
@@ -23,3 +23,4 @@ SparklePrivateKey*
private-key*
private_key*
yt-dlp
ffmpeg
+1 -1
View File
@@ -44,7 +44,7 @@
## ✨ Features
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitterbacked securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
+10
View File
@@ -24,6 +24,16 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
for media_engine in yt-dlp ffmpeg; do
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
if [[ -x "$media_engine_path" ]]; then
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
chmod +x "$RESOURCES_DIR/$media_engine"
else
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
fi
done
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
+1 -21
View File
@@ -208,26 +208,6 @@ final class DownloadController: ObservableObject {
}
}
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
var item = item
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
item.category = FileClassifier.category(forFileName: item.fileName)
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
saveDownloads()
if startImmediately {
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
}
}
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
@@ -534,7 +514,7 @@ final class DownloadController: ObservableObject {
do {
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Checking media add-ons..."
$0.message = "Checking bundled media engines..."
}
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
+4 -8
View File
@@ -8,15 +8,11 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
// IMPORTANT(Backward Compatibility):
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
// without maintaining backward compatibility for older extensions.
// If you need to update the API (e.g., changing the payload structure), add the new
// token here and handle both versions in `processRequest`.
// Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here
// when future store releases need a non-breaking local API transition.
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
+2 -2
View File
@@ -30,10 +30,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
+9 -8
View File
@@ -3,7 +3,6 @@ import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case downloading(progress: Double)
case installed(version: String)
case failed(error: String)
}
@@ -32,9 +31,11 @@ final class MediaEngineManager: ObservableObject {
}
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
for bundle in [Bundle.main, Bundle.module] {
if let bundled = bundle.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
}
return nil
}
@@ -55,18 +56,18 @@ final class MediaEngineManager: ObservableObject {
switch state(for: addon) {
case .installed:
return false
case .downloading, .notInstalled, .failed:
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."])
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 {
+1 -1
View File
@@ -51,7 +51,7 @@ enum MediaExtractionEngine {
case .processFailed(let msg): return "Extraction failed: \(msg)"
case .invalidOutput: return "Invalid output from media engine."
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source."
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
}
}
}
@@ -1,268 +0,0 @@
import SwiftUI
struct MediaInspectorInlineView: View {
let url: URL
let cookieSource: BrowserCookieSource
let credentials: DownloadCredentials?
let transferOptions: DownloadTransferOptions
let onCancel: () -> Void
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
@ObservedObject private var engineManager = MediaEngineManager.shared
@State private var isLoading = true
@State private var statusText = "Checking Media Engine..."
@State private var metadata: MediaMetadata?
@State private var options: [CleanFormatOption] = []
@State private var errorMessage: String?
@State private var loadTask: Task<Void, Never>?
enum MediaType: String, CaseIterable, Identifiable {
case video = "Video"
case audio = "Audio"
var id: String { rawValue }
}
@State private var selectedType: MediaType = .video
@State private var selectedVideoQuality: String = "Best"
@State private var selectedVideoFormat: String = "MP4"
@State private var selectedAudioFormat: String = "MP3"
var body: some View {
HStack(spacing: 16) {
if isLoading {
ProgressView()
.controlSize(.regular)
let ytState = engineManager.ytDlpState
let ffState = engineManager.ffmpegState
if case let .downloading(p) = ytState, p > 0 {
Text("Downloading yt-dlp: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else if case let .downloading(p) = ffState, p > 0 {
Text("Downloading ffmpeg: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
Text(statusText)
.foregroundStyle(.secondary)
cookieStatusLabel
}
}
Spacer()
} else if let errorMessage {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(errorMessage)
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer()
Button("Retry") {
loadMetadata()
}
} else if let metadata {
if let thumbnail = metadata.thumbnail {
AsyncImage(url: thumbnail) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 80, height: 50)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(metadata.title ?? "Unknown Title")
.font(.subheadline.weight(.medium))
.lineLimit(1)
HStack(spacing: 12) {
Picker("Type", selection: $selectedType) {
ForEach(availableTypes) { type in
Text(type.rawValue).tag(type)
}
}
.labelsHidden()
.frame(width: 80)
if selectedType == .video {
Picker("Quality", selection: $selectedVideoQuality) {
ForEach(availableVideoQualities, id: \.self) { q in
Text(q).tag(q)
}
}
.labelsHidden()
.frame(width: 90)
Picker("Format", selection: $selectedVideoFormat) {
ForEach(availableVideoFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 80)
} else {
Picker("Format", selection: $selectedAudioFormat) {
ForEach(availableAudioFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 90)
}
}
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 16)
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
Button("Extract") {
if let selected = resolveSelectedOption() {
onDownload(selected, metadata)
}
}
.buttonStyle(.borderedProminent)
.disabled(resolveSelectedOption() == nil)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.quaternary.opacity(0.35))
)
.onAppear {
loadMetadata()
}
.onDisappear {
loadTask?.cancel()
loadTask = nil
}
.onChange(of: url) { _, _ in loadMetadata() }
.onChange(of: cookieSource) { _, _ in loadMetadata() }
.onChange(of: credentials) { _, _ in loadMetadata() }
.onChange(of: transferOptions) { _, _ in loadMetadata() }
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
.onChange(of: options) { _, _ in ensureValidSelection() }
}
private var availableTypes: [MediaType] {
var types: [MediaType] = []
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
return types
}
private var availableVideoQualities: [String] {
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
return NSOrderedSet(array: qualities).array as? [String] ?? []
}
private var availableVideoFormats: [String] {
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private var availableAudioFormats: [String] {
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private func ensureValidSelection() {
if !availableTypes.contains(selectedType), let first = availableTypes.first {
selectedType = first
}
if selectedType == .video {
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
selectedVideoQuality = first
}
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
selectedVideoFormat = first
}
} else {
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
selectedAudioFormat = first
}
}
}
private func resolveSelectedOption() -> CleanFormatOption? {
if selectedType == .video {
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
} else {
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
}
}
@ViewBuilder
private var cookieStatusLabel: some View {
if let browserName = cookieSource.ytDlpBrowserName {
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else {
Label("Browser cookies off", systemImage: "circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func loadMetadata() {
loadTask?.cancel()
isLoading = true
errorMessage = nil
metadata = nil
options = []
loadTask = Task {
do {
await MainActor.run { statusText = "Checking yt-dlp..." }
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
guard !Task.isCancelled else { return }
await MainActor.run { statusText = "Fetching Metadata..." }
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
for: url,
cookieSource: cookieSource,
credentials: credentials,
transferOptions: transferOptions
)
guard !Task.isCancelled else { return }
await MainActor.run {
if fetchedOptions.isEmpty {
self.errorMessage = "No downloadable media formats were found."
} else {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
}
self.loadTask = nil
withAnimation { self.isLoading = false }
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.loadTask = nil
withAnimation { self.isLoading = false }
}
}
}
}
}
@@ -19,7 +19,7 @@ struct EngineSettingsPane: View {
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Binary Path") {
Text(executableURL?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
@@ -42,9 +42,9 @@ struct EngineSettingsPane: View {
Section {
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) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
@@ -59,7 +59,7 @@ struct EngineSettingsPane: View {
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Powers video and audio extraction from supported sites.")
if settings.mediaCookieSource != .none {
Text(settings.mediaCookieSource.statusDetail)
}
@@ -80,8 +80,6 @@ struct EngineSettingsPane: View {
case .notInstalled:
Text("Missing")
.foregroundStyle(.red)
case .downloading:
Text("Unavailable")
case .installed(let version):
Text(version)
.foregroundStyle(.secondary)
@@ -91,7 +89,7 @@ struct EngineSettingsPane: View {
.foregroundStyle(.red)
.help(error)
}
Text(path?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)