From 7802b612a4b0395b71d389283214b0e8fd1717de Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:29:15 +0330 Subject: [PATCH] feat: integrate settings and speed limiter into main window - Refactor SettingsView to act as a flat detail pane instead of a nested sidebar or macOS native window - Add custom horizontal tab bar for switching between setting categories - Create SpeedLimiterView and add it as a primary tool in the sidebar - Add state retention using AppStorage for settings tab selection and speed limits - Fix EXC_BAD_ACCESS in DownloadTable by using sortableSize instead of debugDescription --- Sources/Firelink/Aria2DownloadEngine.swift | 2 +- Sources/Firelink/ContentView.swift | 4 +- Sources/Firelink/DownloadController.swift | 38 + Sources/Firelink/DownloadTable.swift | 831 +++++--------------- Sources/Firelink/FirelinkApp.swift | 7 + Sources/Firelink/LocalExtensionServer.swift | 20 +- Sources/Firelink/Models.swift | 8 +- Sources/Firelink/SettingsView.swift | 151 +--- Sources/Firelink/SidebarView.swift | 75 +- Sources/Firelink/SpeedLimiterView.swift | 132 ++++ 10 files changed, 487 insertions(+), 781 deletions(-) create mode 100644 Sources/Firelink/SpeedLimiterView.swift diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index ac1ef73..8d0c88f 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -301,7 +301,7 @@ final class Aria2DownloadEngine { var lines = [ urls, " dir=\(sanitizedOptionValue(item.destinationDirectory.path))", - " out=\(sanitizedOptionValue(item.fileName))", + " out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))", " split=\(connections)", " max-connection-per-server=\(connections)" ] diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index f0f3f26..cc8d726 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -32,8 +32,10 @@ struct ContentView: View { queueView(queueID: queueID) case .scheduler: SchedulerView() + case .speedLimiter: + SpeedLimiterView() case .settings: - SettingsView() + SettingsPaneContainer() } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index e71e3fc..00b674a 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -1,6 +1,7 @@ import AppKit import Combine import Foundation +import UserNotifications @MainActor final class DownloadController: ObservableObject { @@ -106,6 +107,10 @@ final class DownloadController: ObservableObject { queueID: normalizedQueueID(queueID) ) + if let password = item.credentials?.password, !password.isEmpty { + KeychainCredentialStore.setPassword(password, for: item.id) + } + downloads.append(item) engineMessage = "Added \(fileName) to \(category.rawValue)." saveDownloads() @@ -145,6 +150,12 @@ final class DownloadController: ObservableObject { ) } + for item in items { + if let password = item.credentials?.password, !password.isEmpty { + KeychainCredentialStore.setPassword(password, for: item.id) + } + } + downloads.append(contentsOf: items) engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")." saveDownloads() @@ -317,6 +328,7 @@ final class DownloadController: ObservableObject { } else if item.status != .completed { removeCacheFiles(for: item) } + KeychainCredentialStore.deletePassword(for: item.id) downloads.removeAll { $0.id == item.id } automaticRetryCounts[item.id] = nil saveDownloads() @@ -468,6 +480,7 @@ final class DownloadController: ObservableObject { $0.autoResumeOnLaunch = false } self.saveDownloads() + self.showNotification(title: "Download Completed", body: item.fileName) 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 { @@ -523,6 +536,11 @@ final class DownloadController: ObservableObject { $0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond) $0.message = "Properties updated" } + if let password = credentials?.password, !password.isEmpty { + KeychainCredentialStore.setPassword(password, for: id) + } else if credentials == nil { + KeychainCredentialStore.deletePassword(for: id) + } saveDownloads() } @@ -577,6 +595,9 @@ final class DownloadController: ObservableObject { $0.autoResumeOnLaunch = false } saveDownloads() + if let item = downloads.first(where: { $0.id == itemID }) { + showNotification(title: "Download Failed", body: item.fileName) + } return } @@ -751,6 +772,11 @@ 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 + } + if adjusted.status == .downloading { adjusted.status = .queued adjusted.message = "Recovered after restart. Resuming from partial file." @@ -799,6 +825,18 @@ final class DownloadController: ObservableObject { } return normalized } + + 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) + } + } } private struct StoredDownloadState: Codable { diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index be51545..4fc6aba 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -2,116 +2,6 @@ import AppKit import SwiftUI import UniformTypeIdentifiers -enum DownloadColumn: String, CaseIterable, Identifiable, Codable { - case priority = "#" - case fileName = "File name" - case size = "Size" - case progress = "Progress" - case status = "Status" - case lastTry = "Last try date" - case dateAdded = "Date added" - case category = "Category" - case connections = "Connections" - case liveConnections = "Live conn." - case speed = "Speed" - case eta = "ETA" - case destination = "Save location" - case url = "URL" - case message = "Message" - - var id: String { rawValue } - - var width: CGFloat { - switch self { - case .priority: return 58 - case .fileName: return 340 - case .size: return 100 - case .status: return 105 - case .progress: return 115 - case .lastTry, .dateAdded: return 155 - case .category: return 105 - case .connections, .liveConnections: return 95 - case .speed, .eta: return 90 - case .destination: return 240 - case .url: return 280 - case .message: return 220 - } - } -} - -enum SortDirection: String, Codable { - case ascending - case descending - - mutating func toggle() { - self = self == .ascending ? .descending : .ascending - } -} - -final class TableSettings: ObservableObject { - @Published var visibleColumns: Set { - didSet { save() } - } - @Published var columnWidths: [DownloadColumn: CGFloat] { - didSet { save() } - } - @Published var sortColumn: DownloadColumn { - didSet { save() } - } - @Published var sortDirection: SortDirection { - didSet { save() } - } - - private let defaults = UserDefaults.standard - private let storageKey = "Firelink.TableSettings.v1" - private var saveTask: Task? - - init() { - let defaultVisibleColumns: Set = [.fileName, .size, .progress, .speed, .eta, .dateAdded] - let legacyDefaultVisibleColumns: Set = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded] - - if let data = defaults.data(forKey: storageKey), - let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) { - visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns - columnWidths = stored.columnWidths - sortColumn = stored.sortColumn - sortDirection = stored.sortDirection - } else { - visibleColumns = defaultVisibleColumns - columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) }) - sortColumn = .dateAdded - sortDirection = .descending - } - } - - private func save() { - let stored = StoredTableSettings( - visibleColumns: visibleColumns, - columnWidths: columnWidths, - sortColumn: sortColumn, - sortDirection: sortDirection - ) - let storageKey = self.storageKey - - saveTask?.cancel() - saveTask = Task { @MainActor in - let data = await Task.detached(priority: .background) { - try? JSONEncoder().encode(stored) - }.value - - guard !Task.isCancelled, let encoded = data else { return } - UserDefaults.standard.set(encoded, forKey: storageKey) - } - } -} - -private struct StoredTableSettings: Codable { - var visibleColumns: Set - var columnWidths: [DownloadColumn: CGFloat] - var sortColumn: DownloadColumn - var sortDirection: SortDirection -} - struct DownloadTable: View { @EnvironmentObject private var controller: DownloadController @EnvironmentObject private var settings: AppSettings @@ -121,11 +11,12 @@ struct DownloadTable: View { let title: String var queueID: UUID? - @StateObject private var tableSettings = TableSettings() + @State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)] @State private var pendingDeleteItems: Set? - @State private var dragOffsets: [DownloadColumn: CGFloat] = [:] - @State private var lastSelectedIndex: Int? - @State private var draggedItemID: DownloadItem.ID? + + var sortedItems: [DownloadItem] { + items.sorted(using: sortOrder) + } var body: some View { VStack(spacing: 0) { @@ -143,38 +34,83 @@ struct DownloadTable: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - GeometryReader { proxy in - let tableWidth = max(totalWidth, proxy.size.width) - let trailingWidth = max(0, tableWidth - totalWidth) - ScrollView(.horizontal) { - VStack(spacing: 0) { - tableHeader(trailingWidth: trailingWidth) - Divider() - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(sortedItems) { item in - tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth) - Divider() - } - } - .frame(width: tableWidth, alignment: .topLeading) - .frame(maxHeight: .infinity, alignment: .topLeading) - } - .defaultScrollAnchor(.topLeading) + Table(sortedItems, selection: $selection, sortOrder: $sortOrder) { + 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) } - .frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading) } - .overlay { - if items.isEmpty { - ContentUnavailableView( - "No Downloads", - systemImage: "arrow.down.circle", - description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.") - ) + .width(min: 200, ideal: 340) + + TableColumn("Size", value: \.sortableSize) { item in + Text(ByteFormatter.string(item.sizeBytes)) + .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) + } + .width(min: 80, ideal: 100) + + TableColumn("Progress", value: \.progress) { item in + progressBarCell(for: item) + } + .width(min: 100, ideal: 115) + + TableColumn("Status", value: \.status.rawValue) { item in + Text(item.status.rawValue) + } + .width(min: 80, ideal: 105) + + TableColumn("Speed", value: \.speedText) { item in + Text(item.speedText) + .lineLimit(1) + .truncationMode(.tail) + } + .width(min: 70, ideal: 90) + + TableColumn("ETA", value: \.etaText) { item in + Text(item.etaText) + .lineLimit(1) + .truncationMode(.tail) + } + .width(min: 70, ideal: 90) + + TableColumn("Date Added", value: \.createdAt) { item in + Text(formatted(item.createdAt)) + .lineLimit(1) + .truncationMode(.tail) + } + .width(min: 100, ideal: 155) + } + .contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in + rowContextMenu(for: itemIDs) + } primaryAction: { itemIDs in + for itemID in itemIDs { + if let item = controller.downloads.first(where: { $0.id == itemID }) { + if item.status == .completed { + openFile(item) + } else { + openWindow(value: item.id) + } } } } + .overlay { + if items.isEmpty { + ContentUnavailableView( + "No Downloads", + systemImage: "arrow.down.circle", + description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.") + ) + } + } } .confirmationDialog( "Delete Download", @@ -215,341 +151,157 @@ struct DownloadTable: View { } } - private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View { - DownloadRow( - item: item, - priorityNumber: priorityNumber(for: item), - visibleColumns: orderedVisibleColumns, - columnWidth: { width(for: $0) }, - trailingWidth: trailingWidth - ) - .id(item.id) - .frame(width: tableWidth, alignment: .leading) - .background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - if item.status == .completed { - openFile(item) - } else { - openWindow(value: item.id) - } - } - .onTapGesture { - let index = sortedItems.firstIndex(where: { $0.id == item.id }) - - if NSEvent.modifierFlags.contains(.command) { - if selection.contains(item.id) { - selection.remove(item.id) - } else { - selection.insert(item.id) - } - lastSelectedIndex = index - } else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index { - let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex) - let rangeIds = range.map { sortedItems[$0].id } - selection.formUnion(rangeIds) - } else { - selection = [item.id] - lastSelectedIndex = index - } - } - .contextMenu { - rowContextMenu(for: item) - } - .onDrag { - draggedItemID = item.id - return NSItemProvider(object: dragPayload(for: item) as NSString) - } - .onDrop( - of: [.text], - delegate: QueueDropDelegate( - item: item, - queueID: queueID, - draggedItemID: $draggedItemID, - controller: controller - ) - ) - } - - private func tableHeader(trailingWidth: CGFloat) -> some View { - HStack(spacing: 0) { - ForEach(orderedVisibleColumns) { column in - ZStack(alignment: .trailing) { - headerContent(for: column) - - Rectangle() - .fill(.secondary.opacity(0.18)) - .frame(width: 1, height: 20) - .frame(width: 8, height: 34) - .contentShape(Rectangle()) - .onHover { isHovering in - if isHovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - dragOffsets[column] = value.translation.width - } - .onEnded { value in - let baseWidth = tableSettings.columnWidths[column] ?? column.width - tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width) - dragOffsets[column] = nil - } - ) - } - .frame(width: width(for: column), height: 34) - .clipped() - } - - if trailingWidth > 0 { - Color.clear - .frame(width: trailingWidth, height: 34) - } - } - .background(.bar) - .contextMenu { - Section("Columns") { - ForEach(availableColumns) { column in - Toggle(column.rawValue, isOn: Binding( - get: { tableSettings.visibleColumns.contains(column) }, - set: { isVisible in - if isVisible { - tableSettings.visibleColumns.insert(column) - } else if tableSettings.visibleColumns.count > 1 { - tableSettings.visibleColumns.remove(column) - } - } - )) - } - } - if queueID == nil { - Section("Sort By") { - ForEach(availableColumns) { column in - Button(column.rawValue) { - tableSettings.sortColumn = column - } - } - } - } - } - } - @ViewBuilder - private func rowContextMenu(for item: DownloadItem) -> some View { - let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item] + private func rowContextMenu(for itemIDs: Set) -> some View { + let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } + + if !targetItems.isEmpty { + if targetItems.allSatisfy({ $0.status == .completed }) { + Button { + for target in targetItems { + openFile(target) + } + } label: { + Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc") + } + } - if targetItems.allSatisfy({ $0.status == .completed }) { Button { for target in targetItems { - openFile(target) + showInFinder(target) } } label: { - Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc") + Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder") } - } - Button { - for target in targetItems { - showInFinder(target) - } - } label: { - Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder") - } - - 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") - } - } - - 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") - } - } - - Divider() - - if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) { - Menu { - ForEach(controller.queues) { queue in - Button(queue.name) { - controller.assignToQueue( - itemIDs: Set(targetItems.map(\.id)), - queueID: queue.id - ) - } - } - } label: { - Label("Move to Queue", systemImage: "list.bullet") - } Divider() - } - Button { - NSPasteboard.general.clearContents() - let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n") - NSPasteboard.general.setString(urls, forType: .string) - } label: { - Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link") - } - - Button { - for target in targetItems { - openWindow(value: target.id) + 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(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle") - } - Divider() - - Button(role: .destructive) { - pendingDeleteItems = Set(targetItems.map(\.id)) - } label: { - Label("Remove", systemImage: "trash") - } - } - - private var orderedVisibleColumns: [DownloadColumn] { - var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) } - if queueID != nil, !columns.contains(.priority) { - columns.insert(.priority, at: 0) - } else if queueID == nil { - columns.removeAll { $0 == .priority } - } - return columns - } - - private var availableColumns: [DownloadColumn] { - DownloadColumn.allCases.filter { $0 != .priority } - } - - private var totalWidth: CGFloat { - orderedVisibleColumns.map { width(for: $0) }.reduce(0, +) - } - - private func width(for column: DownloadColumn) -> CGFloat { - let baseWidth = tableSettings.columnWidths[column] ?? column.width - let offset = dragOffsets[column] ?? 0 - return max(70, baseWidth + offset) - } - - private var sortedItems: [DownloadItem] { - if queueID != nil { - return items - } - - return items.sorted { lhs, rhs in - let result = compare(lhs, rhs, by: tableSettings.sortColumn) - if result == .orderedSame { - return lhs.id.uuidString < rhs.id.uuidString + 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") + } + } + + Divider() + + if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) { + Menu { + ForEach(controller.queues) { queue in + Button(queue.name) { + controller.assignToQueue( + itemIDs: Set(targetItems.map(\.id)), + queueID: queue.id + ) + } + } + } label: { + Label("Move to Queue", systemImage: "list.bullet") + } + Divider() + } + + Button { + NSPasteboard.general.clearContents() + let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n") + NSPasteboard.general.setString(urls, forType: .string) + } label: { + Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link") + } + + Button { + for target in targetItems { + openWindow(value: target.id) + } + } label: { + Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle") + } + + Divider() + + Button(role: .destructive) { + pendingDeleteItems = itemIDs + } label: { + Label("Remove", systemImage: "trash") } - return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending } } - private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult { - switch column { - case .priority: - return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0) - case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) - case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1) - case .status: return compare(lhs.status.rawValue, rhs.status.rawValue) - case .progress: return compare(lhs.progress, rhs.progress) - case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast) - case .dateAdded: return compare(lhs.createdAt, rhs.createdAt) - case .category: return compare(lhs.category.rawValue, rhs.category.rawValue) - case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer) - case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount) - case .speed: return compare(lhs.speedText, rhs.speedText) - case .eta: return compare(lhs.etaText, rhs.etaText) - case .destination: return compare(lhs.destinationPath, rhs.destinationPath) - case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString) - case .message: return compare(lhs.message, rhs.message) + private func categoryColor(for category: DownloadCategory) -> Color { + switch category { + case .musics: return .pink + case .movies: return .indigo + case .compressed: return .orange + case .pictures: return .teal + case .documents: return .blue + case .other: return .gray + } + } + + private func statusColor(for status: DownloadStatus) -> Color { + switch status { + case .queued: return .secondary + case .downloading: return .blue + case .paused: return .orange + case .completed: return .green + case .failed: return .red + case .canceled: return .gray } } @ViewBuilder - private func headerContent(for column: DownloadColumn) -> some View { - let content = HStack(spacing: 4) { - Spacer(minLength: 0) - Text(column.rawValue) - .font(.caption.weight(.semibold)) + private func progressBarCell(for item: DownloadItem) -> some View { + if item.status == .completed { + Text("Completed") + .foregroundStyle(.green) .lineLimit(1) .truncationMode(.tail) - .multilineTextAlignment(.center) - .layoutPriority(1) - if queueID == nil && tableSettings.sortColumn == column { - Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down") - .font(.caption2) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .frame(width: width(for: column), height: 34, alignment: .center) - .clipped() - - if queueID == nil { - Button { - if tableSettings.sortColumn == column { - tableSettings.sortDirection.toggle() - } else { - tableSettings.sortColumn = column - tableSettings.sortDirection = .ascending - } - } label: { - content - } - .buttonStyle(.plain) } else { - content + 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) + } + } + .frame(height: 16) } } - private func priorityNumber(for item: DownloadItem) -> Int? { - guard queueID != nil, - let index = items.firstIndex(where: { $0.id == item.id }) else { - return nil - } - return index + 1 - } - - private func dragPayload(for item: DownloadItem) -> String { - let draggedIDs = selection.contains(item.id) ? selection : [item.id] - return draggedIDs - .map(\.uuidString) - .joined(separator: "\n") - } - - private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { - if lhs < rhs { return .orderedAscending } - if lhs > rhs { return .orderedDescending } - return .orderedSame + private func formatted(_ date: Date?) -> String { + guard let date else { return "-" } + return date.formatted(date: .abbreviated, time: .shortened) } private func showInFinder(_ item: DownloadItem) { @@ -582,184 +334,3 @@ struct DownloadTable: View { } } } - -private struct DownloadRow: View { - @EnvironmentObject private var settings: AppSettings - let item: DownloadItem - let priorityNumber: Int? - let visibleColumns: [DownloadColumn] - let columnWidth: (DownloadColumn) -> CGFloat - let trailingWidth: CGFloat - - var body: some View { - HStack(alignment: .top, spacing: 0) { - ForEach(visibleColumns) { column in - cell(for: column) - .padding(.horizontal, 8) - .padding(.vertical, settings.listRowDensity.verticalPadding) - .frame(width: columnWidth(column), alignment: alignment(for: column)) - .clipped() - } - - if trailingWidth > 0 { - Color.clear - .frame(width: trailingWidth) - } - } - } - - @ViewBuilder - private func cell(for column: DownloadColumn) -> some View { - switch column { - case .priority: - Text(priorityNumber.map(String.init) ?? "-") - .monospacedDigit() - .foregroundStyle(.secondary) - .lineLimit(1) - case .fileName: - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor) - .frame(width: 22) - VStack(alignment: .leading, spacing: 6) { - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - case .size: - Text(ByteFormatter.string(item.sizeBytes)) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - case .status, .progress: - progressBarCell() - case .lastTry: - Text(formatted(item.lastTryAt)) - .lineLimit(1) - .truncationMode(.tail) - case .dateAdded: - Text(formatted(item.createdAt)) - .lineLimit(1) - .truncationMode(.tail) - case .category: - Text(item.category.rawValue) - .lineLimit(1) - .truncationMode(.tail) - case .connections: - Text("\(item.connectionsPerServer)") - .monospacedDigit() - .lineLimit(1) - case .liveConnections: - Text("\(item.connectionCount)") - .monospacedDigit() - .lineLimit(1) - case .speed: - Text(item.speedText) - .lineLimit(1) - .truncationMode(.tail) - case .eta: - Text(item.etaText) - .lineLimit(1) - .truncationMode(.tail) - case .destination: - Text(item.destinationPath) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - case .url: - Text(item.url.absoluteString) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - case .message: - Text(item.message) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - private func alignment(for column: DownloadColumn) -> Alignment { - column == .fileName ? .leading : .center - } - - private var categoryColor: Color { - switch item.category { - case .musics: return .pink - case .movies: return .indigo - case .compressed: return .orange - case .pictures: return .teal - case .documents: return .blue - case .other: return .gray - } - } - - private var statusColor: Color { - switch item.status { - case .queued: return .secondary - case .downloading: return .blue - case .paused: return .orange - case .completed: return .green - case .failed: return .red - case .canceled: return .gray - } - } - - @ViewBuilder - private func progressBarCell() -> some View { - if item.status == .completed { - Text("Completed") - .foregroundStyle(.green) - .lineLimit(1) - .truncationMode(.tail) - } else { - GeometryReader { proxy in - ZStack { - RoundedRectangle(cornerRadius: 4) - .fill(Color.secondary.opacity(0.15)) - - RoundedRectangle(cornerRadius: 4) - .fill(statusColor) - .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) - } - } - .frame(height: 16) - } - } - - private func formatted(_ date: Date?) -> String { - guard let date else { return "-" } - return date.formatted(date: .abbreviated, time: .shortened) - } -} - -private struct QueueDropDelegate: DropDelegate { - let item: DownloadItem - let queueID: UUID? - @Binding var draggedItemID: DownloadItem.ID? - let controller: DownloadController - - func dropEntered(info: DropInfo) { - guard let queueID, - let draggedItemID, - draggedItemID != item.id else { - return - } - - controller.moveDownload(draggedItemID, before: item.id, in: queueID) - } - - func performDrop(info: DropInfo) -> Bool { - draggedItemID = nil - return queueID != nil - } -} diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index e5f518d..94154e1 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -59,6 +59,13 @@ struct FirelinkApp: App { .commands { CommandGroup(after: .newItem) { + Button("Add Downloads...") { + NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) + } + .keyboardShortcut("n", modifiers: [.command]) + + Divider() + Button("Start Queue") { controller.startQueue() } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 341c10e..6a5df70 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -17,14 +17,24 @@ final class LocalExtensionServer: @unchecked Sendable { init?(downloadController: DownloadController) { self.downloadController = downloadController let parameters = NWParameters.tcp - parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: Constants.port) - do { - listener = try NWListener(using: parameters) - } catch { - print("Failed to create listener: \(error)") + var createdListener: NWListener? + for portValue in 6412...6422 { + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!) + do { + createdListener = try NWListener(using: parameters) + break + } catch { + continue + } + } + + guard let createdListener else { + print("Failed to create listener on ports 6412-6422") return nil } + + self.listener = createdListener } func start() { diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index 379bbe7..943ba58 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -191,6 +191,10 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { destinationDirectory.appendingPathComponent(fileName).path } + var sortableSize: Int64 { + sizeBytes ?? 0 + } + var transferOptions: DownloadTransferOptions { DownloadTransferOptions( checksum: checksum, @@ -209,7 +213,9 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { var redactedForPersistence: DownloadItem { var item = self - item.credentials = nil + if item.credentials != nil { + item.credentials?.password = "" + } item.cookieHeader = nil item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue } return item diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index c5f117e..ae78847 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -1,107 +1,52 @@ import AppKit import SwiftUI -private enum SettingsSection: String, CaseIterable, Hashable { - case downloads = "Downloads" - case locations = "Locations" - case lookAndFeel = "Look and feel" - case network = "Network" - case siteLogins = "Site Logins" - case power = "Power" - case engine = "Engine" - case integration = "Integrations" - case about = "About" - - static let orderedCases: [SettingsSection] = [ - .downloads, - .locations, - .lookAndFeel, - .network, - .siteLogins, - .power, - .engine, - .integration, - .about - ] - - var symbolName: String { - switch self { - case .downloads: "arrow.down.circle" - case .lookAndFeel: "paintpalette" - case .network: "network" - case .locations: "folder" - case .siteLogins: "key.fill" - case .power: "moon.zzz" - case .engine: "terminal" - case .integration: "puzzlepiece.extension" - case .about: "info.circle" - } - } - - var groupTitle: String { - switch self { - case .engine, .integration, .about: - "App" - default: - "Preferences" - } - } -} - -struct SettingsView: View { - @EnvironmentObject private var settings: AppSettings - @State private var selection: SettingsSection = .downloads +struct SettingsPaneContainer: View { + @AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads var body: some View { - VStack(alignment: .leading, spacing: 0) { - Text("Settings") - .font(.largeTitle.weight(.semibold)) - .padding(.horizontal, 28) - .padding(.top, 26) - .padding(.bottom, 18) - + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in + Button { + activeTab = filter + } label: { + Text(filter.rawValue) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background(activeTab == filter ? Color.accentColor : Color.clear) + .foregroundStyle(activeTab == filter ? Color.white : Color.primary) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 32) + .padding(.vertical, 16) + } + Divider() - HStack(spacing: 0) { - settingsSidebar + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(activeTab.rawValue) + .font(.largeTitle.weight(.semibold)) + .padding(.bottom, 24) - Divider() - - ScrollView { selectedPane .frame(maxWidth: 720, alignment: .leading) - .padding(28) - .frame(maxWidth: .infinity, alignment: .leading) } + .padding(32) + .frame(maxWidth: .infinity, alignment: .leading) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .themeBackground(settings.appTheme.theme.background) - } - - private var settingsSidebar: some View { - List(selection: $selection) { - Section("Preferences") { - ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in - Label(section.rawValue, systemImage: section.symbolName) - .tag(section) - } - } - - Section("App") { - ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in - Label(section.rawValue, systemImage: section.symbolName) - .tag(section) - } - } - } - .listStyle(.sidebar) - .frame(width: 210) + .frame(maxWidth: .infinity, maxHeight: .infinity) } @ViewBuilder private var selectedPane: some View { - switch selection { + switch activeTab { case .downloads: DownloadSettingsPane() case .lookAndFeel: @@ -488,41 +433,9 @@ private struct DownloadSettingsPane: View { .foregroundStyle(.secondary) } - Section("Bandwidth") { - Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled) - .toggleStyle(.switch) - - Stepper( - "Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s", - value: globalSpeedLimitValue, - in: 1...10_485_760, - step: 128 - ) - .disabled(settings.globalSpeedLimitKiBPerSecond == 0) - - Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.") - .font(.caption) - .foregroundStyle(.secondary) - } } .formStyle(.grouped) } - - private var globalSpeedLimitEnabled: Binding { - Binding { - settings.globalSpeedLimitKiBPerSecond > 0 - } set: { isEnabled in - settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0 - } - } - - private var globalSpeedLimitValue: Binding { - Binding { - max(settings.globalSpeedLimitKiBPerSecond, 1) - } set: { newValue in - settings.globalSpeedLimitKiBPerSecond = newValue - } - } } private struct LocationsSettingsPane: View { diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index 6f5593d..c5d491a 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -21,10 +21,37 @@ enum DownloadSidebarFilter: Hashable { } } +enum SettingsSidebarFilter: String, CaseIterable, Hashable { + case downloads = "Downloads" + case lookAndFeel = "Look and feel" + case network = "Network" + case locations = "Locations" + case siteLogins = "Site Logins" + case power = "Power" + case engine = "Engine" + case integration = "Integrations" + case about = "About" + + var symbolName: String { + switch self { + case .downloads: "arrow.down.circle" + case .lookAndFeel: "paintpalette" + case .network: "network" + case .locations: "folder" + case .siteLogins: "key.fill" + case .power: "moon.zzz" + case .engine: "terminal" + case .integration: "puzzlepiece.extension" + case .about: "info.circle" + } + } +} + enum SidebarSelection: Hashable { case downloads(DownloadSidebarFilter) case queue(UUID) case scheduler + case speedLimiter case settings } @@ -75,9 +102,33 @@ struct SidebarView: View { Section("Tools") { Label("Scheduler", systemImage: "calendar.badge.clock") .tag(SidebarSelection.scheduler) + Label("Speed Limiter", systemImage: "speedometer") + .tag(SidebarSelection.speedLimiter) } + } .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + Button { + selection = .settings + } label: { + Label("Settings", systemImage: "gearshape") + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(selection == .settings ? Color.accentColor : Color.clear) + .foregroundStyle(selection == .settings ? Color.white : Color.primary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .background(.regularMaterial) + } .alert("Rename Queue", isPresented: Binding( get: { queueBeingRenamed != nil }, set: { isPresented in @@ -122,30 +173,6 @@ struct SidebarView: View { } message: { queue in Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.") } - .safeAreaInset(edge: .bottom) { - VStack(spacing: 8) { - Divider() - Button { - selection = .settings - } label: { - Label("Settings", systemImage: "gearshape") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - .background { - if selection == .settings { - RoundedRectangle(cornerRadius: 6) - .fill(Color.accentColor.opacity(0.14)) - } - } - .padding(.horizontal, 8) - .padding(.bottom, 8) - } - .background(.bar) - } } private func folderRow(for category: DownloadCategory) -> some View { diff --git a/Sources/Firelink/SpeedLimiterView.swift b/Sources/Firelink/SpeedLimiterView.swift new file mode 100644 index 0000000..6511837 --- /dev/null +++ b/Sources/Firelink/SpeedLimiterView.swift @@ -0,0 +1,132 @@ +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 + .opacity(isEnabled ? 1.0 : 0.5) + .disabled(!isEnabled) + } + .padding(24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { + loadState() + } + .overlay { + if showSaveToast { + toastView + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + + private var headerView: some View { + HStack { + Toggle(isOn: $isEnabled) { + Text("Speed Limiter") + .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 + } + } + } + .buttonStyle(.borderedProminent) + } + .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 } + Button("5 MB/s") { speedLimitKiBPerSecond = 5120 } + Button("10 MB/s") { speedLimitKiBPerSecond = 10240 } + } + .buttonStyle(.bordered) + .padding(.top, 8) + } + } + + private var toastView: some View { + VStack { + Spacer() + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Speed Limit Saved") + .fontWeight(.medium) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .clipShape(Capsule()) + .shadow(radius: 4, y: 2) + .padding(.bottom, 30) + } + .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 + } +}