feat: add bandwidth speed limits

This commit is contained in:
nimbold
2026-06-03 03:58:17 +03:30
parent 8f17fa1e47
commit 8c2dee2c13
8 changed files with 159 additions and 37 deletions
+36 -24
View File
@@ -12,6 +12,8 @@ struct AddDownloadsView: View {
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@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))
+15
View File
@@ -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]
+15 -2
View File
@@ -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, Error>) -> 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
}
+30 -2
View File
@@ -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 &&
+16 -1
View File
@@ -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)
+6 -8
View File
@@ -66,14 +66,17 @@ final class TableSettings: ObservableObject {
private let storageKey = "Firelink.TableSettings.v1"
init() {
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.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)
}
+8
View File
@@ -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 {
+33
View File
@@ -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<Bool> {
Binding {
settings.globalSpeedLimitKiBPerSecond > 0
} set: { isEnabled in
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
}
}
private var globalSpeedLimitValue: Binding<Int> {
Binding {
max(settings.globalSpeedLimitKiBPerSecond, 1)
} set: { newValue in
settings.globalSpeedLimitKiBPerSecond = newValue
}
}
}
private struct LocationsSettingsPane: View {