mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9af9edbfd4 | |||
| 9f8e01839f | |||
| c1d97f31fb | |||
| 02abef1443 |
@@ -60,6 +60,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: brew install aria2 dylibbundler
|
||||
|
||||
- name: Fetch media engines
|
||||
run: |
|
||||
mkdir -p Sources/Firelink
|
||||
|
||||
# Download latest yt-dlp for macOS
|
||||
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 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:
|
||||
MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
@@ -92,9 +108,11 @@ jobs:
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
|
||||
test -s release_notes.md
|
||||
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
|
||||
@@ -23,3 +23,4 @@ SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
ffmpeg
|
||||
|
||||
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.1] - 2026-06-08
|
||||
|
||||
### New Features
|
||||
- No new user-facing features in this patch release.
|
||||
|
||||
### Improvements
|
||||
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
|
||||
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
|
||||
|
||||
### Changes
|
||||
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
|
||||
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
|
||||
- Remove stale media add-on update language now that media engines are bundled with the app.
|
||||
- Update Firelink Companion to `1.0.8`.
|
||||
|
||||
### Fixes
|
||||
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
|
||||
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
|
||||
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
|
||||
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
|
||||
|
||||
## [0.6.0] - 2026-06-08
|
||||
|
||||
### New features
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: c45809038e...6951259752
+3
-1
@@ -21,7 +21,9 @@ let package = Package(
|
||||
],
|
||||
path: "Sources/Firelink",
|
||||
resources: [
|
||||
.process("Assets.xcassets")
|
||||
.process("Assets.xcassets"),
|
||||
.copy("yt-dlp"),
|
||||
.copy("ffmpeg")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,7 +50,7 @@ struct AddDownloadsView: View {
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
|
||||
.sheet(isPresented: $showingDuplicates) {
|
||||
DuplicateResolutionView(
|
||||
conflicts: $conflictingDownloads,
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
enum BinaryDownloaderError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(statusCode: Int)
|
||||
case downloadFailed(Error?)
|
||||
case moveFailed(Error)
|
||||
case permissionFailed(Error)
|
||||
case unzipFailed
|
||||
case unsupportedDownloadURL
|
||||
case missingChecksum
|
||||
case checksumMismatch
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
"The add-on server returned an invalid response."
|
||||
case .httpError(let statusCode):
|
||||
"The add-on download failed with HTTP \(statusCode)."
|
||||
case .downloadFailed(let error):
|
||||
error?.localizedDescription ?? "The add-on download failed."
|
||||
case .moveFailed(let error):
|
||||
error.localizedDescription
|
||||
case .permissionFailed(let error):
|
||||
"Could not mark the add-on executable: \(error.localizedDescription)"
|
||||
case .unzipFailed:
|
||||
"Could not extract the downloaded add-on archive."
|
||||
case .unsupportedDownloadURL:
|
||||
"The add-on URL must be HTTP or HTTPS."
|
||||
case .missingChecksum:
|
||||
"The add-on configuration is missing a SHA-256 checksum."
|
||||
case .checksumMismatch:
|
||||
"The downloaded add-on did not match the expected SHA-256 checksum."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
||||
private let url: URL
|
||||
private let destination: URL
|
||||
private let expectedSHA256: String?
|
||||
private let onProgress: @Sendable (Double) -> Void
|
||||
private let session: URLSession
|
||||
|
||||
private let continuation: CheckedContinuation<Void, Error>
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
destination: URL,
|
||||
expectedSHA256: String?,
|
||||
onProgress: @escaping @Sendable (Double) -> Void,
|
||||
continuation: CheckedContinuation<Void, Error>
|
||||
) {
|
||||
self.url = url
|
||||
self.destination = destination
|
||||
self.expectedSHA256 = expectedSHA256
|
||||
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,
|
||||
expectedSHA256: String? = nil,
|
||||
onProgress: @escaping @Sendable (Double) -> Void
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let downloader = BinaryDownloader(
|
||||
url: url,
|
||||
destination: destination,
|
||||
expectedSHA256: expectedSHA256,
|
||||
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 {
|
||||
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
|
||||
throw BinaryDownloaderError.unsupportedDownloadURL
|
||||
}
|
||||
|
||||
let isZip = url.pathExtension.lowercased() == "zip"
|
||||
let stagingURL = destination
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged")
|
||||
var cleanupURLs: [URL] = [stagingURL]
|
||||
defer {
|
||||
for cleanupURL in cleanupURLs {
|
||||
try? FileManager.default.removeItem(at: cleanupURL)
|
||||
}
|
||||
}
|
||||
|
||||
if isZip {
|
||||
let tempZip = location.appendingPathExtension("zip")
|
||||
try FileManager.default.moveItem(at: location, to: tempZip)
|
||||
cleanupURLs.append(tempZip)
|
||||
|
||||
let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
|
||||
cleanupURLs.append(extractDir)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
process.arguments = ["-q", tempZip.path, "-d", extractDir.path]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw BinaryDownloaderError.unzipFailed
|
||||
}
|
||||
|
||||
let expectedName = destination.lastPathComponent
|
||||
var foundBinary: URL?
|
||||
if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" {
|
||||
foundBinary = fileURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let foundBinary = foundBinary else {
|
||||
throw BinaryDownloaderError.unzipFailed
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: foundBinary, to: stagingURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: location, to: stagingURL)
|
||||
}
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path)
|
||||
if let expectedSHA256 {
|
||||
let actualSHA256 = try Self.sha256Hex(for: stagingURL)
|
||||
guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else {
|
||||
throw BinaryDownloaderError.checksumMismatch
|
||||
}
|
||||
}
|
||||
try installStagedBinary(stagingURL, at: destination)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
_ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: stagedURL, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
private static func sha256Hex(for url: URL) throws -> String {
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -126,7 +126,7 @@ struct FirelinkApp: App {
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
WindowGroup("Add Downloads", id: "add-downloads") {
|
||||
Window("Add Downloads", id: "add-downloads") {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct AddonConfig: Codable, Equatable, Sendable {
|
||||
let version: String
|
||||
let macArm64: URL?
|
||||
let macX64: URL?
|
||||
let macArm64SHA256: String?
|
||||
let macX64SHA256: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case macArm64 = "mac-arm64"
|
||||
case macX64 = "mac-x64"
|
||||
case macArm64SHA256 = "mac-arm64-sha256"
|
||||
case macX64SHA256 = "mac-x64-sha256"
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
var currentArchSHA256: String? {
|
||||
#if arch(arm64)
|
||||
return macArm64SHA256
|
||||
#elseif arch(x86_64)
|
||||
return macX64SHA256
|
||||
#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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
|
||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||
|
||||
guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
|
||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
|
||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,6 @@ import Combine
|
||||
|
||||
enum AddonState: Equatable, Sendable {
|
||||
case notInstalled
|
||||
case downloading(progress: Double)
|
||||
case installed(version: String)
|
||||
case failed(error: String)
|
||||
}
|
||||
@@ -12,10 +11,6 @@ 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"
|
||||
@@ -31,116 +26,48 @@ final class MediaEngineManager: ObservableObject {
|
||||
@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 var installTasks: [AddonType: Task<Void, Error>] = [:]
|
||||
|
||||
private init() {
|
||||
checkLocalInstallation()
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL {
|
||||
return addonsDirectory.appendingPathComponent(addon.binaryName)
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
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
|
||||
}
|
||||
|
||||
func checkLocalInstallation() {
|
||||
for addon in AddonType.allCases {
|
||||
guard installTasks[addon] == nil else { continue }
|
||||
let path = binaryPath(for: addon)
|
||||
if FileManager.default.isExecutableFile(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"))
|
||||
}
|
||||
if binaryPath(for: addon) != nil {
|
||||
setState(for: addon, to: .installed(version: "Bundled"))
|
||||
} else {
|
||||
setState(for: addon, to: .notInstalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestConfig() async throws -> GatekeeperConfig {
|
||||
var request = URLRequest(url: configURL)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = 30
|
||||
|
||||
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(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
|
||||
let config = try await fetchLatestConfig()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
|
||||
let task = installationTask(for: addon, from: config)
|
||||
group.addTask {
|
||||
try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
|
||||
checkLocalInstallation()
|
||||
let missingAddons = requiredAddons.filter { addon in
|
||||
switch state(for: addon) {
|
||||
case .installed:
|
||||
return false
|
||||
case .downloading, .notInstalled, .failed:
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !missingAddons.isEmpty else { return }
|
||||
try await ensureInstalled(addons: missingAddons)
|
||||
}
|
||||
|
||||
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
|
||||
let state: AddonState
|
||||
let configVersion: String?
|
||||
switch addon {
|
||||
case .ytDlp:
|
||||
state = ytDlpState
|
||||
configVersion = config.ytDlp?.version
|
||||
case .ffmpeg:
|
||||
state = ffmpegState
|
||||
configVersion = config.ffmpeg?.version
|
||||
for missing in missingAddons {
|
||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
case .downloading:
|
||||
return true
|
||||
case .installed(let version):
|
||||
guard let configVersion else { return false }
|
||||
return version != configVersion
|
||||
}
|
||||
}
|
||||
|
||||
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
|
||||
if let task = installTasks[addon] {
|
||||
return task
|
||||
}
|
||||
|
||||
let task = Task { @MainActor in
|
||||
defer { self.installTasks[addon] = nil }
|
||||
try await self.install(addon: addon, from: config)
|
||||
}
|
||||
installTasks[addon] = task
|
||||
return task
|
||||
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 {
|
||||
@@ -150,60 +77,6 @@ final class MediaEngineManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
|
||||
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)"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard let downloadURL = addonConfig.currentArchURL else {
|
||||
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard downloadURL.scheme?.lowercased() == "https" else {
|
||||
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expectedSHA256.isEmpty else {
|
||||
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
|
||||
throw BinaryDownloaderError.missingChecksum
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
let destination = binaryPath(for: addon)
|
||||
|
||||
try await BinaryDownloader.download(
|
||||
from: downloadURL,
|
||||
to: destination,
|
||||
expectedSHA256: expectedSHA256
|
||||
) { 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))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(for addon: AddonType, to state: AddonState) {
|
||||
switch addon {
|
||||
case .ytDlp: ytDlpState = state
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,11 @@ enum MediaExtractionEngine {
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path
|
||||
guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else {
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
}
|
||||
let ytDlpPath = ytDlpURL.path
|
||||
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,tv"]
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ struct EngineSettingsPane: View {
|
||||
@StateObject private var engineManager = MediaEngineManager.shared
|
||||
@State private var version = "Checking..."
|
||||
|
||||
@State private var isCheckingForUpdates = false
|
||||
@State private var updateCheckResult: String?
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
@@ -16,23 +13,13 @@ struct EngineSettingsPane: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status") {
|
||||
if executableURL != nil {
|
||||
Label("Ready", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Missing", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
|
||||
LabeledContent("Binary Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
@@ -54,40 +41,10 @@ struct EngineSettingsPane: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Updates") {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
checkMediaEngineUpdates()
|
||||
} label: {
|
||||
Text("Check for Updates")
|
||||
}
|
||||
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||
|
||||
if isCheckingForUpdates {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else if let result = updateCheckResult {
|
||||
if result == "Up to date" || result == "Updated successfully" {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(result)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(result)
|
||||
.foregroundStyle(.red)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
@@ -97,14 +54,12 @@ struct EngineSettingsPane: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState)
|
||||
} header: {
|
||||
Text("Media Extractors")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Powers video and audio extraction from supported sites.")
|
||||
|
||||
|
||||
if settings.mediaCookieSource != .none {
|
||||
Text(settings.mediaCookieSource.statusDetail)
|
||||
}
|
||||
@@ -117,63 +72,30 @@ struct EngineSettingsPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func checkMediaEngineUpdates() {
|
||||
Task {
|
||||
isCheckingForUpdates = true
|
||||
updateCheckResult = nil
|
||||
// Brief visual feedback delay
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
|
||||
do {
|
||||
let wasDownloading = isDownloadingMediaEngines
|
||||
try await engineManager.ensureInstalled()
|
||||
if wasDownloading || isDownloadingMediaEngines {
|
||||
updateCheckResult = "Updated successfully"
|
||||
} else {
|
||||
updateCheckResult = "Up to date"
|
||||
}
|
||||
} catch {
|
||||
updateCheckResult = "Update failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isCheckingForUpdates = false
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if !isCheckingForUpdates {
|
||||
withAnimation {
|
||||
updateCheckResult = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isDownloadingMediaEngines: Bool {
|
||||
if case .downloading = engineManager.ytDlpState { return true }
|
||||
if case .downloading = engineManager.ffmpegState { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func addonStatusRow(title: String, state: AddonState) -> some View {
|
||||
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
|
||||
LabeledContent(title) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Label("Missing", systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .downloading(let progress):
|
||||
HStack(spacing: 6) {
|
||||
ProgressView(value: progress)
|
||||
.frame(width: 60)
|
||||
Text("\(Int(progress * 100))%")
|
||||
.monospacedDigit()
|
||||
VStack(alignment: .trailing) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Text("Error")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
case .installed(let version):
|
||||
Label("v\(version)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Label("Failed", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user