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 destinationPath = ""
@State private var metadataTask: Task<Void, Never>? @State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID @State private var targetQueueID = DownloadQueue.mainQueueID
@State private var speedLimitEnabled = false
@State private var speedLimitKiBPerSecond = 1024
@State private var showsAdvancedTransfer = false @State private var showsAdvancedTransfer = false
@State private var checksumEnabled = false @State private var checksumEnabled = false
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256 @State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
@@ -30,13 +32,13 @@ struct AddDownloadsView: View {
summarySection summarySection
previewSection previewSection
} }
.padding(20) .padding(16)
} }
Divider() Divider()
actionBar actionBar
} }
.frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740) .frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
.onChange(of: linkText) { _, newValue in .onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue) scheduleMetadataRefresh(for: newValue)
} }
@@ -64,7 +66,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35)) .background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 140) .frame(minHeight: 96)
HStack { HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected") Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -112,27 +114,40 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1) Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 220) .frame(width: 170)
Text("\(Int(connectionsPerServer)) segments") Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit() .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 { private var summarySection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { HStack(spacing: 10) {
GridRow { SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc") SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive") SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive") SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
} }
} }
@@ -149,10 +164,6 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.fileName) Text(item.fileName)
.lineLimit(1) .lineLimit(1)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} }
} }
} }
@@ -175,7 +186,7 @@ struct AddDownloadsView: View {
} }
.width(130) .width(130)
} }
.frame(minHeight: 230) .frame(minHeight: 170)
} }
} }
@@ -238,7 +249,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35)) .background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 74) .frame(minHeight: 60)
} }
GridRow(alignment: .firstTextBaseline) { GridRow(alignment: .firstTextBaseline) {
@@ -257,7 +268,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35)) .background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 74) .frame(minHeight: 60)
} }
} }
.padding(.top, 10) .padding(.top, 10)
@@ -395,7 +406,8 @@ struct AddDownloadsView: View {
overrideDirectory: overrideDirectory, overrideDirectory: overrideDirectory,
startImmediately: start, startImmediately: start,
queueID: targetQueueID, queueID: targetQueueID,
transferOptions: transferOptions transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
) )
dismiss() dismiss()
} }
@@ -478,7 +490,7 @@ private struct SummaryTile: View {
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(12) .padding(12)
.frame(width: 190) .frame(maxWidth: .infinity)
.frame(minHeight: 64) .frame(minHeight: 64)
.background(.quaternary.opacity(0.35)) .background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8)) .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 { @Published var preventsSleepWhileDownloading: Bool {
didSet { save() } didSet { save() }
} }
@@ -120,6 +131,7 @@ final class AppSettings: ObservableObject {
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) { let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
perServerConnections = min(max(stored.perServerConnections, 1), 16) perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12) maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings() proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins siteLogins = stored.siteLogins
@@ -127,6 +139,7 @@ final class AppSettings: ObservableObject {
} else { } else {
perServerConnections = 16 perServerConnections = 16
maxConcurrentDownloads = 3 maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true preventsSleepWhileDownloading = true
proxySettings = ProxySettings() proxySettings = ProxySettings()
siteLogins = [] siteLogins = []
@@ -207,6 +220,7 @@ final class AppSettings: ObservableObject {
let stored = StoredSettings( let stored = StoredSettings(
perServerConnections: perServerConnections, perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads, maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading, preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized, proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
@@ -265,6 +279,7 @@ final class AppSettings: ObservableObject {
private struct StoredSettings: Codable { private struct StoredSettings: Codable {
var perServerConnections: Int var perServerConnections: Int
var maxConcurrentDownloads: Int? var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings? var proxySettings: ProxySettings?
var downloadDirectories: [String: String] var downloadDirectories: [String: String]
+15 -2
View File
@@ -86,6 +86,7 @@ final class Aria2DownloadEngine {
func start( func start(
item: DownloadItem, item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration, proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle { ) throws -> Handle {
@@ -100,7 +101,11 @@ final class Aria2DownloadEngine {
let process = Process() let process = Process()
process.executableURL = executableURL 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 inputPipe = Pipe()
let outputPipe = 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 = [ var arguments = [
"--continue=true", "--continue=true",
"--allow-overwrite=false", "--allow-overwrite=false",
@@ -185,6 +194,10 @@ final class Aria2DownloadEngine {
"--input-file=-" "--input-file=-"
] ]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration)) arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments return arguments
} }
+30 -2
View File
@@ -114,10 +114,12 @@ final class DownloadController: ObservableObject {
overrideDirectory: URL?, overrideDirectory: URL?,
startImmediately: Bool, startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID, queueID: UUID = DownloadQueue.mainQueueID,
transferOptions: DownloadTransferOptions = DownloadTransferOptions() transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
speedLimitKiBPerSecond: Int? = nil
) { ) {
let clampedConnections = min(max(connectionsPerServer, 1), 16) let clampedConnections = min(max(connectionsPerServer, 1), 16)
let targetQueueID = normalizedQueueID(queueID) let targetQueueID = normalizedQueueID(queueID)
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in let items = pendingDownloads.map { pending in
DownloadItem( DownloadItem(
@@ -131,6 +133,7 @@ final class DownloadController: ObservableObject {
requestHeaders: transferOptions.requestHeaders, requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader, cookieHeader: transferOptions.cookieHeader,
mirrorURLs: transferOptions.mirrorURLs, mirrorURLs: transferOptions.mirrorURLs,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
sizeBytes: pending.sizeBytes, sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes), bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue", message: startImmediately ? "Queued to start" : "Added to queue",
@@ -366,6 +369,7 @@ final class DownloadController: ObservableObject {
let handle = try engine.start( let handle = try engine.start(
item: item, item: item,
proxyConfiguration: settings.downloadProxyConfiguration, proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in progress: { [weak self] progress in
Task { @MainActor in Task { @MainActor in
self?.update(item.id) { self?.update(item.id) {
@@ -433,7 +437,8 @@ final class DownloadController: ObservableObject {
destinationDirectory: URL, destinationDirectory: URL,
connectionsPerServer: Int, connectionsPerServer: Int,
credentials: DownloadCredentials?, credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions transferOptions: DownloadTransferOptions,
speedLimitKiBPerSecond: Int?
) { ) {
update(id) { update(id) {
$0.url = url $0.url = url
@@ -446,11 +451,34 @@ final class DownloadController: ObservableObject {
$0.requestHeaders = transferOptions.requestHeaders $0.requestHeaders = transferOptions.requestHeaders
$0.cookieHeader = transferOptions.cookieHeader $0.cookieHeader = transferOptions.cookieHeader
$0.mirrorURLs = transferOptions.mirrorURLs $0.mirrorURLs = transferOptions.mirrorURLs
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
$0.message = "Properties updated" $0.message = "Properties updated"
} }
saveDownloads() 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?) { private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
let normalizedID = queueID.map(normalizedQueueID) let normalizedID = queueID.map(normalizedQueueID)
for index in downloads.indices where downloads[index].status == .queued && 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 loginMode: LoginMode
@State private var username: String @State private var username: String
@State private var password: String @State private var password: String
@State private var speedLimitEnabled: Bool
@State private var speedLimitKiBPerSecond: Int
@State private var checksumEnabled: Bool @State private var checksumEnabled: Bool
@State private var checksumAlgorithm: ChecksumAlgorithm @State private var checksumAlgorithm: ChecksumAlgorithm
@State private var checksumValue: String @State private var checksumValue: String
@@ -51,6 +53,8 @@ struct DownloadPropertiesView: View {
_fileName = State(initialValue: item.fileName) _fileName = State(initialValue: item.fileName)
_destinationPath = State(initialValue: item.destinationDirectory.path) _destinationPath = State(initialValue: item.destinationDirectory.path)
_connections = State(initialValue: item.connectionsPerServer) _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 { if let credentials = item.credentials {
_loginMode = State(initialValue: .custom) _loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username) _username = State(initialValue: credentials.username)
@@ -91,6 +95,15 @@ struct DownloadPropertiesView: View {
} }
} }
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16) 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") { Section("Site Login") {
@@ -234,7 +247,8 @@ struct DownloadPropertiesView: View {
destinationDirectory: destination, destinationDirectory: destination,
connectionsPerServer: connections, connectionsPerServer: connections,
credentials: credentials, credentials: credentials,
transferOptions: transferOptions transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
) )
dismiss() dismiss()
} }
@@ -275,6 +289,7 @@ private struct InfoGrid: View {
info("Speed", item.speedText) info("Speed", item.speedText)
info("ETA", item.etaText) info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)") info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened)) info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-") info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue) info("Category", item.category.rawValue)
+6 -8
View File
@@ -66,14 +66,17 @@ final class TableSettings: ObservableObject {
private let storageKey = "Firelink.TableSettings.v1" private let storageKey = "Firelink.TableSettings.v1"
init() { 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), if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) { let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
visibleColumns = stored.visibleColumns visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
columnWidths = stored.columnWidths columnWidths = stored.columnWidths
sortColumn = stored.sortColumn sortColumn = stored.sortColumn
sortDirection = stored.sortDirection sortDirection = stored.sortDirection
} else { } else {
visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded] visibleColumns = defaultVisibleColumns
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) }) columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
sortColumn = .dateAdded sortColumn = .dateAdded
sortDirection = .descending sortDirection = .descending
@@ -158,7 +161,7 @@ struct DownloadTable: View {
ContentUnavailableView( ContentUnavailableView(
"No Downloads", "No Downloads",
systemImage: "arrow.down.circle", 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) .font(.headline)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
+8
View File
@@ -173,6 +173,7 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var requestHeaders: [DownloadRequestHeader]? var requestHeaders: [DownloadRequestHeader]?
var cookieHeader: String? var cookieHeader: String?
var mirrorURLs: [URL]? var mirrorURLs: [URL]?
var speedLimitKiBPerSecond: Int?
var status: DownloadStatus = .queued var status: DownloadStatus = .queued
var progress: Double = 0 var progress: Double = 0
var speedText: String = "-" var speedText: String = "-"
@@ -198,6 +199,13 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
mirrorURLs: mirrorURLs ?? [] mirrorURLs: mirrorURLs ?? []
) )
} }
var speedLimitText: String {
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
return "No limit"
}
return "\(speedLimitKiBPerSecond) KiB/s"
}
} }
struct DownloadProgress: Equatable, Sendable { struct DownloadProgress: Equatable, Sendable {
+33
View File
@@ -423,9 +423,42 @@ private struct DownloadSettingsPane: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .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) .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 { private struct LocationsSettingsPane: View {