diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index dff0aef..3aaa253 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -27,7 +27,12 @@ struct ContentView: View { _ = provider.loadObject(ofClass: URL.self) { url, _ in if let url = url { DispatchQueue.main.async { - controller.pendingPasteboardText = url.absoluteString + let newText = url.absoluteString + if let existing = controller.pendingPasteboardText, !existing.isEmpty { + controller.pendingPasteboardText = existing + "\n" + newText + } else { + controller.pendingPasteboardText = newText + } controller.pendingReferer = nil openWindow(id: "add-downloads") } @@ -37,7 +42,11 @@ struct ContentView: View { _ = provider.loadObject(ofClass: String.self) { text, _ in if let text = text { DispatchQueue.main.async { - controller.pendingPasteboardText = text + if let existing = controller.pendingPasteboardText, !existing.isEmpty { + controller.pendingPasteboardText = existing + "\n" + text + } else { + controller.pendingPasteboardText = text + } controller.pendingReferer = nil openWindow(id: "add-downloads") } @@ -146,18 +155,24 @@ struct ContentView: View { } .keyboardShortcut(.delete, modifiers: []) .opacity(0) + .buttonStyle(.plain) + .focusable(false) Button("") { handlePaste(queueID: queueID) } .keyboardShortcut("v", modifiers: .command) .opacity(0) + .buttonStyle(.plain) + .focusable(false) Button("") { selectAll(items: items) } .keyboardShortcut("a", modifiers: .command) .opacity(0) + .buttonStyle(.plain) + .focusable(false) Button("") { if let item = selectedItems.first { @@ -166,6 +181,8 @@ struct ContentView: View { } .keyboardShortcut(.return, modifiers: []) .opacity(0) + .buttonStyle(.plain) + .focusable(false) } .confirmationDialog( "Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")", diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 230ede9..709a789 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -29,6 +29,9 @@ final class DownloadController: ObservableObject { return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json") }() private var saveTask: Task? + private var pendingNotifications: [(title: String, body: String)] = [] + private var notificationDebounceTask: Task? + private var lastProgressUpdateTimes: [UUID: Date] = [:] init(settings: AppSettings) { self.settings = settings @@ -230,6 +233,7 @@ final class DownloadController: ObservableObject { } func pause(_ item: DownloadItem) { + lastProgressUpdateTimes[item.id] = nil activeHandles[item.id]?.cancel() activeHandles[item.id] = nil activeMediaHandles[item.id]?.cancel() @@ -530,6 +534,11 @@ final class DownloadController: ObservableObject { speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem), progress: { [weak self] progress in Task { @MainActor in + let now = Date() + if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 { + return + } + self?.lastProgressUpdateTimes[item.id] = now self?.update(item.id) { guard $0.status == .downloading else { return } $0.progress = progress.fraction @@ -581,6 +590,11 @@ final class DownloadController: ObservableObject { speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), progress: { [weak self] progress in Task { @MainActor in + let now = Date() + if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 { + return + } + self?.lastProgressUpdateTimes[item.id] = now self?.update(item.id) { guard $0.status == .downloading else { return } $0.progress = progress.fraction @@ -1088,14 +1102,35 @@ final class DownloadController: ObservableObject { } private func showNotification(title: String, body: String) { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) + pendingNotifications.append((title: title, body: body)) + notificationDebounceTask?.cancel() + + notificationDebounceTask = Task { @MainActor in + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled else { return } + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in + guard granted, let self else { return } + Task { @MainActor in + let items = self.pendingNotifications + self.pendingNotifications.removeAll() + guard !items.isEmpty else { return } + + let content = UNMutableNotificationContent() + if items.count == 1 { + content.title = items[0].title + content.body = items[0].body + } else { + content.title = "\(items.count) Downloads Completed" + content.body = "Multiple items have finished downloading." + } + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + } + } catch {} } } } diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 156e0d6..d93afab 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -104,6 +104,7 @@ struct DownloadTable: View { .width(min: 100, ideal: 155) } .environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight) + .animation(.default, value: sortedItems) .contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in rowContextMenu(for: itemIDs) } primaryAction: { itemIDs in @@ -202,33 +203,37 @@ struct DownloadTable: View { Divider() - if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) { - Button { - for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled { - controller.resume(target) - } - } label: { - Label("Start", systemImage: "play.fill") - } - } + Divider() - if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) { - Button { - for target in targetItems where target.status == .downloading || target.status == .queued { - controller.pause(target) + Menu("Controls") { + if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) { + Button { + for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled { + controller.resume(target) + } + } label: { + Label("Start", systemImage: "play.fill") } - } label: { - Label("Stop", systemImage: "stop.fill") } - } - if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) { - Button { - for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled { - controller.redownload(target) + if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) { + Button { + for target in targetItems where target.status == .downloading || target.status == .queued { + controller.pause(target) + } + } label: { + Label("Stop", systemImage: "stop.fill") + } + } + + if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) { + Button { + for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled { + controller.redownload(target) + } + } label: { + Label("Redownload", systemImage: "arrow.clockwise") } - } label: { - Label("Redownload", systemImage: "arrow.clockwise") } } @@ -306,22 +311,15 @@ struct DownloadTable: View { .lineLimit(1) .truncationMode(.tail) } else { - GeometryReader { proxy in - 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) - } + HStack(spacing: 4) { + ProgressView(value: item.progress) + .progressViewStyle(.linear) + .tint(statusColor(for: item.status)) + + Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .frame(width: 35, alignment: .trailing) } - .frame(height: 16) } } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index f505d8b..185f0a6 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -56,12 +56,17 @@ final class LocalExtensionServer: @unchecked Sendable { private func handleConnection(_ connection: NWConnection) { connection.start(queue: queue) - receiveRequest(from: connection, accumulatedData: Data()) + let timeoutItem = DispatchWorkItem { [weak connection] in + connection?.cancel() + } + queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem) + receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem) } - private func receiveRequest(from connection: NWConnection, accumulatedData: Data) { + private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in guard let self else { + timeoutItem.cancel() connection.cancel() return } @@ -72,22 +77,25 @@ final class LocalExtensionServer: @unchecked Sendable { } guard error == nil, requestData.count <= Constants.maxRequestBytes else { + timeoutItem.cancel() self.sendResponse(.payloadTooLarge, connection: connection, origin: nil) return } if let request = HTTPRequest(data: requestData) { + timeoutItem.cancel() let status = self.processRequest(request) self.sendResponse(status, connection: connection, origin: request.header(named: "origin")) return } if isComplete { + timeoutItem.cancel() self.sendResponse(.badRequest, connection: connection, origin: nil) return } - self.receiveRequest(from: connection, accumulatedData: requestData) + self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem) } } diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index c1f8bf4..f46cfdb 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -161,21 +161,23 @@ final class MediaDownloadEngine: @unchecked Sendable { } let baseName = expectedURL.deletingPathExtension().lastPathComponent - guard let contents = try? FileManager.default.contentsOfDirectory( - at: item.destinationDirectory, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - return expectedURL + let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"] + + var mostRecent: URL? + var mostRecentDate: Date = .distantPast + + for ext in commonExtensions { + let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)") + if FileManager.default.fileExists(atPath: candidate.path) { + let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + if date >= mostRecentDate { + mostRecentDate = date + mostRecent = candidate + } + } } - 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 + return mostRecent ?? expectedURL } private static func cleanErrorMessage(_ message: String, status: Int32) -> String {