From 02abef1443e2716b31c243896796390d5e5463b0 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:12:40 +0330 Subject: [PATCH] feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle - Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources. - Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift. - Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners. - Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window. - Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling. --- Package.swift | 4 +- Sources/Firelink/AddDownloadsView.swift | 2 +- Sources/Firelink/BinaryDownloader.swift | 192 ------------------ Sources/Firelink/FirelinkApp.swift | 2 +- Sources/Firelink/GatekeeperConfig.swift | 48 ----- Sources/Firelink/MediaDownloadEngine.swift | 4 +- Sources/Firelink/MediaEngineManager.swift | 154 ++------------ Sources/Firelink/MediaExtractionEngine.swift | 5 +- .../Settings/EngineSettingsPane.swift | 128 +++--------- 9 files changed, 49 insertions(+), 490 deletions(-) delete mode 100644 Sources/Firelink/BinaryDownloader.swift delete mode 100644 Sources/Firelink/GatekeeperConfig.swift diff --git a/Package.swift b/Package.swift index 111a684..c11c656 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,9 @@ let package = Package( ], path: "Sources/Firelink", resources: [ - .process("Assets.xcassets") + .process("Assets.xcassets"), + .copy("yt-dlp"), + .copy("ffmpeg") ] ) ] diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 2dedf2b..4e4f8ef 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -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, diff --git a/Sources/Firelink/BinaryDownloader.swift b/Sources/Firelink/BinaryDownloader.swift deleted file mode 100644 index 9310dba..0000000 --- a/Sources/Firelink/BinaryDownloader.swift +++ /dev/null @@ -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 - - init( - url: URL, - destination: URL, - expectedSHA256: String?, - onProgress: @escaping @Sendable (Double) -> Void, - continuation: CheckedContinuation - ) { - 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() - } -} diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 688cd87..66257ca 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -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) diff --git a/Sources/Firelink/GatekeeperConfig.swift b/Sources/Firelink/GatekeeperConfig.swift deleted file mode 100644 index 95d2117..0000000 --- a/Sources/Firelink/GatekeeperConfig.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index 1c8de62..b711e5f 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -29,10 +29,10 @@ 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 { + guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else { throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.") } - guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else { + guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else { throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.") } diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 496a60c..6da029a 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -12,10 +12,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,67 +27,28 @@ 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] = [:] - private init() { checkLocalInstallation() } - func binaryPath(for addon: AddonType) -> URL { - return addonsDirectory.appendingPathComponent(addon.binaryName) + 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 + } + 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 = 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) async throws { checkLocalInstallation() let missingAddons = requiredAddons.filter { addon in @@ -104,43 +61,12 @@ final class MediaEngineManager: ObservableObject { } 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 { - 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."]) } private func state(for addon: AddonType) -> AddonState { @@ -150,60 +76,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 diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index aa2dbc8..fe1b74e 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -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) diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index db27d20..131dd0c 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -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,16 +13,6 @@ 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)) @@ -54,39 +41,9 @@ struct EngineSettingsPane: View { } Section { - LabeledContent("Updates") { - HStack(spacing: 8) { - Button { - checkMediaEngineUpdates() - } label: { - Text("Check for Updates") - } - .disabled(isDownloadingMediaEngines || isCheckingForUpdates) - - 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: "yt-dlp", state: engineManager.ytDlpState) + 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) { @@ -97,8 +54,6 @@ struct EngineSettingsPane: View { .labelsHidden() .frame(maxWidth: 200) } - - addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState) } header: { Text("Media Extractors") } footer: { @@ -117,63 +72,32 @@ 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 .downloading: + Text("Unavailable") + 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) } } }