From 4e48f1e42c5a4955001fefdfea2c709c76175a28 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:06:00 +0330 Subject: [PATCH] fix: harden media download flow --- Scripts/create_app_bundle.sh | 10 +- Scripts/fix_table.py | 23 --- Scripts/fix_table_2.py | 62 ------- Scripts/fix_table_final.py | 86 ---------- Scripts/fix_window.py | 21 --- Scripts/remove_doubleclick.py | 45 ----- Scripts/verify.sh | 1 + Sources/Firelink/AddDownloadsView.swift | 17 +- Sources/Firelink/AppSettings.swift | 6 +- Sources/Firelink/Aria2DownloadEngine.swift | 14 +- Sources/Firelink/BinaryDownloader.swift | 3 + Sources/Firelink/ChunkMapView.swift | 32 ++-- Sources/Firelink/ContentView.swift | 18 +- Sources/Firelink/DownloadController.swift | 142 +++++++++++++--- Sources/Firelink/DownloadTable.swift | 12 +- Sources/Firelink/FirelinkApp.swift | 14 +- Sources/Firelink/GatekeeperConfig.swift | 6 +- Sources/Firelink/LocalExtensionServer.swift | 6 +- Sources/Firelink/MediaDetector.swift | 6 +- Sources/Firelink/MediaDownloadEngine.swift | 128 +++++++++++--- Sources/Firelink/MediaEngineManager.swift | 34 +++- Sources/Firelink/MediaExtractionEngine.swift | 156 ++++++++++++++---- .../Firelink/MediaInspectorInlineView.swift | 25 ++- Sources/Firelink/SchedulerController.swift | 26 +-- Sources/Firelink/SchedulerView.swift | 44 ++--- .../Firelink/Settings/AboutSettingsPane.swift | 2 +- .../Settings/DownloadSettingsPane.swift | 2 +- .../Settings/EngineSettingsPane.swift | 18 +- .../Settings/IntegrationSettingsPane.swift | 6 +- .../Settings/LookAndFeelSettingsPane.swift | 10 +- .../Firelink/Settings/SettingsContainer.swift | 2 +- Sources/Firelink/SidebarView.swift | 2 +- Sources/Firelink/SpeedLimiterView.swift | 36 ++-- generate_preview.py | 63 ------- process_icon.py | 28 ++-- test_128.png | Bin 9514 -> 0 bytes 36 files changed, 576 insertions(+), 530 deletions(-) delete mode 100644 Scripts/fix_table.py delete mode 100644 Scripts/fix_table_2.py delete mode 100644 Scripts/fix_table_final.py delete mode 100644 Scripts/fix_window.py delete mode 100644 Scripts/remove_doubleclick.py delete mode 100644 generate_preview.py delete mode 100644 test_128.png diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 3acc3e8..3bbb20d 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -4,8 +4,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_NAME="Firelink" CONFIGURATION="${CONFIGURATION:-release}" -MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}" -BUILD_NUMBER="${BUILD_NUMBER:-1}" +DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)" +DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)" +MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}" +BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}" APP_DIR="$ROOT_DIR/build/$APP_NAME.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" @@ -35,12 +37,12 @@ ARIA2C_PATH=$(which aria2c || true) if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then echo "Bundling aria2c from $ARIA2C_PATH..." cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c" - + if ! command -v dylibbundler &> /dev/null; then echo "Installing dylibbundler..." brew install dylibbundler fi - + FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" mkdir -p "$FRAMEWORKS_DIR" dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/" diff --git a/Scripts/fix_table.py b/Scripts/fix_table.py deleted file mode 100644 index 41a57a5..0000000 --- a/Scripts/fix_table.py +++ /dev/null @@ -1,23 +0,0 @@ -import re - -with open("Sources/Firelink/DownloadTable.swift", "r") as f: - content = f.read() - -# Remove doubleClickableCell block -content = re.sub(r' private func doubleClickableCell.*? }\n\n', '', content, flags=re.DOTALL) - -# Remove doubleClickableCell wrappers from TableColumn -content = re.sub(r'doubleClickableCell\(for: item\) \{\n\s*(.*?)\n\s*\}', r'\1', content, flags=re.DOTALL) - -# Add simultaneousGesture to Table -table_end = content.find(' .environment(\\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)') -gesture = ''' .simultaneousGesture(TapGesture(count: 2).onEnded { - if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) { - performPrimaryAction(for: item) - } - }) -''' -content = content[:table_end] + gesture + content[table_end:] - -with open("Sources/Firelink/DownloadTable.swift", "w") as f: - f.write(content) diff --git a/Scripts/fix_table_2.py b/Scripts/fix_table_2.py deleted file mode 100644 index 7087c7e..0000000 --- a/Scripts/fix_table_2.py +++ /dev/null @@ -1,62 +0,0 @@ -import re - -with open("Sources/Firelink/DownloadTable.swift", "r") as f: - content = f.read() - -# Fix size to use bytesText if sizeBytes is nil or 0 -old_size = ''' TableColumn("Size", value: \\.sortableSize) { item in - Text(ByteFormatter.string(item.sizeBytes)) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - }''' -new_size = ''' TableColumn("Size", value: \\.sortableSize) { item in - if let size = item.sizeBytes, size > 0 { - Text(ByteFormatter.string(size)) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - } else if item.bytesText != "-" && !item.bytesText.isEmpty { - Text(item.bytesText) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - } else { - Text("Unknown") - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - } - }''' -content = content.replace(old_size, new_size) - -# Add allowsHitTesting(false) to text to fix single click interception -old_text = ''' Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail)''' -new_text = ''' Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false)''' -content = content.replace(old_text, new_text) - -# Change performPrimaryAction to use id -old_action = 'openWindow(value: item.id)' -new_action = 'openWindow(id: "download-properties", value: item.id)' -content = content.replace(old_action, new_action) - -with open("Sources/Firelink/DownloadTable.swift", "w") as f: - f.write(content) - -with open("Sources/Firelink/FirelinkApp.swift", "r") as f: - app_content = f.read() - -app_content = app_content.replace( - 'WindowGroup("Download Properties", for: UUID.self)', - 'WindowGroup("Download Properties", id: "download-properties", for: UUID.self)' -) - -with open("Sources/Firelink/FirelinkApp.swift", "w") as f: - f.write(app_content) diff --git a/Scripts/fix_table_final.py b/Scripts/fix_table_final.py deleted file mode 100644 index 7007377..0000000 --- a/Scripts/fix_table_final.py +++ /dev/null @@ -1,86 +0,0 @@ -with open("Sources/Firelink/FirelinkApp.swift", "r") as f: - app_content = f.read() - -app_content = app_content.replace( - 'WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in\n if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {', - 'WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in\n if let downloadID {' -) - -with open("Sources/Firelink/FirelinkApp.swift", "w") as f: - f.write(app_content) - -with open("Sources/Firelink/DownloadTable.swift", "r") as f: - table_content = f.read() - -# Fix openWindow in performPrimaryAction -table_content = table_content.replace( - 'openWindow(id: "download-properties", value: item.id.uuidString)', - 'openWindow(id: "download-properties", value: item.id)' -) - -# Fix openWindow in rowContextMenu -table_content = table_content.replace( - 'openWindow(value: target.id)', - 'openWindow(id: "download-properties", value: target.id)' -) - -# Remove simultaneousGesture from Table -import re -table_content = re.sub( - r'\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*if let id = selection\.first, let item = controller\.downloads\.first\(where: \{ \$0\.id == id \}\) \{\s*performPrimaryAction\(for: item\)\s*\}\s*\}\)', - '', - table_content -) - -# Replace the File Name TableColumn to use doubleClickableCell -old_column = ''' TableColumn("File Name", value: \\.fileName) { item in - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor(for: item.category)) - .frame(width: 22) - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false) - .draggable(item.id.uuidString) - } - }''' - -new_column = ''' TableColumn("File Name", value: \\.fileName) { item in - doubleClickableCell(for: item) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor(for: item.category)) - .frame(width: 22) - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false) - } - .draggable(item.id.uuidString) - } - }''' - -table_content = table_content.replace(old_column, new_column) - -# Add doubleClickableCell helper -helper = ''' private func performPrimaryAction(for item: DownloadItem) {''' - -helper_new = ''' private func doubleClickableCell(for item: DownloadItem, @ViewBuilder content: () -> Content) -> some View { - content() - .contentShape(Rectangle()) - .simultaneousGesture(TapGesture(count: 2).onEnded { - performPrimaryAction(for: item) - }) - } - - private func performPrimaryAction(for item: DownloadItem) {''' - -table_content = table_content.replace(helper, helper_new) - -with open("Sources/Firelink/DownloadTable.swift", "w") as f: - f.write(table_content) diff --git a/Scripts/fix_window.py b/Scripts/fix_window.py deleted file mode 100644 index e08d4cc..0000000 --- a/Scripts/fix_window.py +++ /dev/null @@ -1,21 +0,0 @@ -with open("Sources/Firelink/FirelinkApp.swift", "r") as f: - app_content = f.read() - -old_group = ''' WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in - if let downloadID {''' -new_group = ''' WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in - if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {''' -app_content = app_content.replace(old_group, new_group) - -with open("Sources/Firelink/FirelinkApp.swift", "w") as f: - f.write(app_content) - -with open("Sources/Firelink/DownloadTable.swift", "r") as f: - table_content = f.read() - -old_open1 = 'openWindow(id: "download-properties", value: item.id)' -new_open1 = 'openWindow(id: "download-properties", value: item.id.uuidString)' -table_content = table_content.replace(old_open1, new_open1) - -with open("Sources/Firelink/DownloadTable.swift", "w") as f: - f.write(table_content) diff --git a/Scripts/remove_doubleclick.py b/Scripts/remove_doubleclick.py deleted file mode 100644 index a5da007..0000000 --- a/Scripts/remove_doubleclick.py +++ /dev/null @@ -1,45 +0,0 @@ -with open("Sources/Firelink/DownloadTable.swift", "r") as f: - content = f.read() - -old_column = ''' TableColumn("File Name", value: \\.fileName) { item in - doubleClickableCell(for: item) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor(for: item.category)) - .frame(width: 22) - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false) - } - .draggable(item.id.uuidString) - } - }''' - -new_column = ''' TableColumn("File Name", value: \\.fileName) { item in - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor(for: item.category)) - .frame(width: 22) - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false) - } - .draggable(item.id.uuidString) - }''' - -content = content.replace(old_column, new_column) - -import re - -# Remove doubleClickableCell function block -pattern = r'\s*private func doubleClickableCell\(for item: DownloadItem, @ViewBuilder content: \(\) -> Content\) -> some View \{\s*content\(\)\s*\.contentShape\(Rectangle\(\)\)\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*performPrimaryAction\(for: item\)\s*\}\)\s*\}' -content = re.sub(pattern, '', content) - -with open("Sources/Firelink/DownloadTable.swift", "w") as f: - f.write(content) diff --git a/Scripts/verify.sh b/Scripts/verify.sh index ccec882..2edbf8a 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -5,4 +5,5 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" swift build +git diff --check python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 804e78a..1ecd706 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -493,9 +493,16 @@ struct AddDownloadsView: View { let urls = DownloadURLParser.parse(text) metadataTask?.cancel() - if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { + let mediaURL: URL? = { + guard urls.count == 1, let first = urls.first, MediaDetector.isSupportedMedia(url: first) else { + return nil + } + return first + }() + + if let mediaURL { withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { - detectedMediaURL = first + detectedMediaURL = mediaURL } } else { withAnimation { @@ -522,6 +529,12 @@ struct AddDownloadsView: View { saveLogin = false } + guard mediaURL == nil else { + pendingDownloads = [] + metadataTask = nil + return + } + metadataTask = Task { var loaded: [PendingDownload] = [] for url in urls { diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 5fcfac1..3017efa 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -116,11 +116,11 @@ final class AppSettings: ObservableObject { @Published var appTheme: AppTheme = .system { didSet { save() } } - + @Published var appFontSize: AppFontSize = .standard { didSet { save() } } - + @Published var listRowDensity: ListRowDensity = .standard { didSet { save() } } @@ -342,7 +342,7 @@ final class AppSettings: ObservableObject { let data = await Task.detached(priority: .background) { try? JSONEncoder().encode(stored) }.value - + guard !Task.isCancelled, let encoded = data else { return } defaults.set(encoded, forKey: storageKey) } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index aa437e7..1c5efd4 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -91,10 +91,10 @@ final class Aria2DownloadEngine { // Close the write file handle in the parent process immediately // This guarantees readToEnd() won't hang waiting for the parent itself outputPipe.fileHandleForWriting.closeFile() - + let data = try? outputPipe.fileHandleForReading.readToEnd() process.waitUntilExit() - + guard process.terminationStatus == 0, let data = data else { return nil } let output = String(data: data, encoding: .utf8) ?? "" @@ -230,7 +230,7 @@ final class Aria2DownloadEngine { rpcPort: Int, rpcSecret: String, process: Process, - completionGate: CompletionGate + completionGate: CompletionGate ) -> Task { Task.detached { while !Task.isCancelled && process.isRunning { @@ -527,16 +527,16 @@ final class LockedDataBuffer: @unchecked Sendable { } } -final class CompletionGate: @unchecked Sendable { +final class CompletionGate: @unchecked Sendable { private let lock = NSLock() private var didComplete = false - private let completion: @Sendable (Result) -> Void + private let completion: @Sendable (Result) -> Void - init(_ completion: @escaping @Sendable (Result) -> Void) { + init(_ completion: @escaping @Sendable (Result) -> Void) { self.completion = completion } - func complete(_ result: Result) { + func complete(_ result: Result) { lock.lock() let shouldComplete = !didComplete if shouldComplete { diff --git a/Sources/Firelink/BinaryDownloader.swift b/Sources/Firelink/BinaryDownloader.swift index b2c3f1a..9310dba 100644 --- a/Sources/Firelink/BinaryDownloader.swift +++ b/Sources/Firelink/BinaryDownloader.swift @@ -9,6 +9,7 @@ enum BinaryDownloaderError: LocalizedError { case permissionFailed(Error) case unzipFailed case unsupportedDownloadURL + case missingChecksum case checksumMismatch var errorDescription: String? { @@ -27,6 +28,8 @@ enum BinaryDownloaderError: LocalizedError { "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." } diff --git a/Sources/Firelink/ChunkMapView.swift b/Sources/Firelink/ChunkMapView.swift index 806851c..c118a96 100644 --- a/Sources/Firelink/ChunkMapView.swift +++ b/Sources/Firelink/ChunkMapView.swift @@ -2,12 +2,12 @@ import SwiftUI struct ChunkMapView: View { let item: DownloadItem - + @State private var bitfield: String = "" @State private var numPieces: Int = 0 @State private var pollTask: Task? @State private var isVisible = false - + var body: some View { VStack(alignment: .leading, spacing: 8) { if numPieces > 0 { @@ -34,11 +34,11 @@ struct ChunkMapView: View { } } } - + private func startPolling() { pollTask?.cancel() guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return } - + pollTask = Task { while !Task.isCancelled { await fetchStatus(port: port, secret: secret) @@ -50,23 +50,23 @@ struct ChunkMapView: View { } } } - + private func fetchStatus(port: Int, secret: String) async { guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") - + let payload: [String: Any] = [ "jsonrpc": "2.0", "method": "aria2.tellActive", "id": "1", "params": ["token:\(secret)", ["bitfield", "numPieces"]] ] - + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } request.httpBody = data - + do { let (responseData, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], @@ -74,11 +74,11 @@ struct ChunkMapView: View { let active = result.first else { return } - + let fetchedBitfield = active["bitfield"] as? String ?? "" let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0" let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0 - + await MainActor.run { self.bitfield = fetchedBitfield self.numPieces = fetchedNumPieces @@ -92,7 +92,7 @@ struct ChunkMapView: View { struct ChunkGrid: View { let bitfield: String let numPieces: Int - + private var pieces: [Bool] { var result = [Bool]() result.reserveCapacity(numPieces) @@ -110,7 +110,7 @@ struct ChunkGrid: View { } return result } - + var body: some View { let itemPieces = pieces Canvas { context, size in @@ -118,10 +118,10 @@ struct ChunkGrid: View { let spacing: CGFloat = 2 let cornerSize = CGSize(width: 2, height: 2) let width = size.width - + let x: CGFloat = 0 let y: CGFloat = 0 - + let completedPath = Path { p in var cx = x var cy = y @@ -136,7 +136,7 @@ struct ChunkGrid: View { } } } - + let pendingPath = Path { p in var cx: CGFloat = 0 var cy: CGFloat = 0 @@ -151,7 +151,7 @@ struct ChunkGrid: View { } } } - + context.fill(pendingPath, with: .color(Color.primary.opacity(0.08))) context.fill(completedPath, with: .color(Color.accentColor)) } diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 7fc863d..d99b0c3 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -154,6 +154,14 @@ struct ContentView: View { } .keyboardShortcut("a", modifiers: .command) .opacity(0) + + Button("") { + if let item = selectedItems.first { + performPrimaryAction(for: item) + } + } + .keyboardShortcut(.return, modifiers: []) + .opacity(0) } .confirmationDialog( "Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")", @@ -181,7 +189,7 @@ struct ContentView: View { } selection.removeAll() } - + private func handlePaste(queueID: UUID?) { guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return } controller.pendingPasteboardText = text @@ -194,6 +202,14 @@ struct ContentView: View { selection = Set(items.map { $0.id }) } + private func performPrimaryAction(for item: DownloadItem) { + if item.status == .completed { + NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath)) + } else { + openWindow(id: "download-properties", value: item.id) + } + } + private func hasActiveDownloads(in queueID: UUID?) -> Bool { if let queueID { return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 77d98d8..e8ae66e 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -61,7 +61,7 @@ final class DownloadController: ObservableObject { } } .store(in: &cancellables) - + $downloads .dropFirst() .debounce(for: .seconds(2.0), scheduler: RunLoop.main) @@ -69,7 +69,7 @@ final class DownloadController: ObservableObject { self?.saveDownloads() } .store(in: &cancellables) - + NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) .sink { [weak self] _ in self?.saveDownloads() @@ -535,15 +535,17 @@ final class DownloadController: ObservableObject { $0.message = "Checking media add-ons..." } try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg]) + guard let liveItem = activeDownloadItem(id: item.id) else { return } + update(item.id) { guard $0.status == .downloading else { return } $0.message = "Starting yt-dlp..." } let handle = try await mediaEngine.start( - item: item, + item: liveItem, cookieSource: settings.mediaCookieSource, proxyConfiguration: settings.downloadProxyConfiguration, - speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), + speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem), progress: { [weak self] progress in Task { @MainActor in self?.update(item.id) { @@ -569,12 +571,20 @@ final class DownloadController: ObservableObject { }, completion: { [weak self] result in Task { @MainActor in - self?.handleCompletion(item: item, result: result, isMedia: true) + self?.handleMediaCompletion(item: item, result: result) } } ) + guard activeDownloadItem(id: item.id) != nil else { + handle.cancel() + return + } activeMediaHandles[item.id] = handle + saveDownloads() + applySpeedLimitsToActiveDownloads() + updateSleepActivity() } catch { + guard shouldHandleStartFailure(for: item.id) else { return } handleDownloadFailure(itemID: item.id, error: error) applySpeedLimitsToActiveDownloads() updateSleepActivity() @@ -602,7 +612,7 @@ final class DownloadController: ObservableObject { }, completion: { [weak self] result in Task { @MainActor in - self?.handleCompletion(item: item, result: result, isMedia: false) + self?.handleCompletion(item: item, result: result) } } ) @@ -624,12 +634,20 @@ final class DownloadController: ObservableObject { } } - private func handleCompletion(item: DownloadItem, result: Result, isMedia: Bool) { - if isMedia { - activeMediaHandles[item.id] = nil - } else { - activeHandles[item.id] = nil + private func activeDownloadItem(id: UUID) -> DownloadItem? { + downloads.first { $0.id == id && $0.status == .downloading } + } + + private func shouldHandleStartFailure(for id: UUID) -> Bool { + guard let item = downloads.first(where: { $0.id == id }) else { + automaticRetryCounts[id] = nil + return false } + return item.status != .paused && item.status != .canceled + } + + private func handleCompletion(item: DownloadItem, result: Result) { + activeHandles[item.id] = nil switch result { case .success: @@ -641,7 +659,7 @@ final class DownloadController: ObservableObject { $0.etaText = "-" $0.message = "Saved to \($0.destinationPath)" $0.autoResumeOnLaunch = false - + if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath), let size = attr[.size] as? Int64 { $0.sizeBytes = size @@ -663,6 +681,45 @@ final class DownloadController: ObservableObject { self.updateSleepActivity() } + private func handleMediaCompletion(item: DownloadItem, result: Result) { + activeMediaHandles[item.id] = nil + + switch result { + case .success(let outputURL): + self.automaticRetryCounts[item.id] = nil + self.update(item.id) { + $0.status = .completed + $0.progress = 1 + $0.speedText = "-" + $0.etaText = "-" + $0.connectionCount = 0 + $0.destinationDirectory = outputURL.deletingLastPathComponent() + $0.fileName = outputURL.lastPathComponent + $0.category = FileClassifier.category(forFileName: $0.fileName) + $0.message = "Saved to \(outputURL.path)" + $0.autoResumeOnLaunch = false + + if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path), + let size = attr[.size] as? Int64 { + $0.sizeBytes = size + $0.bytesText = ByteFormatter.string(size) + } + } + self.saveDownloads() + self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent) + case .failure(let error): + if self.downloads.first(where: { $0.id == item.id })?.status == .paused || + self.downloads.first(where: { $0.id == item.id })?.status == .canceled { + return + } + self.handleDownloadFailure(itemID: item.id, error: error) + } + + self.pumpQueue() + self.applySpeedLimitsToActiveDownloads() + self.updateSleepActivity() + } + private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) { guard let index = downloads.firstIndex(where: { $0.id == id }) else { return } mutate(&downloads[index]) @@ -747,6 +804,14 @@ final class DownloadController: ObservableObject { } private func handleDownloadFailure(itemID: UUID, error: Error) { + guard let currentItem = downloads.first(where: { $0.id == itemID }) else { + automaticRetryCounts[itemID] = nil + return + } + guard currentItem.status != .paused, currentItem.status != .canceled else { + return + } + let retryCount = automaticRetryCounts[itemID] ?? 0 guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else { @@ -779,16 +844,51 @@ final class DownloadController: ObservableObject { } private func isAutomaticallyRecoverable(_ error: Error) -> Bool { - guard let engineError = error as? Aria2DownloadEngine.EngineError else { - return true + if let engineError = error as? Aria2DownloadEngine.EngineError { + switch engineError { + case .executableNotFound, .unsupportedProxy: + return false + case .launchFailed: + return true + } } - switch engineError { - case .executableNotFound, .unsupportedProxy: - return false - case .launchFailed: - return true + if let mediaError = error as? MediaDownloadEngine.EngineError { + switch mediaError { + case .missingEngine: + return false + case .launchFailed(let message): + return isRecoverableMediaFailure(message) + } } + + return true + } + + private func isRecoverableMediaFailure(_ message: String) -> Bool { + let lowercased = message.lowercased() + let permanentMarkers = [ + "requires browser cookies", + "choose a browser", + "challenge solving failed", + "install deno or node", + "requested format is not available", + "unsupported url", + "private video", + "sign in", + "not a bot", + "video unavailable", + "no video formats found", + "no audio formats found", + "ffmpeg is not installed", + "yt-dlp is not installed" + ] + + if permanentMarkers.contains(where: { lowercased.contains($0) }) { + return false + } + + return true } private func isAllowedToStart(_ item: DownloadItem) -> Bool { @@ -897,7 +997,7 @@ final class DownloadController: ObservableObject { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy) let data = try JSONEncoder().encode(state) - + guard !Task.isCancelled else { return } try data.write(to: storageURL, options: .atomic) } catch { @@ -937,7 +1037,7 @@ final class DownloadController: ObservableObject { if isLegacyDownloadList, item.queueID == nil { adjusted.queueID = DownloadQueue.mainQueueID } - + if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) { adjusted.credentials?.password = storedPassword } diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 5d05881..3a0bac4 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -156,14 +156,6 @@ struct DownloadTable: View { } } - private func performPrimaryAction(for item: DownloadItem) { - if item.status == .completed { - openFile(item) - } else { - openWindow(id: "download-properties", value: item.id) - } - } - @ViewBuilder private func statusCell(for item: DownloadItem) -> some View { let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines) @@ -311,12 +303,12 @@ struct DownloadTable: View { ZStack { RoundedRectangle(cornerRadius: 4) .fill(Color.secondary.opacity(0.15)) - + RoundedRectangle(cornerRadius: 4) .fill(statusColor(for: item.status)) .frame(width: max(0, proxy.size.width * item.progress)) .frame(maxWidth: .infinity, alignment: .leading) - + Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.primary) diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index a9b21f9..da7212f 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -4,7 +4,7 @@ import Sparkle final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { private var _controller: SPUStandardUpdaterController? var controller: SPUStandardUpdaterController { _controller! } - + @Published var isChecking = false @Published var updateStatus: String? @Published var foundUpdateItem: SUAppcastItem? @@ -13,7 +13,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { super.init() self._controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) } - + func checkForUpdates() { guard controller.updater.canCheckForUpdates else { isChecking = false @@ -41,7 +41,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { self.updateStatus = "You're up to date!" } } - + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { DispatchQueue.main.async { let nsError = error as NSError @@ -49,7 +49,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { // SUNoUpdateError (1001), handled by updaterDidNotFindUpdate return } - + self.isChecking = false self.updateStatus = "Update check failed: \(error.localizedDescription)" } @@ -70,7 +70,7 @@ struct FirelinkApp: App { @StateObject private var controller: DownloadController @StateObject private var schedulerController: SchedulerController @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true - + // Server must be retained to keep listening private let extensionServer: LocalExtensionServer? @@ -82,7 +82,7 @@ struct FirelinkApp: App { _settings = StateObject(wrappedValue: settings) _controller = StateObject(wrappedValue: controller) _schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller)) - + extensionServer = LocalExtensionServer(downloadController: controller) extensionServer?.start() controller.extensionServerPort = extensionServer?.port @@ -136,7 +136,7 @@ struct FirelinkApp: App { NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) } .keyboardShortcut("n", modifiers: [.command]) - + Divider() Button("Start Queue") { diff --git a/Sources/Firelink/GatekeeperConfig.swift b/Sources/Firelink/GatekeeperConfig.swift index d874b8d..95d2117 100644 --- a/Sources/Firelink/GatekeeperConfig.swift +++ b/Sources/Firelink/GatekeeperConfig.swift @@ -6,7 +6,7 @@ struct AddonConfig: Codable, Equatable, Sendable { let macX64: URL? let macArm64SHA256: String? let macX64SHA256: String? - + enum CodingKeys: String, CodingKey { case version case macArm64 = "mac-arm64" @@ -14,7 +14,7 @@ struct AddonConfig: Codable, Equatable, Sendable { 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) @@ -40,7 +40,7 @@ struct AddonConfig: Codable, Equatable, Sendable { 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/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index d9c0934..8706dbb 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -20,7 +20,7 @@ final class LocalExtensionServer: @unchecked Sendable { init?(downloadController: DownloadController) { self.downloadController = downloadController let parameters = NWParameters.tcp - + var createdListener: NWListener? var selectedPort: UInt16? for portValue in Constants.portRange { @@ -33,12 +33,12 @@ final class LocalExtensionServer: @unchecked Sendable { continue } } - + guard let createdListener else { print("Failed to create listener on ports 6412-6422") return nil } - + self.listener = createdListener self.port = selectedPort ?? 6412 } diff --git a/Sources/Firelink/MediaDetector.swift b/Sources/Firelink/MediaDetector.swift index d95bd65..085a32a 100644 --- a/Sources/Firelink/MediaDetector.swift +++ b/Sources/Firelink/MediaDetector.swift @@ -12,13 +12,13 @@ enum MediaDetector { "reddit.com", "v.redd.it", "soundcloud.com" ] - + static func isSupportedMedia(url: URL) -> Bool { guard let host = url.host?.lowercased() else { return false } - + for domain in supportedDomains { if host == domain || host.hasSuffix(".\(domain)") { - // Ignore raw files that happen to be hosted on these domains, if any, + // Ignore raw files that happen to be hosted on these domains, if any, // though usually these domains serve web pages for media. return true } diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index a7a273e..1c8de62 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -4,11 +4,11 @@ final class MediaDownloadEngine: @unchecked Sendable { struct Handle { let cancel: @Sendable () -> Void } - + enum EngineError: LocalizedError { case missingEngine(String) case launchFailed(String) - + var errorDescription: String? { switch self { case .missingEngine(let msg): return msg @@ -16,7 +16,7 @@ final class MediaDownloadEngine: @unchecked Sendable { } } } - + func start( item: DownloadItem, cookieSource: BrowserCookieSource, @@ -24,34 +24,34 @@ final class MediaDownloadEngine: @unchecked Sendable { speedLimitKiBPerSecond: Int?, progress: @escaping @Sendable (DownloadProgress) -> Void, messageUpdate: @escaping @Sendable (String) -> Void, - completion: @escaping @Sendable (Result) -> Void + completion: @escaping @Sendable (Result) -> Void ) async throws -> Handle { 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 FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else { throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.") } - + try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true) - + let process = Process() process.executableURL = ytDlpURL - + var arguments = [ "--newline", "--ffmpeg-location", ffmpegURL.path, "--extractor-args", "youtube:player_client=ios,tv", "-o", item.destinationPath ] - + if let format = item.mediaFormatSelector { arguments.append("-f") arguments.append(format) - + if item.isAudioOnlyMedia == true { let audioFormat = item.fileName.fileExtension(defaultValue: "mp3") arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"]) @@ -80,7 +80,7 @@ final class MediaDownloadEngine: @unchecked Sendable { arguments.append(item.url.absoluteString) process.arguments = arguments - + let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe @@ -88,9 +88,11 @@ final class MediaDownloadEngine: @unchecked Sendable { let parser = YTDLPProgressParser() let errorBuffer = LockedDataBuffer() + let outputPathTracker = YTDLPOutputPathTracker() let completionGate = CompletionGate(completion) let outputHandler = YTDLPOutputHandler( parser: parser, + outputPathTracker: outputPathTracker, progress: progress, messageUpdate: messageUpdate ) @@ -109,24 +111,24 @@ final class MediaDownloadEngine: @unchecked Sendable { outputHandler.handle(text) } } - + process.terminationHandler = { finishedProcess in outputPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil - + if finishedProcess.terminationStatus == 0 { - completionGate.complete(.success(())) + completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker))) } else { let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error" completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus)))) } } - + try process.run() messageUpdate("Fetching media data...") outputPipe.fileHandleForWriting.closeFile() errorPipe.fileHandleForWriting.closeFile() - + return Handle(cancel: { if process.isRunning { process.terminate() @@ -134,6 +136,34 @@ final class MediaDownloadEngine: @unchecked Sendable { }) } + private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL { + let expectedURL = URL(fileURLWithPath: item.destinationPath) + if FileManager.default.fileExists(atPath: expectedURL.path) { + return expectedURL + } + + if let observedURL = tracker.lastExistingOutputURL { + return observedURL + } + + let baseName = expectedURL.deletingPathExtension().lastPathComponent + guard let contents = try? FileManager.default.contentsOfDirectory( + at: item.destinationDirectory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return expectedURL + } + + return contents + .filter { $0.deletingPathExtension().lastPathComponent == baseName } + .max { lhs, rhs in + let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return lhsDate < rhsDate + } ?? expectedURL + } + private static func cleanErrorMessage(_ message: String, status: Int32) -> String { guard !message.isEmpty else { return "Exit code \(status)" @@ -164,15 +194,18 @@ final class MediaDownloadEngine: @unchecked Sendable { final class YTDLPOutputHandler: @unchecked Sendable { private let parser: YTDLPProgressParser + private let outputPathTracker: YTDLPOutputPathTracker private let progress: @Sendable (DownloadProgress) -> Void private let messageUpdate: @Sendable (String) -> Void init( parser: YTDLPProgressParser, + outputPathTracker: YTDLPOutputPathTracker, progress: @escaping @Sendable (DownloadProgress) -> Void, messageUpdate: @escaping @Sendable (String) -> Void ) { self.parser = parser + self.outputPathTracker = outputPathTracker self.progress = progress self.messageUpdate = messageUpdate } @@ -180,6 +213,7 @@ final class YTDLPOutputHandler: @unchecked Sendable { func handle(_ text: String) { for line in text.split(whereSeparator: \.isNewline) { let stringLine = String(line) + outputPathTracker.observe(stringLine) if let update = parser.parse(stringLine) { progress(update) messageUpdate("Downloading Media") @@ -215,6 +249,60 @@ final class YTDLPOutputHandler: @unchecked Sendable { } } +final class YTDLPOutputPathTracker: @unchecked Sendable { + private let lock = NSLock() + private var observedPaths: [String] = [] + private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#) + + var lastExistingOutputURL: URL? { + lock.withLock { + observedPaths + .reversed() + .map { URL(fileURLWithPath: $0) } + .first { FileManager.default.fileExists(atPath: $0.path) } + } + } + + func observe(_ line: String) { + let candidates = pathCandidates(from: line) + guard !candidates.isEmpty else { return } + + lock.withLock { + for candidate in candidates where !observedPaths.contains(candidate) { + observedPaths.append(candidate) + } + } + } + + private func pathCandidates(from line: String) -> [String] { + var paths: [String] = [] + + if line.contains("Destination:"), + let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines), + destination.hasPrefix("/") { + paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\""))) + } + + for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") { + paths.append(quoted) + } + + return paths + } + + private func quotedCaptures(in text: String) -> [String] { + guard let quotedPathRegex else { return [] } + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[captureRange]) + } + } +} + private extension String { func fileExtension(defaultValue: String) -> String { let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -227,14 +315,14 @@ final class YTDLPProgressParser: @unchecked Sendable { private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#) private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#) private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#) - + func parse(_ line: String) -> DownloadProgress? { if line.contains("[download]") && line.contains("%") { let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0 let speed = firstCapture(in: line, regex: speedRegex) ?? "-" let eta = firstCapture(in: line, regex: etaRegex) ?? "-" let size = firstCapture(in: line, regex: sizeRegex) ?? "-" - + return DownloadProgress( fraction: min(max(fraction, 0), 1), bytesText: size, @@ -248,7 +336,7 @@ final class YTDLPProgressParser: @unchecked Sendable { let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-" let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-" let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1 - + return DownloadProgress( fraction: min(max(fraction, 0), 1), bytesText: size, @@ -259,7 +347,7 @@ final class YTDLPProgressParser: @unchecked Sendable { } return nil } - + private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? { guard let regex else { return nil } let range = NSRange(text.startIndex..] = [:] private init() { checkLocalInstallation() @@ -49,6 +50,7 @@ final class MediaEngineManager: ObservableObject { 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) { @@ -79,9 +81,10 @@ final class MediaEngineManager: ObservableObject { let config = try await fetchLatestConfig() try await withThrowingTaskGroup(of: Void.self) { group in - for addon in requiredAddons where shouldInstall(addon: addon, config: config) { + for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil { + let task = installationTask(for: addon, from: config) group.addTask { - try await self.install(addon: addon, from: config) + try await task.value } } @@ -93,9 +96,9 @@ final class MediaEngineManager: ObservableObject { checkLocalInstallation() let missingAddons = requiredAddons.filter { addon in switch state(for: addon) { - case .installed, .downloading: + case .installed: return false - case .notInstalled, .failed: + case .downloading, .notInstalled, .failed: return true } } @@ -120,13 +123,26 @@ final class MediaEngineManager: ObservableObject { case .notInstalled, .failed: return true case .downloading: - return false + 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 + } + private func state(for addon: AddonType) -> AddonState { switch addon { case .ytDlp: return ytDlpState @@ -159,6 +175,12 @@ final class MediaEngineManager: ObservableObject { 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) @@ -166,7 +188,7 @@ final class MediaEngineManager: ObservableObject { try await BinaryDownloader.download( from: downloadURL, to: destination, - expectedSHA256: addonConfig.currentArchSHA256 + expectedSHA256: expectedSHA256 ) { progress in Task { @MainActor in self.setState(for: addon, to: .downloading(progress: progress)) diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index c97c41b..edff250 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -20,7 +20,7 @@ struct MediaMetadata: Decodable, Sendable { let thumbnail: URL? let duration: Double? let formats: [RawMediaFormat]? - + var displayUploader: String? { channel ?? uploader } @@ -34,6 +34,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable { let symbol: String let outputExtension: String let detail: String + let estimatedBytes: Int64? } enum MediaExtractionEngine { @@ -44,7 +45,7 @@ enum MediaExtractionEngine { case invalidOutput case parsingFailed(Error) case timedOut - + var errorDescription: String? { switch self { case .processFailed(let msg): return "Extraction failed: \(msg)" @@ -54,7 +55,7 @@ enum MediaExtractionEngine { } } } - + static func fetchMetadata( for url: URL, cookieSource: BrowserCookieSource, @@ -151,9 +152,6 @@ enum MediaExtractionEngine { var options: [CleanFormatOption] = [] let rawFormats = metadata.formats ?? [] - let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 } - let maxHeight = heights.max() ?? 0 - let standardResolutions = [ (2160, "4K"), (1440, "1440p"), @@ -164,7 +162,9 @@ enum MediaExtractionEngine { ] let availableResolutions = standardResolutions.filter { resolution, _ in - maxHeight == 0 || maxHeight >= resolution - 100 + rawFormats.contains { format in + isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100 + } } let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) } let videoContainers = [ @@ -175,47 +175,139 @@ enum MediaExtractionEngine { for (height, qualityName) in videoQualities { for (container, containerName) in videoContainers { + guard hasVideoFormat(rawFormats, height: height, container: container) else { continue } + let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container) options.append(CleanFormatOption( name: "\(qualityName) \(containerName)", formatSelector: videoSelector(height: height, container: container), isAudioOnly: false, symbol: "play.tv.fill", outputExtension: container, - detail: height == nil ? "Best available video" : "Up to \(qualityName)" + detail: optionDetail( + base: height == nil ? "Best available video" : "Up to \(qualityName)", + estimatedBytes: estimatedBytes + ), + estimatedBytes: estimatedBytes )) } } - options.append(CleanFormatOption( - name: "Audio MP3", - formatSelector: "bestaudio/best", - isAudioOnly: true, - symbol: "music.note", - outputExtension: "mp3", - detail: "Converted with ffmpeg" - )) + if hasAudioFormat(rawFormats, preferredExtension: nil) { + let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil) + options.append(CleanFormatOption( + name: "Audio MP3", + formatSelector: "bestaudio/best", + isAudioOnly: true, + symbol: "music.note", + outputExtension: "mp3", + detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes), + estimatedBytes: estimatedBytes + )) + } - options.append(CleanFormatOption( - name: "Audio M4A", - formatSelector: "bestaudio[ext=m4a]/bestaudio/best", - isAudioOnly: true, - symbol: "waveform", - outputExtension: "m4a", - detail: "Prefer native M4A" - )) + if hasAudioFormat(rawFormats, preferredExtension: "m4a") { + let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a") + options.append(CleanFormatOption( + name: "Audio M4A", + formatSelector: "bestaudio[ext=m4a]/bestaudio/best", + isAudioOnly: true, + symbol: "waveform", + outputExtension: "m4a", + detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes), + estimatedBytes: estimatedBytes + )) + } - options.append(CleanFormatOption( - name: "Audio Opus", - formatSelector: "bestaudio[ext=webm]/bestaudio/best", - isAudioOnly: true, - symbol: "waveform", - outputExtension: "opus", - detail: "Efficient audio" - )) + if hasAudioFormat(rawFormats, preferredExtension: "webm") { + let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm") + options.append(CleanFormatOption( + name: "Audio Opus", + formatSelector: "bestaudio[ext=webm]/bestaudio/best", + isAudioOnly: true, + symbol: "waveform", + outputExtension: "opus", + detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes), + estimatedBytes: estimatedBytes + )) + } return options } + private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool { + formats.contains { format in + guard isVideo(format), matchesHeight(format, height: height) else { return false } + return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame + } + } + + private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool { + formats.contains { format in + guard isAudio(format) else { return false } + guard let preferredExtension else { return true } + return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame + } + } + + private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? { + let videoBytes = formats + .filter { format in + guard isVideo(format), matchesHeight(format, height: height) else { return false } + return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame + } + .compactMap { formatSize($0) } + .max() + + guard let videoBytes else { return nil } + let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ?? + estimatedAudioBytes(formats, preferredExtension: nil) ?? + 0 + return videoBytes + audioBytes + } + + private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? { + let preferred = formats + .filter { format in + guard isAudio(format) else { return false } + guard let preferredExtension else { return true } + return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame + } + .compactMap { formatSize($0) } + .max() + + if preferred != nil || preferredExtension == nil { + return preferred + } + + return estimatedAudioBytes(formats, preferredExtension: nil) + } + + private static func isVideo(_ format: RawMediaFormat) -> Bool { + guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false } + return true + } + + private static func isAudio(_ format: RawMediaFormat) -> Bool { + let acodec = format.acodec?.lowercased() + let vcodec = format.vcodec?.lowercased() + return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none") + } + + private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool { + guard let height else { return true } + guard let formatHeight = format.height else { return false } + return formatHeight <= height && formatHeight >= height - 100 + } + + private static func formatSize(_ format: RawMediaFormat) -> Int64? { + format.filesize ?? format.filesize_approx + } + + private static func optionDetail(base: String, estimatedBytes: Int64?) -> String { + guard let estimatedBytes, estimatedBytes > 0 else { return base } + return "\(base) - ~\(ByteFormatter.string(estimatedBytes))" + } + private static func videoSelector(height: Int?, container: String) -> String { let filter = heightFilter(height) switch container { diff --git a/Sources/Firelink/MediaInspectorInlineView.swift b/Sources/Firelink/MediaInspectorInlineView.swift index 85fa19d..15abb4a 100644 --- a/Sources/Firelink/MediaInspectorInlineView.swift +++ b/Sources/Firelink/MediaInspectorInlineView.swift @@ -33,7 +33,7 @@ struct MediaInspectorInlineView: View { if isLoading { ProgressView() .controlSize(.regular) - + let ytState = engineManager.ytDlpState let ffState = engineManager.ffmpegState @@ -117,6 +117,13 @@ struct MediaInspectorInlineView: View { .frame(width: 90) } } + + if let selected = resolveSelectedOption() { + Text(selected.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } } Spacer(minLength: 16) @@ -147,6 +154,10 @@ struct MediaInspectorInlineView: View { 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() } } @@ -216,6 +227,8 @@ struct MediaInspectorInlineView: View { loadTask?.cancel() isLoading = true errorMessage = nil + metadata = nil + options = [] loadTask = Task { do { @@ -233,9 +246,13 @@ struct MediaInspectorInlineView: View { guard !Task.isCancelled else { return } await MainActor.run { - self.metadata = fetchedMetadata - self.options = fetchedOptions - self.ensureValidSelection() + 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 } } diff --git a/Sources/Firelink/SchedulerController.swift b/Sources/Firelink/SchedulerController.swift index 9bd9365..588b2f1 100644 --- a/Sources/Firelink/SchedulerController.swift +++ b/Sources/Firelink/SchedulerController.swift @@ -49,7 +49,7 @@ final class SchedulerController: ObservableObject { private let defaults = UserDefaults.standard private let storageKey = "Firelink.SchedulerSettings.v1" - + // We only trigger once per minute to prevent multiple triggers in the same minute private var lastTriggeredMinute: Date? @@ -68,14 +68,14 @@ final class SchedulerController: ObservableObject { checkAutomationPermission() startTimer() - + $settings .dropFirst() .sink { _ in // We do NOT save instantly here to UserDefaults because the UI will have a "Save" button } .store(in: &cancellables) - + // Observe downloads to check if we should trigger post-action downloadController.$downloads .dropFirst() @@ -84,7 +84,7 @@ final class SchedulerController: ObservableObject { } .store(in: &cancellables) } - + func saveSettings() { if let data = try? JSONEncoder().encode(settings) { defaults.set(data, forKey: storageKey) @@ -112,7 +112,7 @@ final class SchedulerController: ObservableObject { let startHour = calendar.component(.hour, from: settings.startTime) let startMinute = calendar.component(.minute, from: settings.startTime) - + let currentHour = calendar.component(.hour, from: now) let currentMinute = calendar.component(.minute, from: now) let currentWeekday = calendar.component(.weekday, from: now) @@ -141,26 +141,26 @@ final class SchedulerController: ObservableObject { } guard !runnableQueueIDs.isEmpty else { return } - + isRunning = true - + for queueID in runnableQueueIDs { downloadController.startQueue(queueID: queueID) } - + checkIfRunningFinished() } private func checkIfRunningFinished() { guard isRunning else { return } - + let targetQueueIDs = effectiveTargetQueueIDs() let hasActiveItems = targetQueueIDs.contains { queueID in downloadController.queueItems(for: queueID).contains { $0.status == .queued || $0.status == .downloading } } - + if !hasActiveItems { isRunning = false performPostAction() @@ -173,7 +173,7 @@ final class SchedulerController: ObservableObject { private func performPostAction() { guard settings.postQueueAction != .doNothing else { return } - + var scriptCode = "" switch settings.postQueueAction { case .sleep: @@ -185,9 +185,9 @@ final class SchedulerController: ObservableObject { case .doNothing: break } - + guard !scriptCode.isEmpty else { return } - + var error: NSDictionary? if let script = NSAppleScript(source: scriptCode) { script.executeAndReturnError(&error) diff --git a/Sources/Firelink/SchedulerView.swift b/Sources/Firelink/SchedulerView.swift index b49692f..e9ff851 100644 --- a/Sources/Firelink/SchedulerView.swift +++ b/Sources/Firelink/SchedulerView.swift @@ -4,7 +4,7 @@ struct SchedulerView: View { @EnvironmentObject private var downloadController: DownloadController @EnvironmentObject private var schedulerController: SchedulerController @State private var showSaveToast: Bool = false - + // Local state to hold edits before saving @State private var isEnabled: Bool = false @State private var startTime: Date = Date() @@ -12,12 +12,12 @@ struct SchedulerView: View { @State private var selectedDays: Set = [] @State private var postQueueAction: PostQueueAction = .doNothing @State private var targetQueueIDs: Set = [] - + var body: some View { VStack(spacing: 0) { headerView Divider() - + ScrollView { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) { @@ -27,7 +27,7 @@ struct SchedulerView: View { } .opacity(isEnabled ? 1.0 : 0.5) .disabled(!isEnabled) - + Divider() permissionsSection } @@ -49,7 +49,7 @@ struct SchedulerView: View { } } } - + private var headerView: some View { HStack { Toggle(isOn: $isEnabled) { @@ -57,15 +57,15 @@ struct SchedulerView: View { .font(.title2.weight(.bold)) } .toggleStyle(.switch) - + Spacer() - + Button("Save Settings") { saveState() withAnimation(.spring()) { showSaveToast = true } - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { withAnimation { showSaveToast = false @@ -76,18 +76,18 @@ struct SchedulerView: View { } .padding(24) } - + private var timeSelectionSection: some View { VStack(alignment: .leading, spacing: 16) { Text("Start Time") .font(.headline) - + DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute]) .datePickerStyle(.stepperField) .labelsHidden() - + Toggle("Everyday", isOn: $isEveryday) - + if !isEveryday { HStack(spacing: 12) { ForEach(SchedulerDay.allCases) { day in @@ -107,12 +107,12 @@ struct SchedulerView: View { } } } - + private var queueSelectionSection: some View { VStack(alignment: .leading, spacing: 16) { Text("Queues to Start") .font(.headline) - + if downloadController.queues.isEmpty { Text("No queues available") .foregroundStyle(.secondary) @@ -134,12 +134,12 @@ struct SchedulerView: View { } } } - + private var postActionSection: some View { VStack(alignment: .leading, spacing: 16) { Text("After Completion") .font(.headline) - + Picker("Action", selection: $postQueueAction) { ForEach(PostQueueAction.allCases) { action in Text(action.rawValue).tag(action) @@ -149,17 +149,17 @@ struct SchedulerView: View { .pickerStyle(.radioGroup) } } - + private var permissionsSection: some View { VStack(alignment: .leading, spacing: 12) { Text("System Permissions") .font(.headline) - + Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - + if schedulerController.hasAutomationPermission { Button("Revoke Permissions") { schedulerController.openAutomationPermissionSettings() @@ -173,7 +173,7 @@ struct SchedulerView: View { } } } - + private var toastView: some View { VStack { Spacer() @@ -192,7 +192,7 @@ struct SchedulerView: View { } .allowsHitTesting(false) } - + private func loadState() { isEnabled = schedulerController.settings.isEnabled startTime = schedulerController.settings.startTime @@ -203,7 +203,7 @@ struct SchedulerView: View { ? [DownloadQueue.mainQueueID] : schedulerController.settings.targetQueueIDs } - + private func saveState() { schedulerController.settings.isEnabled = isEnabled schedulerController.settings.startTime = startTime diff --git a/Sources/Firelink/Settings/AboutSettingsPane.swift b/Sources/Firelink/Settings/AboutSettingsPane.swift index 9abf968..ae61099 100644 --- a/Sources/Firelink/Settings/AboutSettingsPane.swift +++ b/Sources/Firelink/Settings/AboutSettingsPane.swift @@ -66,7 +66,7 @@ struct AboutSettingsPane: View { Label("Check for Updates", systemImage: "arrow.clockwise") } .disabled(sparkleUpdater.isChecking) - + Button { NSWorkspace.shared.open(projectURL.appendingPathComponent("releases")) } label: { diff --git a/Sources/Firelink/Settings/DownloadSettingsPane.swift b/Sources/Firelink/Settings/DownloadSettingsPane.swift index b6568bf..abb9a9a 100644 --- a/Sources/Firelink/Settings/DownloadSettingsPane.swift +++ b/Sources/Firelink/Settings/DownloadSettingsPane.swift @@ -24,7 +24,7 @@ struct DownloadSettingsPane: View { .font(.caption) .foregroundStyle(.secondary) } - + Section { LabeledContent("Global speed limit") { HStack { diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index 3291534..5a4968c 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -42,25 +42,25 @@ struct EngineSettingsPane: View { .foregroundStyle(.secondary) } } - + Section("Media Engine (yt-dlp & ffmpeg)") { addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState) addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState) - + HStack(spacing: 12) { Button("Check for Updates") { Task { isCheckingForUpdates = true updateCheckResult = nil try? await Task.sleep(nanoseconds: 800_000_000) - + do { try await engineManager.ensureInstalled() updateCheckResult = "Up to date." } catch { updateCheckResult = "Update failed." } - + isCheckingForUpdates = false try? await Task.sleep(nanoseconds: 3_000_000_000) if !isCheckingForUpdates { @@ -69,7 +69,7 @@ struct EngineSettingsPane: View { } } .disabled(isDownloadingMediaEngines || isCheckingForUpdates) - + if isCheckingForUpdates { ProgressView().controlSize(.small) Text("Checking...") @@ -80,10 +80,10 @@ struct EngineSettingsPane: View { .foregroundStyle(.secondary) .font(.subheadline) } - + Spacer() } - + Picker("Browser Cookies", selection: $settings.mediaCookieSource) { ForEach(BrowserCookieSource.allCases, id: \.self) { source in Text(source.rawValue).tag(source) @@ -110,13 +110,13 @@ struct EngineSettingsPane: View { version = await Aria2DownloadEngine.versionString() ?? "Unavailable" } } - + 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 { LabeledContent(title) { diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index d14cc8d..5982baa 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -23,12 +23,12 @@ struct IntegrationSettingsPane: View { } .padding(.vertical, 4) } - + Section("Installation") { VStack(alignment: .leading, spacing: 16) { Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.") .foregroundStyle(.secondary) - + Button { if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { NSWorkspace.shared.open(url) @@ -57,7 +57,7 @@ struct IntegrationSettingsPane: View { } } } - + Section("Permissions & Privacy") { Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.") .font(.caption) diff --git a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift index 7d0df1d..382a671 100644 --- a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift +++ b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift @@ -14,29 +14,29 @@ struct LookAndFeelSettingsPane: View { } } .pickerStyle(.radioGroup) - + Text("Select a color palette for the app's user interface.") .font(.caption) .foregroundStyle(.secondary) } - + Section("Display") { Picker("Font Size", selection: $settings.appFontSize) { ForEach(AppFontSize.allCases) { size in Text(size.rawValue).tag(size) } } - + Picker("List Row Density", selection: $settings.listRowDensity) { ForEach(ListRowDensity.allCases) { density in Text(density.rawValue).tag(density) } } } - + Section("Menu Bar") { Toggle("Show menu bar icon", isOn: $showMenuBarIcon) - + Text("Provides quick access to downloads and queues from the macOS menu bar.") .font(.caption) .foregroundStyle(.secondary) diff --git a/Sources/Firelink/Settings/SettingsContainer.swift b/Sources/Firelink/Settings/SettingsContainer.swift index 119a367..a904191 100644 --- a/Sources/Firelink/Settings/SettingsContainer.swift +++ b/Sources/Firelink/Settings/SettingsContainer.swift @@ -31,7 +31,7 @@ struct SettingsPaneContainer: View { } .padding(.horizontal, 32) .padding(.vertical, 16) - + Divider() ScrollView { diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index c5d491a..bea13fb 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -98,7 +98,7 @@ struct SidebarView: View { } .buttonStyle(.plain) } - + Section("Tools") { Label("Scheduler", systemImage: "calendar.badge.clock") .tag(SidebarSelection.scheduler) diff --git a/Sources/Firelink/SpeedLimiterView.swift b/Sources/Firelink/SpeedLimiterView.swift index 6511837..03c88a8 100644 --- a/Sources/Firelink/SpeedLimiterView.swift +++ b/Sources/Firelink/SpeedLimiterView.swift @@ -3,16 +3,16 @@ import SwiftUI struct SpeedLimiterView: View { @EnvironmentObject private var settings: AppSettings @State private var showSaveToast: Bool = false - + // Local state to hold edits before saving @State private var isEnabled: Bool = false @State private var speedLimitKiBPerSecond: Int = 1024 - + var body: some View { VStack(spacing: 0) { headerView Divider() - + ScrollView { VStack(alignment: .leading, spacing: 24) { limitSelectionSection @@ -33,7 +33,7 @@ struct SpeedLimiterView: View { } } } - + private var headerView: some View { HStack { Toggle(isOn: $isEnabled) { @@ -41,15 +41,15 @@ struct SpeedLimiterView: View { .font(.title2.weight(.bold)) } .toggleStyle(.switch) - + Spacer() - + Button("Save Limit") { saveState() withAnimation(.spring()) { showSaveToast = true } - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { withAnimation { showSaveToast = false @@ -60,29 +60,29 @@ struct SpeedLimiterView: View { } .padding(24) } - + private var limitSelectionSection: some View { VStack(alignment: .leading, spacing: 16) { Text("Global Speed Limit") .font(.headline) - + Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.") .font(.subheadline) .foregroundStyle(.secondary) - + HStack { Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) { Text("Maximum Speed:") } - + TextField("Speed", value: $speedLimitKiBPerSecond, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 80) - + Text("KiB/s") .foregroundStyle(.secondary) } - + // Helpful presets HStack(spacing: 12) { Button("1 MB/s") { speedLimitKiBPerSecond = 1024 } @@ -93,7 +93,7 @@ struct SpeedLimiterView: View { .padding(.top, 8) } } - + private var toastView: some View { VStack { Spacer() @@ -112,20 +112,20 @@ struct SpeedLimiterView: View { } .allowsHitTesting(false) } - + @AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024 - + private func loadState() { let currentLimit = settings.globalSpeedLimitKiBPerSecond isEnabled = currentLimit > 0 speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit } - + private func saveState() { // Clamp to ensure it doesn't break aria2 let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1) speedLimitKiBPerSecond = clampedSpeed - + lastCustomSpeedLimit = clampedSpeed settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0 } diff --git a/generate_preview.py b/generate_preview.py deleted file mode 100644 index 7986b5c..0000000 --- a/generate_preview.py +++ /dev/null @@ -1,63 +0,0 @@ -import sys -from PIL import Image, ImageDraw - -def generate_gradient_preview(src_path, dest_path): - img = Image.open(src_path).convert("RGBA") - width, height = img.size - pixels = img.load() - - # Background color from an inner point - bg_color = pixels[100, 100] - - # Define gradient colors - # Top color: slightly lighter/richer blue (e.g. #1E2541) - # Bottom color: darker navy (e.g. #0A0D1A) - top_color = (30, 37, 65, 255) - bottom_color = (10, 13, 26, 255) - - # Create new image - new_img = Image.new("RGBA", (width, height)) - new_pixels = new_img.load() - - for y in range(height): - # Interpolate background color for this row - ratio = y / float(height - 1) - grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio) - grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio) - grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio) - grad_color = (grad_r, grad_g, grad_b, 255) - - for x in range(width): - p = pixels[x, y] - - # distance from original background color - dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2])) - - if dist < 15: - # purely background - new_pixels[x, y] = grad_color - elif dist < 60: - # anti-aliased edge, blend - alpha = (dist - 15) / 45.0 - r = int(p[0] * alpha + grad_color[0] * (1 - alpha)) - g = int(p[1] * alpha + grad_color[1] * (1 - alpha)) - b = int(p[2] * alpha + grad_color[2] * (1 - alpha)) - new_pixels[x, y] = (r, g, b, 255) - else: - # purely logo - new_pixels[x, y] = p - - # Apply rounded rectangle mask - radius = int(width * 0.225) - mask = Image.new("L", (width, height), 0) - draw = ImageDraw.Draw(mask) - draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255) - new_img.putalpha(mask) - - # Resize to something reasonable for preview - preview = new_img.resize((512, 512), Image.Resampling.LANCZOS) - preview.save(dest_path) - print("Preview saved to", dest_path) - -if __name__ == '__main__': - generate_gradient_preview(sys.argv[1], sys.argv[2]) diff --git a/process_icon.py b/process_icon.py index 3209e02..5a3395e 100644 --- a/process_icon.py +++ b/process_icon.py @@ -3,32 +3,32 @@ from PIL import Image, ImageDraw def process_images(src_path): img = Image.open(src_path).convert("RGBA") - + # Crop the 28px black padding img = img.crop((28, 28, 1226, 1226)) width, height = img.size pixels = img.load() - + # Lighter color (+1) at top, original color (0) at bottom bg_color = pixels[100, 100] # Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255) bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255) - + new_img = Image.new("RGBA", (width, height)) new_pixels = new_img.load() - + for y in range(height): ratio = y / float(height - 1) grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio) grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio) grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio) grad_color = (grad_r, grad_g, grad_b, 255) - + for x in range(width): p = pixels[x, y] dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2])) - + # Replace pure black corners or background with gradient if p[0] < 15 and p[1] < 15 and p[2] < 15: new_pixels[x, y] = grad_color @@ -42,7 +42,7 @@ def process_images(src_path): new_pixels[x, y] = (r, g, b, 255) else: new_pixels[x, y] = p - + img = new_img radius = int(width * 0.225) mask = Image.new("L", (width, height), 0) @@ -53,17 +53,17 @@ def process_images(src_path): # Save standard png img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS) img_1024.save("Resources/AppIcon.png") - + # Save Firefox extension icons img_48 = img.resize((48, 48), Image.Resampling.LANCZOS) img_48.save("Extensions/Firefox/icons/icon-48.png") img_128 = img.resize((128, 128), Image.Resampling.LANCZOS) img_128.save("Extensions/Firefox/icons/icon-128.png") - + # MenuBarIconTemplate (64x64 monochrome) data = img.getdata() new_data = [] - + for item in data: r, g, b, a = item if r > 100 and r > b * 1.5 and a > 0: @@ -71,14 +71,14 @@ def process_images(src_path): new_data.append((0, 0, 0, alpha)) else: new_data.append((0, 0, 0, 0)) - + menu_bar_full = Image.new("RGBA", img.size) menu_bar_full.putdata(new_data) - + menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS) menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png") - + print("Done generating main PNGs") - + if __name__ == '__main__': process_images(sys.argv[1]) diff --git a/test_128.png b/test_128.png deleted file mode 100644 index 09b5ca037312c092c7eee6610a7e794ff7a5da6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9514 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
eU8;>;R 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