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 09b5ca0..0000000 Binary files a/test_128.png and /dev/null differ