From 9f8e01839f145f19452d5bb3d7d2d81c9a458654 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:57:07 +0330 Subject: [PATCH] fix: package bundled media engines --- .github/workflows/release.yml | 14 +- .gitignore | 1 + Extensions/Firefox | 2 +- README.md | 2 +- Scripts/create_app_bundle.sh | 10 + Sources/Firelink/DownloadController.swift | 22 +- Sources/Firelink/LocalExtensionServer.swift | 12 +- Sources/Firelink/MediaDownloadEngine.swift | 4 +- Sources/Firelink/MediaEngineManager.swift | 17 +- Sources/Firelink/MediaExtractionEngine.swift | 2 +- .../Firelink/MediaInspectorInlineView.swift | 268 ------------------ .../Settings/EngineSettingsPane.swift | 12 +- 12 files changed, 43 insertions(+), 323 deletions(-) delete mode 100644 Sources/Firelink/MediaInspectorInlineView.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84e5bb4..1177f5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 300f520..7cc03ef 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ SparklePrivateKey* private-key* private_key* yt-dlp +ffmpeg diff --git a/Extensions/Firefox b/Extensions/Firefox index c458090..2b24d69 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit c45809038e8b2d887f1d4a5cdc76966d3ef807b6 +Subproject commit 2b24d69977461263ce28ca12d21e21e9409f379a diff --git a/README.md b/README.md index 54223d0..5ce8615 100644 --- a/README.md +++ b/README.md @@ -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 Twitter—backed 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. diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 01ff8f2..092149f 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -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" diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 49c10d6..870ad86 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -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 } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 5dec809..27f493d 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -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"]) } diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index b711e5f..82e8427 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -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) diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 6da029a..f718143 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -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 { diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index fe1b74e..f8ac557 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -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." } } } diff --git a/Sources/Firelink/MediaInspectorInlineView.swift b/Sources/Firelink/MediaInspectorInlineView.swift deleted file mode 100644 index 15abb4a..0000000 --- a/Sources/Firelink/MediaInspectorInlineView.swift +++ /dev/null @@ -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? - - 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 } - } - } - } - } -} diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index 131dd0c..326887f 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -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)