mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
feat: add bandwidth speed limits
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user