From 2e78bfe588e67ee22a92e3ddd16b93e553368a14 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:08:07 +0330 Subject: [PATCH] feat: add download table controls --- README.md | 8 +- Sources/Firelink/AddDownloadsView.swift | 402 +++++++++++++++ Sources/Firelink/AppSettings.swift | 22 + Sources/Firelink/Aria2DownloadEngine.swift | 11 +- Sources/Firelink/ContentView.swift | 461 ++++++++++++++---- Sources/Firelink/DownloadController.swift | 81 ++- .../Firelink/DownloadMetadataFetcher.swift | 112 +++++ Sources/Firelink/DownloadPropertiesView.swift | 216 ++++++++ Sources/Firelink/FirelinkApp.swift | 19 + Sources/Firelink/Models.swift | 26 +- Sources/Firelink/SettingsView.swift | 15 +- 11 files changed, 1267 insertions(+), 106 deletions(-) create mode 100644 Sources/Firelink/AddDownloadsView.swift create mode 100644 Sources/Firelink/DownloadMetadataFetcher.swift create mode 100644 Sources/Firelink/DownloadPropertiesView.swift diff --git a/README.md b/README.md index 684681d..39bc000 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ This project is early, but it already has a working native prototype and an `ari ## Features - Native SwiftUI macOS interface. -- Segmented downloads with 16-32 requested parts per file. +- Segmented downloads with a per-file connection count that also controls the split count. - Multiple files downloading at the same time. - Queue-based downloads with drag-and-drop priority ordering. - Native macOS Settings window, available from App menu > Settings and the main toolbar. -- Configurable per-server connection count. +- Configurable default per-server connection count. +- Configurable parallel file download limit in Settings. +- Per-batch connection controls in the Add Downloads window. - Automatic save folders under `~/Downloads`: - `Musics` - `Movies` @@ -31,7 +33,7 @@ This project is early, but it already has a working native prototype and an `ari This first version uses `aria2c` as the download engine. It is a better fit than plain `curl` for the requested IDM/FDM-style behavior because it has segmented downloads, resumable transfers, concurrent downloads, HTTP/FTP/SFTP support, and username/password options built in. -The UI allows 16-32 requested parts. For ordinary same-host HTTP downloads, Firelink currently caps `aria2c`'s per-server connection count at 16 while still setting the requested split count. This keeps behavior aligned with common server limits and `aria2c`'s stable controls. +Firelink uses one per-file connection value for both `aria2c` split count and same-server connection count. That keeps the download behavior close to the familiar IDM-style model: choosing 8 connections splits the file into 8 parallel segments. ## Requirements diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift new file mode 100644 index 0000000..9cbce79 --- /dev/null +++ b/Sources/Firelink/AddDownloadsView.swift @@ -0,0 +1,402 @@ +import SwiftUI + +struct AddDownloadsView: View { + @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings + @Environment(\.dismiss) private var dismiss + + @State private var linkText = "" + @State private var pendingDownloads: [PendingDownload] = [] + @State private var connectionsPerServer = 16.0 + @State private var overrideDestination = false + @State private var destinationPath = "" + @State private var metadataTask: Task? + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + linkSection + optionsSection + summarySection + previewSection + } + .padding(20) + } + + Divider() + actionBar + } + .frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740) + .onChange(of: linkText) { _, newValue in + scheduleMetadataRefresh(for: newValue) + } + .onAppear { + connectionsPerServer = Double(settings.perServerConnections) + } + .onDisappear { + metadataTask?.cancel() + } + } + + private var linkSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Download Links", systemImage: "link") + .font(.headline) + + TextEditor(text: $linkText) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .background(.quaternary.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(minHeight: 140) + + HStack { + Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected") + .foregroundStyle(.secondary) + Spacer() + Button { + refreshMetadata(for: linkText) + } label: { + Label("Refresh Metadata", systemImage: "arrow.clockwise") + } + .disabled(DownloadURLParser.parse(linkText).isEmpty) + } + .font(.caption) + } + } + + private var optionsSection: some View { + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) { + GridRow { + Label("Save Location", systemImage: "folder") + .font(.headline) + Toggle("Use one folder for all files", isOn: $overrideDestination) + } + + GridRow { + Text("") + HStack(spacing: 10) { + TextField("Automatic by file type", text: $destinationPath) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .disabled(!overrideDestination) + + Button { + selectDestination() + } label: { + Label("Select", systemImage: "folder.badge.plus") + } + .disabled(!overrideDestination) + } + } + + GridRow { + Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted") + .font(.headline) + VStack(alignment: .leading, spacing: 4) { + HStack { + Slider(value: $connectionsPerServer, in: 1...16, step: 1) + .frame(width: 220) + Text("\(Int(connectionsPerServer)) segments") + .monospacedDigit() + .frame(width: 130, alignment: .leading) + } + Text("Firelink splits each file into this many parallel segments.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var summarySection: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + GridRow { + SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc") + SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive") + SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive") + SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle") + } + } + } + + private var previewSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Preview", systemImage: "list.bullet.rectangle") + .font(.headline) + + Table(pendingDownloads) { + TableColumn("File") { item in + HStack { + Image(systemName: item.category.symbolName) + .foregroundStyle(categoryColor(item.category)) + VStack(alignment: .leading, spacing: 2) { + Text(item.fileName) + .lineLimit(1) + Text(item.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + TableColumn("Size") { item in + Text(ByteFormatter.string(item.sizeBytes)) + .monospacedDigit() + } + .width(95) + + TableColumn("Save To") { item in + Text(destinationDirectory(for: item).path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + TableColumn("Status") { item in + MetadataStatusView(state: item.state) + } + .width(130) + } + .frame(minHeight: 230) + } + } + + private var actionBar: some View { + HStack { + Text(actionMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Button("Cancel") { + dismiss() + } + Button { + addDownloads(start: false) + } label: { + Label("Add to Queue", systemImage: "list.bullet") + } + .disabled(pendingDownloads.isEmpty) + + Button { + addDownloads(start: true) + } label: { + Label("Start Downloads", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(pendingDownloads.isEmpty) + } + .padding(14) + .background(.bar) + } + + private var requiredSpaceText: String { + let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +) + guard knownBytes > 0 else { return "Unknown" } + return ByteFormatter.string(knownBytes) + } + + private var unknownSizeCount: Int { + pendingDownloads.filter { $0.sizeBytes == nil }.count + } + + private var freeSpaceText: String { + guard let bytes = availableCapacity() else { return "Unknown" } + return ByteFormatter.string(bytes) + } + + private var actionMessage: String { + if pendingDownloads.isEmpty { + return "Paste one or more HTTP, HTTPS, FTP, or SFTP links." + } + + if unknownSizeCount > 0 { + return "Some servers did not report file size before download." + } + + return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")." + } + + private func scheduleMetadataRefresh(for text: String) { + metadataTask?.cancel() + metadataTask = Task { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + await MainActor.run { + refreshMetadata(for: text) + } + } + } + + private func refreshMetadata(for text: String) { + let urls = DownloadURLParser.parse(text) + metadataTask?.cancel() + + pendingDownloads = urls.map { url in + let fileName = FileClassifier.fileName(from: url) + let category = FileClassifier.category(forFileName: fileName) + return PendingDownload( + url: url, + fileName: fileName, + category: category, + defaultDirectory: settings.destinationDirectory(for: category), + state: .loading + ) + } + + metadataTask = Task { + var loaded: [PendingDownload] = [] + for url in urls { + guard !Task.isCancelled else { return } + let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings) + loaded.append(item) + await MainActor.run { + for loadedItem in loaded { + if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) { + pendingDownloads[index] = loadedItem + } + } + } + } + } + } + + private func selectDestination() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + + if let currentURL = overrideDirectory { + panel.directoryURL = currentURL + } + + if panel.runModal() == .OK, let url = panel.url { + destinationPath = url.path + overrideDestination = true + } + } + + private func addDownloads(start: Bool) { + controller.addPendingDownloads( + pendingDownloads, + connectionsPerServer: Int(connectionsPerServer), + overrideDirectory: overrideDirectory, + startImmediately: start + ) + dismiss() + } + + private var overrideDirectory: URL? { + guard overrideDestination else { return nil } + let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true) + } + + private func destinationDirectory(for item: PendingDownload) -> URL { + overrideDirectory ?? item.defaultDirectory + } + + private func availableCapacity() -> Int64? { + let urls = pendingDownloads.isEmpty + ? [settings.destinationDirectory(for: .other)] + : pendingDownloads.map { destinationDirectory(for: $0) } + + return urls.compactMap { url in + let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ]) + if let important = values?.volumeAvailableCapacityForImportantUsage { + return important + } + if let available = values?.volumeAvailableCapacity { + return Int64(available) + } + return nil + } + .min() + } + + private func existingVolumeURL(for url: URL) -> URL { + var candidate = url + while !FileManager.default.fileExists(atPath: candidate.path) { + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + return URL(fileURLWithPath: NSHomeDirectory()) + } + candidate = parent + } + return candidate + } + + private func categoryColor(_ category: DownloadCategory) -> Color { + switch category { + case .musics: .pink + case .movies: .indigo + case .compressed: .orange + case .pictures: .teal + case .documents: .blue + case .other: .gray + } + } +} + +private struct SummaryTile: View { + let title: String + let value: String + let symbolName: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: symbolName) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.headline.monospacedDigit()) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(12) + .frame(width: 190) + .frame(minHeight: 64) + .background(.quaternary.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +private struct MetadataStatusView: View { + let state: PendingDownload.MetadataState + + var body: some View { + switch state { + case .pending: + Label("Pending", systemImage: "clock") + .foregroundStyle(.secondary) + case .loading: + HStack { + ProgressView() + .controlSize(.small) + Text("Checking") + } + .foregroundStyle(.secondary) + case .loaded: + Label("Ready", systemImage: "checkmark.circle") + .foregroundStyle(.green) + case .failed: + Label("Unknown", systemImage: "exclamationmark.circle") + .foregroundStyle(.orange) + } + } +} diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 509176b..5e521c4 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -18,6 +18,16 @@ final class AppSettings: ObservableObject { } } + @Published var maxConcurrentDownloads: Int { + didSet { + let clamped = min(max(maxConcurrentDownloads, 1), 12) + if maxConcurrentDownloads != clamped { + maxConcurrentDownloads = clamped + } + save() + } + } + @Published var preventsSleepWhileDownloading: Bool { didSet { save() } } @@ -41,11 +51,13 @@ final class AppSettings: ObservableObject { if let data = defaults.data(forKey: storageKey), let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) { perServerConnections = min(max(stored.perServerConnections, 1), 16) + maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12) preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading siteLogins = stored.siteLogins downloadDirectories = Self.decodeDirectories(stored.downloadDirectories) } else { perServerConnections = 16 + maxConcurrentDownloads = 3 preventsSleepWhileDownloading = true siteLogins = [] downloadDirectories = Self.defaultDirectories() @@ -106,9 +118,18 @@ final class AppSettings: ObservableObject { return DownloadCredentials(username: login.username, password: password) } + func credentials(for login: SiteLogin) -> DownloadCredentials? { + guard let password = KeychainCredentialStore.password(for: login.id) else { + return nil + } + + return DownloadCredentials(username: login.username, password: password) + } + private func save() { let stored = StoredSettings( perServerConnections: perServerConnections, + maxConcurrentDownloads: maxConcurrentDownloads, preventsSleepWhileDownloading: preventsSleepWhileDownloading, downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), siteLogins: siteLogins @@ -165,6 +186,7 @@ final class AppSettings: ObservableObject { private struct StoredSettings: Codable { var perServerConnections: Int + var maxConcurrentDownloads: Int? var preventsSleepWhileDownloading: Bool var downloadDirectories: [String: String] var siteLogins: [SiteLogin] diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index a75c0fc..4bbd99f 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -55,7 +55,6 @@ final class Aria2DownloadEngine { func start( item: DownloadItem, - perServerConnections: Int, progress: @escaping @Sendable (DownloadProgress) -> Void, completion: @escaping @Sendable (Result) -> Void ) throws -> Handle { @@ -122,7 +121,7 @@ final class Aria2DownloadEngine { do { try process.run() - if let input = inputFileContent(for: item, perServerConnections: perServerConnections).data(using: .utf8) { + if let input = inputFileContent(for: item).data(using: .utf8) { inputPipe.fileHandleForWriting.write(input) } inputPipe.fileHandleForWriting.closeFile() @@ -151,14 +150,14 @@ final class Aria2DownloadEngine { ] } - private func inputFileContent(for item: DownloadItem, perServerConnections: Int) -> String { - let sameHostConnections = min(max(perServerConnections, 1), item.parts) + private func inputFileContent(for item: DownloadItem) -> String { + let connections = min(max(item.connectionsPerServer, 1), 16) var lines = [ sanitizedOptionValue(item.url.absoluteString), " dir=\(sanitizedOptionValue(item.destinationDirectory.path))", " out=\(sanitizedOptionValue(item.fileName))", - " split=\(item.parts)", - " max-connection-per-server=\(sameHostConnections)" + " split=\(connections)", + " max-connection-per-server=\(connections)" ] if let credentials = item.credentials, !credentials.isEmpty { diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index ad801d4..2eca254 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -1,29 +1,35 @@ +import AppKit import SwiftUI struct ContentView: View { @EnvironmentObject private var controller: DownloadController - @State private var urlText = "" - @State private var parts = 16.0 + @Environment(\.openWindow) private var openWindow @State private var selection: DownloadItem.ID? + @State private var sidebarFilter: DownloadSidebarFilter = .all var body: some View { NavigationSplitView { - SidebarView() + SidebarView(selection: $sidebarFilter) .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260) } detail: { VStack(spacing: 0) { - AddDownloadBar( - urlText: $urlText, - parts: $parts, - addAction: addDownload + DownloadTable( + items: filteredDownloads, + selection: $selection, + title: sidebarFilter.title ) Divider() - DownloadTable(selection: $selection) - Divider() StatusBar() } .toolbar { ToolbarItemGroup { + Button { + openWindow(id: "add-downloads") + } label: { + Label("Add", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + Button { controller.startQueue() } label: { @@ -39,7 +45,7 @@ struct ContentView: View { Button { controller.pause(selectedItem) } label: { - Label("Pause", systemImage: "pause.fill") + Label("Stop", systemImage: "stop.fill") } } else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled { Button { @@ -65,36 +71,73 @@ struct ContentView: View { return controller.downloads.first(where: { $0.id == selection }) } - private func addDownload() { - controller.add( - urlText: urlText, - parts: Int(parts) - ) - if controller.engineMessage.hasPrefix("Added") { - urlText = "" + private var filteredDownloads: [DownloadItem] { + switch sidebarFilter { + case .all: + controller.downloads + case .queued: + controller.downloads.filter { $0.status == .queued } + case .active: + controller.downloads.filter { $0.status == .downloading } + case .completed: + controller.downloads.filter { $0.status == .completed } + case .failed: + controller.downloads.filter { $0.status == .failed } + case .category(let category): + controller.downloads.filter { $0.category == category } + } + } +} + +enum DownloadSidebarFilter: Hashable { + case all + case queued + case active + case completed + case failed + case category(DownloadCategory) + + var title: String { + switch self { + case .all: "All Downloads" + case .queued: "Queue" + case .active: "Active" + case .completed: "Completed" + case .failed: "Failed" + case .category(let category): category.rawValue } } } private struct SidebarView: View { @EnvironmentObject private var controller: DownloadController + @Binding var selection: DownloadSidebarFilter var body: some View { - List { + List(selection: $selection) { Section("Library") { + Label("All", systemImage: "tray.full") + .badge(controller.downloads.count) + .tag(DownloadSidebarFilter.all) Label("Queue", systemImage: "list.bullet") .badge(controller.queuedCount) + .tag(DownloadSidebarFilter.queued) Label("Active", systemImage: "bolt.fill") .badge(controller.activeCount) + .tag(DownloadSidebarFilter.active) Label("Completed", systemImage: "checkmark.circle") .badge(controller.completedCount) + .tag(DownloadSidebarFilter.completed) Label("Failed", systemImage: "exclamationmark.triangle") .badge(controller.failedCount) + .tag(DownloadSidebarFilter.failed) } Section("Folders") { ForEach(DownloadCategory.allCases, id: \.self) { category in Label(category.rawValue, systemImage: category.symbolName) + .badge(controller.downloads.filter { $0.category == category }.count) + .tag(DownloadSidebarFilter.category(category)) } } @@ -104,79 +147,307 @@ private struct SidebarView: View { .foregroundStyle(controller.hasAria2 ? .green : .orange) Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing") } - Stepper("Parallel files: \(controller.maxConcurrentDownloads)", value: $controller.maxConcurrentDownloads, in: 1...12) } } .listStyle(.sidebar) } } -private struct AddDownloadBar: View { - @Binding var urlText: String - @Binding var parts: Double - let addAction: () -> Void +enum DownloadColumn: String, CaseIterable, Identifiable { + case fileName = "File name" + case size = "Size" + case status = "Status" + case progress = "Progress" + 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 body: some View { - VStack(spacing: 12) { - HStack(spacing: 10) { - TextField("Download URL", text: $urlText) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) + var id: String { rawValue } - Button(action: addAction) { - Label("Add", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - .disabled(urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - HStack(spacing: 12) { - HStack { - Image(systemName: "square.split.2x2") - Slider(value: $parts, in: 16...32, step: 1) - .frame(width: 160) - Text("\(Int(parts)) parts") - .monospacedDigit() - .frame(width: 62, alignment: .trailing) - } - - Spacer() - } - .font(.callout) + var width: CGFloat { + switch self { + case .fileName: 320 + case .size: 100 + case .status: 105 + case .progress: 95 + case .lastTry, .dateAdded: 155 + case .category: 105 + case .connections, .liveConnections: 95 + case .speed, .eta: 90 + case .destination: 240 + case .url: 280 + case .message: 220 } - .padding(14) - .background(.background) + } +} + +enum SortDirection { + case ascending + case descending + + mutating func toggle() { + self = self == .ascending ? .descending : .ascending } } private struct DownloadTable: View { @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings + @Environment(\.openWindow) private var openWindow + let items: [DownloadItem] @Binding var selection: DownloadItem.ID? + let title: String + + @State private var visibleColumns: Set = [ + .fileName, .size, .status, .progress, .lastTry, .dateAdded, .speed, .eta, .message + ] + @State private var sortColumn: DownloadColumn = .dateAdded + @State private var sortDirection: SortDirection = .descending var body: some View { - List(selection: $selection) { - ForEach(controller.downloads) { item in - DownloadRow(item: item) - .tag(item.id) + VStack(spacing: 0) { + HStack { + Text(title) + .font(.headline) + Text("\(items.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + tableHeader + Divider() + ScrollView([.horizontal, .vertical]) { + LazyVStack(spacing: 0) { + ForEach(sortedItems) { item in + DownloadRow(item: item, visibleColumns: orderedVisibleColumns) + .id(item.id) + .background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + selection = item.id + } + .contextMenu { + rowContextMenu(for: item) + } + Divider() + } + } + .frame(minWidth: totalWidth, alignment: .topLeading) + } + .overlay { + if items.isEmpty { + ContentUnavailableView( + "No Downloads", + systemImage: "arrow.down.circle", + description: Text("Use Add to paste one or more links.") + ) + } } - .onMove(perform: controller.move) - .onDelete(perform: controller.remove) } - .listStyle(.inset) + } + + private var tableHeader: some View { + HStack(spacing: 0) { + ForEach(orderedVisibleColumns) { column in + Button { + if sortColumn == column { + sortDirection.toggle() + } else { + sortColumn = column + sortDirection = .ascending + } + } label: { + HStack(spacing: 4) { + Text(column.rawValue) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if sortColumn == column { + Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down") + .font(.caption2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(width: column.width, height: 34, alignment: .leading) + } + .buttonStyle(.plain) + } + } + .frame(minWidth: totalWidth, alignment: .leading) + .background(.bar) + .contextMenu { + Section("Columns") { + ForEach(DownloadColumn.allCases) { column in + Toggle(column.rawValue, isOn: Binding( + get: { visibleColumns.contains(column) }, + set: { isVisible in + if isVisible { + visibleColumns.insert(column) + } else if visibleColumns.count > 1 { + visibleColumns.remove(column) + } + } + )) + } + } + Section("Sort By") { + ForEach(DownloadColumn.allCases) { column in + Button(column.rawValue) { + sortColumn = column + } + } + } + } + } + + @ViewBuilder + private func rowContextMenu(for item: DownloadItem) -> some View { + Button { + openWindow(value: item.id) + } label: { + Label("Properties", systemImage: "info.circle") + } + + Button { + showInFinder(item) + } label: { + Label("Show in Finder", systemImage: "finder") + } + + Divider() + + Button { + controller.resume(item) + } label: { + Label("Resume", systemImage: "arrow.clockwise") + } + .disabled(item.status == .downloading) + + Button { + controller.pause(item) + } label: { + Label("Stop", systemImage: "stop.fill") + } + .disabled(item.status != .downloading) + + Button { + controller.queue(item) + } label: { + Label("Add to Queue", systemImage: "list.bullet") + } + .disabled(item.status == .downloading || item.status == .queued) + + Divider() + + Button(role: .destructive) { + controller.delete(item) + } label: { + Label("Delete", systemImage: "trash") + } + } + + private var orderedVisibleColumns: [DownloadColumn] { + DownloadColumn.allCases.filter { visibleColumns.contains($0) } + } + + private var totalWidth: CGFloat { + orderedVisibleColumns.map(\.width).reduce(0, +) + } + + private var sortedItems: [DownloadItem] { + items.sorted { lhs, rhs in + let result = compare(lhs, rhs, by: sortColumn) + if result == .orderedSame { + return lhs.id.uuidString < rhs.id.uuidString + } + return sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending + } + } + + private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult { + switch column { + case .fileName: lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) + case .size: compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1) + case .status: compare(lhs.status.rawValue, rhs.status.rawValue) + case .progress: compare(lhs.progress, rhs.progress) + case .lastTry: compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast) + case .dateAdded: compare(lhs.createdAt, rhs.createdAt) + case .category: compare(lhs.category.rawValue, rhs.category.rawValue) + case .connections: compare(lhs.connectionsPerServer, rhs.connectionsPerServer) + case .liveConnections: compare(lhs.connectionCount, rhs.connectionCount) + case .speed: compare(lhs.speedText, rhs.speedText) + case .eta: compare(lhs.etaText, rhs.etaText) + case .destination: compare(lhs.destinationPath, rhs.destinationPath) + case .url: compare(lhs.url.absoluteString, rhs.url.absoluteString) + case .message: compare(lhs.message, rhs.message) + } + } + + private func compare(_ lhs: T, _ rhs: T) -> ComparisonResult { + if lhs < rhs { return .orderedAscending } + if lhs > rhs { return .orderedDescending } + return .orderedSame + } + + private func showInFinder(_ item: DownloadItem) { + let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName) + if FileManager.default.fileExists(atPath: fileURL.path) { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } else { + NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory)) + } + } + + private func existingFolder(for url: URL) -> URL { + var candidate = url + while !FileManager.default.fileExists(atPath: candidate.path) { + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + return URL(fileURLWithPath: NSHomeDirectory()) + } + candidate = parent + } + return candidate } } private struct DownloadRow: View { let item: DownloadItem + let visibleColumns: [DownloadColumn] var body: some View { - Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { - GridRow { + HStack(alignment: .top, spacing: 0) { + ForEach(visibleColumns) { column in + cell(for: column) + .frame(width: column.width, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + } + } + + @ViewBuilder + private func cell(for column: DownloadColumn) -> some View { + switch column { + case .fileName: + HStack(alignment: .top, spacing: 8) { Image(systemName: item.category.symbolName) .font(.title3) .foregroundStyle(categoryColor) - .frame(width: 24) - + .frame(width: 22) VStack(alignment: .leading, spacing: 6) { HStack { Text(item.fileName) @@ -190,42 +461,51 @@ private struct DownloadRow: View { .foregroundStyle(statusColor) .clipShape(RoundedRectangle(cornerRadius: 5)) } - ProgressView(value: item.progress) .progressViewStyle(.linear) - Text(item.url.absoluteString) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } - - metric("Parts", "\(item.parts)") - metric("CN", "\(item.connectionCount)") - metric("Speed", item.speedText) - metric("ETA", item.etaText) - metric("Size", item.bytesText) - - Text(item.message) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - .frame(width: 220, alignment: .leading) } - } - .padding(.vertical, 7) - } - - private func metric(_ label: String, _ value: String) -> some View { - VStack(alignment: .trailing, spacing: 2) { - Text(label) - .font(.caption2) + case .size: + Text(ByteFormatter.string(item.sizeBytes)) + .monospacedDigit() + case .status: + Text(item.status.rawValue) + case .progress: + Text(item.progress, format: .percent.precision(.fractionLength(0))) + .monospacedDigit() + case .lastTry: + Text(formatted(item.lastTryAt)) + case .dateAdded: + Text(formatted(item.createdAt)) + case .category: + Text(item.category.rawValue) + case .connections: + Text("\(item.connectionsPerServer)") + .monospacedDigit() + case .liveConnections: + Text("\(item.connectionCount)") + .monospacedDigit() + case .speed: + Text(item.speedText) + case .eta: + Text(item.etaText) + case .destination: + Text(item.destinationPath) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + case .url: + Text(item.url.absoluteString) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + case .message: + Text(item.message) .foregroundStyle(.secondary) - Text(value) - .font(.callout.monospacedDigit()) - .lineLimit(1) + .lineLimit(2) } - .frame(width: 72, alignment: .trailing) } private var categoryColor: Color { @@ -249,6 +529,11 @@ private struct DownloadRow: View { case .canceled: .gray } } + + private func formatted(_ date: Date?) -> String { + guard let date else { return "-" } + return date.formatted(date: .abbreviated, time: .shortened) + } } private struct StatusBar: View { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 96207c0..4896126 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -4,7 +4,6 @@ import Foundation @MainActor final class DownloadController: ObservableObject { @Published var downloads: [DownloadItem] = [] - @Published var maxConcurrentDownloads = 3 @Published var engineMessage = "" private let settings: AppSettings @@ -46,7 +45,7 @@ final class DownloadController: ObservableObject { Aria2DownloadEngine.findExecutable() != nil } - func add(urlText: String, parts: Int) { + func add(urlText: String, connectionsPerServer: Int? = nil) { guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), let scheme = url.scheme?.lowercased(), ["http", "https", "ftp", "sftp"].contains(scheme) else { @@ -61,7 +60,7 @@ final class DownloadController: ObservableObject { fileName: fileName, category: category, destinationDirectory: settings.destinationDirectory(for: category), - parts: min(max(parts, 16), 32), + connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16), credentials: settings.credentials(for: url) ) @@ -69,6 +68,36 @@ final class DownloadController: ObservableObject { engineMessage = "Added \(fileName) to \(category.rawValue)." } + func addPendingDownloads( + _ pendingDownloads: [PendingDownload], + connectionsPerServer: Int, + overrideDirectory: URL?, + startImmediately: Bool + ) { + let clampedConnections = min(max(connectionsPerServer, 1), 16) + + let items = pendingDownloads.map { pending in + DownloadItem( + url: pending.url, + fileName: pending.fileName, + category: pending.category, + destinationDirectory: overrideDirectory ?? pending.defaultDirectory, + connectionsPerServer: clampedConnections, + credentials: settings.credentials(for: pending.url), + sizeBytes: pending.sizeBytes, + bytesText: ByteFormatter.string(pending.sizeBytes), + message: startImmediately ? "Queued to start" : "Added to queue" + ) + } + + downloads.append(contentsOf: items) + engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")." + + if startImmediately { + startQueue() + } + } + func startQueue() { engineMessage = "" pumpQueue() @@ -85,6 +114,22 @@ final class DownloadController: ObservableObject { pumpQueue() } + func queue(_ item: DownloadItem) { + activeHandles[item.id]?.cancel() + activeHandles[item.id] = nil + update(item.id) { + $0.status = .queued + if item.status != .paused { + $0.progress = 0 + $0.speedText = "-" + $0.etaText = "-" + $0.connectionCount = 0 + } + $0.message = "Added to queue" + } + updateSleepActivity() + } + func resume(_ item: DownloadItem) { update(item.id) { $0.status = .queued @@ -114,6 +159,13 @@ final class DownloadController: ObservableObject { updateSleepActivity() } + func delete(_ item: DownloadItem) { + activeHandles[item.id]?.cancel() + activeHandles[item.id] = nil + downloads.removeAll { $0.id == item.id } + updateSleepActivity() + } + func move(from source: IndexSet, to destination: Int) { downloads.move(fromOffsets: source, toOffset: destination) } @@ -124,7 +176,7 @@ final class DownloadController: ObservableObject { return } - while activeCount < maxConcurrentDownloads, + while activeCount < settings.maxConcurrentDownloads, let next = downloads.first(where: { $0.status == .queued }) { start(next) } @@ -133,6 +185,7 @@ final class DownloadController: ObservableObject { private func start(_ item: DownloadItem) { update(item.id) { $0.status = .downloading + $0.lastTryAt = Date() $0.message = "Starting" $0.speedText = "-" $0.etaText = "-" @@ -141,7 +194,6 @@ final class DownloadController: ObservableObject { do { let handle = try engine.start( item: item, - perServerConnections: settings.perServerConnections, progress: { [weak self] progress in Task { @MainActor in self?.update(item.id) { @@ -204,6 +256,25 @@ final class DownloadController: ObservableObject { mutate(&downloads[index]) } + func updateDownload( + id: UUID, + url: URL, + fileName: String, + destinationDirectory: URL, + connectionsPerServer: Int, + credentials: DownloadCredentials? + ) { + update(id) { + $0.url = url + $0.fileName = fileName + $0.category = FileClassifier.category(forFileName: fileName) + $0.destinationDirectory = destinationDirectory + $0.connectionsPerServer = min(max(connectionsPerServer, 1), 16) + $0.credentials = credentials + $0.message = "Properties updated" + } + } + private func updateSleepActivity() { let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0 diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift new file mode 100644 index 0000000..c0f44c9 --- /dev/null +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -0,0 +1,112 @@ +import Foundation + +enum DownloadURLParser { + static func parse(_ text: String) -> [URL] { + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let range = NSRange(text.startIndex.. URL? in + let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()")) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + ["http", "https", "ftp", "sftp"].contains(scheme) else { + return nil + } + return url + } + + var seen = Set() + return (detected + tokenized).filter { url in + guard let scheme = url.scheme?.lowercased(), + ["http", "https", "ftp", "sftp"].contains(scheme) else { + return false + } + return seen.insert(url.absoluteString).inserted + } + } +} + +enum DownloadMetadataFetcher { + static func fetch(for url: URL, settings: AppSettings) async -> PendingDownload { + let initialName = FileClassifier.fileName(from: url) + let initialCategory = FileClassifier.category(forFileName: initialName) + let initialDirectory = await settings.destinationDirectory(for: initialCategory) + var pending = PendingDownload( + url: url, + fileName: initialName, + category: initialCategory, + defaultDirectory: initialDirectory, + state: .loading + ) + + guard url.scheme?.lowercased().hasPrefix("http") == true else { + pending.state = .loaded + return pending + } + + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = 12 + request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent") + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + pending.state = .loaded + return pending + } + + if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"), + let fileName = fileName(fromContentDisposition: disposition) { + pending.fileName = fileName + pending.category = FileClassifier.category(forFileName: fileName) + pending.defaultDirectory = await settings.destinationDirectory(for: pending.category) + } + + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let bytes = Int64(contentLength) { + pending.sizeBytes = bytes + } else if response.expectedContentLength > 0 { + pending.sizeBytes = response.expectedContentLength + } + + pending.mimeType = httpResponse.mimeType + pending.state = .loaded + } catch { + pending.state = .failed(error.localizedDescription) + } + + return pending + } + + private static func fileName(fromContentDisposition header: String) -> String? { + let parts = header.components(separatedBy: ";") + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("filename*="), + let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding { + return value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + + if trimmed.lowercased().hasPrefix("filename=") { + return String(trimmed.dropFirst("filename=".count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + return nil + } +} + +enum ByteFormatter { + static func string(_ bytes: Int64?) -> String { + guard let bytes else { return "Unknown" } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.includesUnit = true + formatter.includesCount = true + return formatter.string(fromByteCount: bytes) + } +} diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift new file mode 100644 index 0000000..038ebe1 --- /dev/null +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -0,0 +1,216 @@ +import AppKit +import SwiftUI + +struct DownloadPropertiesWindow: View { + @EnvironmentObject private var controller: DownloadController + let downloadID: UUID + + var body: some View { + if let item = controller.downloads.first(where: { $0.id == downloadID }) { + DownloadPropertiesView(item: item) + } else { + ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle") + .frame(width: 420, height: 240) + } + } +} + +struct DownloadPropertiesView: View { + enum LoginMode: String, CaseIterable, Identifiable { + case matching = "Matching site login" + case custom = "Custom credentials" + case none = "No login" + + var id: String { rawValue } + } + + @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings + @Environment(\.dismiss) private var dismiss + + let item: DownloadItem + + @State private var urlText: String + @State private var fileName: String + @State private var destinationPath: String + @State private var connections: Int + @State private var loginMode: LoginMode + @State private var username: String + @State private var password: String + @State private var errorMessage = "" + + init(item: DownloadItem) { + self.item = item + _urlText = State(initialValue: item.url.absoluteString) + _fileName = State(initialValue: item.fileName) + _destinationPath = State(initialValue: item.destinationDirectory.path) + _connections = State(initialValue: item.connectionsPerServer) + if let credentials = item.credentials { + _loginMode = State(initialValue: .custom) + _username = State(initialValue: credentials.username) + _password = State(initialValue: credentials.password) + } else { + _loginMode = State(initialValue: .matching) + _username = State(initialValue: "") + _password = State(initialValue: "") + } + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section("Download") { + TextField("URL", text: $urlText) + .font(.system(.body, design: .monospaced)) + TextField("File name", text: $fileName) + HStack { + TextField("Save location", text: $destinationPath) + .font(.system(.body, design: .monospaced)) + Button { + selectDestination() + } label: { + Label("Select", systemImage: "folder.badge.plus") + } + } + Stepper("Connections per file: \(connections)", value: $connections, in: 1...16) + } + + Section("Site Login") { + Picker("Login", selection: $loginMode) { + ForEach(LoginMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if loginMode == .matching { + Text(matchingLoginText) + .font(.caption) + .foregroundStyle(.secondary) + } else if loginMode == .custom { + TextField("Username", text: $username) + SecureField("Password", text: $password) + } + } + + Section("Progress") { + ProgressView(value: item.progress) + InfoGrid(item: item) + } + } + .formStyle(.grouped) + + Divider() + HStack { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(1) + Spacer() + Button("Cancel") { + dismiss() + } + Button { + save() + } label: { + Label("Save", systemImage: "checkmark") + } + .buttonStyle(.borderedProminent) + } + .padding(14) + .background(.bar) + } + .frame(width: 620, height: 560) + } + + private var matchingLoginText: String { + guard let url = URL(string: urlText), + let credentials = settings.credentials(for: url) else { + return "No matching saved login for this URL." + } + + return "Will use saved login for \(credentials.username)." + } + + private func selectDestination() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.directoryURL = URL(fileURLWithPath: NSString(string: destinationPath).expandingTildeInPath) + + if panel.runModal() == .OK, let url = panel.url { + destinationPath = url.path + } + } + + private func save() { + guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme?.lowercased(), + ["http", "https", "ftp", "sftp"].contains(scheme) else { + errorMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL." + return + } + + let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanFileName.isEmpty else { + errorMessage = "File name cannot be empty." + return + } + + let destination = URL( + fileURLWithPath: NSString(string: destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)).expandingTildeInPath, + isDirectory: true + ) + + let credentials: DownloadCredentials? + switch loginMode { + case .matching: + credentials = settings.credentials(for: url) + case .custom: + let custom = DownloadCredentials(username: username, password: password) + credentials = custom.isEmpty ? nil : custom + case .none: + credentials = nil + } + + controller.updateDownload( + id: item.id, + url: url, + fileName: cleanFileName, + destinationDirectory: destination, + connectionsPerServer: connections, + credentials: credentials + ) + dismiss() + } +} + +private struct InfoGrid: View { + let item: DownloadItem + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) { + info("Status", item.status.rawValue) + info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0)))) + info("Size", ByteFormatter.string(item.sizeBytes)) + info("Speed", item.speedText) + info("ETA", item.etaText) + info("Live connections", "\(item.connectionCount)") + info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened)) + info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-") + info("Category", item.category.rawValue) + info("Destination", item.destinationPath) + } + } + + private func info(_ label: String, _ value: String) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Text(value) + .lineLimit(2) + } + } +} diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 53f7e92..a6018a5 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -19,6 +19,25 @@ struct FirelinkApp: App { .frame(minWidth: 980, minHeight: 640) } .windowStyle(.titleBar) + + WindowGroup("Add Downloads", id: "add-downloads") { + AddDownloadsView() + .environmentObject(controller) + .environmentObject(settings) + } + .windowResizability(.contentSize) + + WindowGroup("Download Properties", for: UUID.self) { $downloadID in + if let downloadID { + DownloadPropertiesWindow(downloadID: downloadID) + .environmentObject(controller) + .environmentObject(settings) + } else { + ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle") + } + } + .windowResizability(.contentSize) + .commands { CommandGroup(after: .newItem) { Button("Start Queue") { diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index e62c491..295d862 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -45,16 +45,18 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { var fileName: String var category: DownloadCategory var destinationDirectory: URL - var parts: Int + var connectionsPerServer: Int var credentials: DownloadCredentials? var status: DownloadStatus = .queued var progress: Double = 0 var speedText: String = "-" var etaText: String = "-" var connectionCount: Int = 0 + var sizeBytes: Int64? var bytesText: String = "-" var message: String = "" var createdAt = Date() + var lastTryAt: Date? var destinationPath: String { destinationDirectory.appendingPathComponent(fileName).path @@ -68,3 +70,25 @@ struct DownloadProgress: Equatable, Sendable { var etaText: String var connectionCount: Int } + +struct PendingDownload: Identifiable, Equatable, Sendable { + enum MetadataState: Equatable, Sendable { + case pending + case loading + case loaded + case failed(String) + } + + var id = UUID() + var url: URL + var fileName: String + var category: DownloadCategory + var defaultDirectory: URL + var sizeBytes: Int64? + var mimeType: String? + var state: MetadataState = .pending + + var destinationPath: String { + defaultDirectory.appendingPathComponent(fileName).path + } +} diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index a0353a5..c1f4140 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -17,7 +17,7 @@ struct SettingsView: View { SiteLoginsSettingsPane() .tabItem { - Label("Site Logins", systemImage: "person.crop.circle.badge.key") + Label("Site Logins", systemImage: "key.fill") } PowerSettingsPane() @@ -37,11 +37,20 @@ private struct DownloadSettingsPane: View { Form { Section { Stepper( - "Connections per server: \(settings.perServerConnections)", + "Default connections per server: \(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16 ) - Text("Used by aria2 for each server. Firelink still lets each download request 16-32 parts.") + Text("Used as the default for new downloads. The Add Downloads window can override it per batch.") + .font(.caption) + .foregroundStyle(.secondary) + + Stepper( + "Parallel downloads: \(settings.maxConcurrentDownloads)", + value: $settings.maxConcurrentDownloads, + in: 1...12 + ) + Text("Controls how many files Firelink downloads at the same time.") .font(.caption) .foregroundStyle(.secondary) }