diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 5764ce1..2e5c75c 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -12,6 +12,8 @@ struct AddDownloadsView: View { @State private var destinationPath = "" @State private var metadataTask: Task? @State private var targetQueueID = DownloadQueue.mainQueueID + @State private var speedLimitEnabled = false + @State private var speedLimitKiBPerSecond = 1024 @State private var showsAdvancedTransfer = false @State private var checksumEnabled = false @State private var checksumAlgorithm: ChecksumAlgorithm = .sha256 @@ -30,13 +32,13 @@ struct AddDownloadsView: View { summarySection previewSection } - .padding(20) + .padding(16) } Divider() actionBar } - .frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740) + .frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620) .onChange(of: linkText) { _, newValue in scheduleMetadataRefresh(for: newValue) } @@ -64,7 +66,7 @@ struct AddDownloadsView: View { .scrollContentBackground(.hidden) .background(.quaternary.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 140) + .frame(minHeight: 96) HStack { Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected") @@ -112,27 +114,40 @@ struct AddDownloadsView: View { VStack(alignment: .leading, spacing: 4) { HStack { Slider(value: $connectionsPerServer, in: 1...16, step: 1) - .frame(width: 220) + .frame(width: 170) Text("\(Int(connectionsPerServer)) segments") .monospacedDigit() - .frame(width: 130, alignment: .leading) + .frame(width: 110, alignment: .leading) + } + } + } + + GridRow(alignment: .firstTextBaseline) { + Label("Speed Limit", systemImage: "speedometer") + .font(.headline) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Toggle("Limit this batch", isOn: $speedLimitEnabled) + .toggleStyle(.switch) + Stepper( + "\(speedLimitKiBPerSecond) KiB/s", + value: $speedLimitKiBPerSecond, + in: 1...10_485_760, + step: 128 + ) + .disabled(!speedLimitEnabled) } - Text("Firelink splits each file into this many parallel segments. This also sets the number of concurrent connections to the server.") - .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") - } + HStack(spacing: 10) { + 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") } } @@ -149,10 +164,6 @@ struct AddDownloadsView: View { VStack(alignment: .leading, spacing: 2) { Text(item.fileName) .lineLimit(1) - Text(item.url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) } } } @@ -175,7 +186,7 @@ struct AddDownloadsView: View { } .width(130) } - .frame(minHeight: 230) + .frame(minHeight: 170) } } @@ -238,7 +249,7 @@ struct AddDownloadsView: View { .scrollContentBackground(.hidden) .background(.quaternary.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 74) + .frame(minHeight: 60) } GridRow(alignment: .firstTextBaseline) { @@ -257,7 +268,7 @@ struct AddDownloadsView: View { .scrollContentBackground(.hidden) .background(.quaternary.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 74) + .frame(minHeight: 60) } } .padding(.top, 10) @@ -395,7 +406,8 @@ struct AddDownloadsView: View { overrideDirectory: overrideDirectory, startImmediately: start, queueID: targetQueueID, - transferOptions: transferOptions + transferOptions: transferOptions, + speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil ) dismiss() } @@ -478,7 +490,7 @@ private struct SummaryTile: View { Spacer(minLength: 0) } .padding(12) - .frame(width: 190) + .frame(maxWidth: .infinity) .frame(minHeight: 64) .background(.quaternary.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 8)) diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index c498265..26a6c44 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -85,6 +85,17 @@ final class AppSettings: ObservableObject { } } + @Published var globalSpeedLimitKiBPerSecond: Int { + didSet { + let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760) + if globalSpeedLimitKiBPerSecond != clamped { + globalSpeedLimitKiBPerSecond = clamped + return + } + save() + } + } + @Published var preventsSleepWhileDownloading: Bool { didSet { save() } } @@ -120,6 +131,7 @@ final class AppSettings: ObservableObject { let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) { perServerConnections = min(max(stored.perServerConnections, 1), 16) maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12) + globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760) preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading proxySettings = stored.proxySettings?.normalized ?? ProxySettings() siteLogins = stored.siteLogins @@ -127,6 +139,7 @@ final class AppSettings: ObservableObject { } else { perServerConnections = 16 maxConcurrentDownloads = 3 + globalSpeedLimitKiBPerSecond = 0 preventsSleepWhileDownloading = true proxySettings = ProxySettings() siteLogins = [] @@ -207,6 +220,7 @@ final class AppSettings: ObservableObject { let stored = StoredSettings( perServerConnections: perServerConnections, maxConcurrentDownloads: maxConcurrentDownloads, + globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond, preventsSleepWhileDownloading: preventsSleepWhileDownloading, proxySettings: proxySettings.normalized, downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), @@ -265,6 +279,7 @@ final class AppSettings: ObservableObject { private struct StoredSettings: Codable { var perServerConnections: Int var maxConcurrentDownloads: Int? + var globalSpeedLimitKiBPerSecond: Int? var preventsSleepWhileDownloading: Bool var proxySettings: ProxySettings? var downloadDirectories: [String: String] diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index d55d9b2..b1b73ee 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -86,6 +86,7 @@ final class Aria2DownloadEngine { func start( item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration, + speedLimitKiBPerSecond: Int?, progress: @escaping @Sendable (DownloadProgress) -> Void, completion: @escaping @Sendable (Result) -> Void ) throws -> Handle { @@ -100,7 +101,11 @@ final class Aria2DownloadEngine { let process = Process() process.executableURL = executableURL - process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration) + process.arguments = try arguments( + for: item, + proxyConfiguration: proxyConfiguration, + speedLimitKiBPerSecond: speedLimitKiBPerSecond + ) let inputPipe = Pipe() let outputPipe = Pipe() @@ -167,7 +172,11 @@ final class Aria2DownloadEngine { } } - private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] { + private func arguments( + for item: DownloadItem, + proxyConfiguration: DownloadProxyConfiguration, + speedLimitKiBPerSecond: Int? + ) throws -> [String] { var arguments = [ "--continue=true", "--allow-overwrite=false", @@ -185,6 +194,10 @@ final class Aria2DownloadEngine { "--input-file=-" ] + if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 { + arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K") + } + arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration)) return arguments } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 6cbdb82..73c5c6e 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -114,10 +114,12 @@ final class DownloadController: ObservableObject { overrideDirectory: URL?, startImmediately: Bool, queueID: UUID = DownloadQueue.mainQueueID, - transferOptions: DownloadTransferOptions = DownloadTransferOptions() + transferOptions: DownloadTransferOptions = DownloadTransferOptions(), + speedLimitKiBPerSecond: Int? = nil ) { let clampedConnections = min(max(connectionsPerServer, 1), 16) let targetQueueID = normalizedQueueID(queueID) + let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond) let items = pendingDownloads.map { pending in DownloadItem( @@ -131,6 +133,7 @@ final class DownloadController: ObservableObject { requestHeaders: transferOptions.requestHeaders, cookieHeader: transferOptions.cookieHeader, mirrorURLs: transferOptions.mirrorURLs, + speedLimitKiBPerSecond: speedLimitKiBPerSecond, sizeBytes: pending.sizeBytes, bytesText: ByteFormatter.string(pending.sizeBytes), message: startImmediately ? "Queued to start" : "Added to queue", @@ -366,6 +369,7 @@ final class DownloadController: ObservableObject { let handle = try engine.start( item: item, proxyConfiguration: settings.downloadProxyConfiguration, + speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), progress: { [weak self] progress in Task { @MainActor in self?.update(item.id) { @@ -433,7 +437,8 @@ final class DownloadController: ObservableObject { destinationDirectory: URL, connectionsPerServer: Int, credentials: DownloadCredentials?, - transferOptions: DownloadTransferOptions + transferOptions: DownloadTransferOptions, + speedLimitKiBPerSecond: Int? ) { update(id) { $0.url = url @@ -446,11 +451,34 @@ final class DownloadController: ObservableObject { $0.requestHeaders = transferOptions.requestHeaders $0.cookieHeader = transferOptions.cookieHeader $0.mirrorURLs = transferOptions.mirrorURLs + $0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond) $0.message = "Properties updated" } saveDownloads() } + private func normalizedSpeedLimit(_ value: Int?) -> Int? { + guard let value, value > 0 else { return nil } + return min(value, 10_485_760) + } + + private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? { + let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond) + let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond) + .map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) } + + switch (itemLimit, globalLimit) { + case let (.some(itemLimit), .some(globalLimit)): + return min(itemLimit, globalLimit) + case let (.some(itemLimit), .none): + return itemLimit + case let (.none, .some(globalLimit)): + return globalLimit + case (.none, .none): + return nil + } + } + private func markQueuedDownloadsForAutoResume(queueID: UUID?) { let normalizedID = queueID.map(normalizedQueueID) for index in downloads.indices where downloads[index].status == .queued && diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift index e3d8113..5d642f1 100644 --- a/Sources/Firelink/DownloadPropertiesView.swift +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -37,6 +37,8 @@ struct DownloadPropertiesView: View { @State private var loginMode: LoginMode @State private var username: String @State private var password: String + @State private var speedLimitEnabled: Bool + @State private var speedLimitKiBPerSecond: Int @State private var checksumEnabled: Bool @State private var checksumAlgorithm: ChecksumAlgorithm @State private var checksumValue: String @@ -51,6 +53,8 @@ struct DownloadPropertiesView: View { _fileName = State(initialValue: item.fileName) _destinationPath = State(initialValue: item.destinationDirectory.path) _connections = State(initialValue: item.connectionsPerServer) + _speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0) + _speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1)) if let credentials = item.credentials { _loginMode = State(initialValue: .custom) _username = State(initialValue: credentials.username) @@ -91,6 +95,15 @@ struct DownloadPropertiesView: View { } } Stepper("Connections per file: \(connections)", value: $connections, in: 1...16) + Toggle("Limit speed", isOn: $speedLimitEnabled) + if speedLimitEnabled { + Stepper( + "Speed cap: \(speedLimitKiBPerSecond) KiB/s", + value: $speedLimitKiBPerSecond, + in: 1...10_485_760, + step: 128 + ) + } } Section("Site Login") { @@ -234,7 +247,8 @@ struct DownloadPropertiesView: View { destinationDirectory: destination, connectionsPerServer: connections, credentials: credentials, - transferOptions: transferOptions + transferOptions: transferOptions, + speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil ) dismiss() } @@ -275,6 +289,7 @@ private struct InfoGrid: View { info("Speed", item.speedText) info("ETA", item.etaText) info("Live connections", "\(item.connectionCount)") + info("Speed cap", item.speedLimitText) 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) diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 8a5370b..36b88f9 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -66,14 +66,17 @@ final class TableSettings: ObservableObject { private let storageKey = "Firelink.TableSettings.v1" 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 + visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns columnWidths = stored.columnWidths sortColumn = stored.sortColumn sortDirection = stored.sortDirection } else { - visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded] + visibleColumns = defaultVisibleColumns columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) }) sortColumn = .dateAdded sortDirection = .descending @@ -158,7 +161,7 @@ struct DownloadTable: View { ContentUnavailableView( "No Downloads", systemImage: "arrow.down.circle", - description: Text("Use Add to paste one or more links.") + description: Text("Use Add or press Command-V to paste one or more links.") ) } } @@ -565,11 +568,6 @@ private struct DownloadRow: View { .font(.headline) .lineLimit(1) .truncationMode(.tail) - Text(item.url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index de0d419..8d0cecf 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -173,6 +173,7 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { var requestHeaders: [DownloadRequestHeader]? var cookieHeader: String? var mirrorURLs: [URL]? + var speedLimitKiBPerSecond: Int? var status: DownloadStatus = .queued var progress: Double = 0 var speedText: String = "-" @@ -198,6 +199,13 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { mirrorURLs: mirrorURLs ?? [] ) } + + var speedLimitText: String { + guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else { + return "No limit" + } + return "\(speedLimitKiBPerSecond) KiB/s" + } } struct DownloadProgress: Equatable, Sendable { diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index e6db133..05cd18d 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -423,9 +423,42 @@ private struct DownloadSettingsPane: View { .font(.caption) .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 {