mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 11:07:08 +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 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))
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user