mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat: integrate settings and speed limiter into main window
- Refactor SettingsView to act as a flat detail pane instead of a nested sidebar or macOS native window - Add custom horizontal tab bar for switching between setting categories - Create SpeedLimiterView and add it as a primary tool in the sidebar - Add state retention using AppStorage for settings tab selection and speed limits - Fix EXC_BAD_ACCESS in DownloadTable by using sortableSize instead of debugDescription
This commit is contained in:
@@ -301,7 +301,7 @@ final class Aria2DownloadEngine {
|
||||
var lines = [
|
||||
urls,
|
||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||
" out=\(sanitizedOptionValue(item.fileName))",
|
||||
" out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
|
||||
" split=\(connections)",
|
||||
" max-connection-per-server=\(connections)"
|
||||
]
|
||||
|
||||
@@ -32,8 +32,10 @@ struct ContentView: View {
|
||||
queueView(queueID: queueID)
|
||||
case .scheduler:
|
||||
SchedulerView()
|
||||
case .speedLimiter:
|
||||
SpeedLimiterView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
SettingsPaneContainer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class DownloadController: ObservableObject {
|
||||
@@ -106,6 +107,10 @@ final class DownloadController: ObservableObject {
|
||||
queueID: normalizedQueueID(queueID)
|
||||
)
|
||||
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
|
||||
downloads.append(item)
|
||||
engineMessage = "Added \(fileName) to \(category.rawValue)."
|
||||
saveDownloads()
|
||||
@@ -145,6 +150,12 @@ final class DownloadController: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
for item in items {
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
downloads.append(contentsOf: items)
|
||||
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
@@ -317,6 +328,7 @@ final class DownloadController: ObservableObject {
|
||||
} else if item.status != .completed {
|
||||
removeCacheFiles(for: item)
|
||||
}
|
||||
KeychainCredentialStore.deletePassword(for: item.id)
|
||||
downloads.removeAll { $0.id == item.id }
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
@@ -468,6 +480,7 @@ final class DownloadController: ObservableObject {
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
self.saveDownloads()
|
||||
self.showNotification(title: "Download Completed", body: item.fileName)
|
||||
case .failure(let error):
|
||||
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
|
||||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
|
||||
@@ -523,6 +536,11 @@ final class DownloadController: ObservableObject {
|
||||
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
$0.message = "Properties updated"
|
||||
}
|
||||
if let password = credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: id)
|
||||
} else if credentials == nil {
|
||||
KeychainCredentialStore.deletePassword(for: id)
|
||||
}
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
@@ -577,6 +595,9 @@ final class DownloadController: ObservableObject {
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
saveDownloads()
|
||||
if let item = downloads.first(where: { $0.id == itemID }) {
|
||||
showNotification(title: "Download Failed", body: item.fileName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -751,6 +772,11 @@ final class DownloadController: ObservableObject {
|
||||
if isLegacyDownloadList, item.queueID == nil {
|
||||
adjusted.queueID = DownloadQueue.mainQueueID
|
||||
}
|
||||
|
||||
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
|
||||
adjusted.credentials?.password = storedPassword
|
||||
}
|
||||
|
||||
if adjusted.status == .downloading {
|
||||
adjusted.status = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
@@ -799,6 +825,18 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func showNotification(title: String, body: String) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
guard granted else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredDownloadState: Codable {
|
||||
|
||||
@@ -2,116 +2,6 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
||||
case priority = "#"
|
||||
case fileName = "File name"
|
||||
case size = "Size"
|
||||
case progress = "Progress"
|
||||
case status = "Status"
|
||||
case lastTry = "Last try date"
|
||||
case dateAdded = "Date added"
|
||||
case category = "Category"
|
||||
case connections = "Connections"
|
||||
case liveConnections = "Live conn."
|
||||
case speed = "Speed"
|
||||
case eta = "ETA"
|
||||
case destination = "Save location"
|
||||
case url = "URL"
|
||||
case message = "Message"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var width: CGFloat {
|
||||
switch self {
|
||||
case .priority: return 58
|
||||
case .fileName: return 340
|
||||
case .size: return 100
|
||||
case .status: return 105
|
||||
case .progress: return 115
|
||||
case .lastTry, .dateAdded: return 155
|
||||
case .category: return 105
|
||||
case .connections, .liveConnections: return 95
|
||||
case .speed, .eta: return 90
|
||||
case .destination: return 240
|
||||
case .url: return 280
|
||||
case .message: return 220
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SortDirection: String, Codable {
|
||||
case ascending
|
||||
case descending
|
||||
|
||||
mutating func toggle() {
|
||||
self = self == .ascending ? .descending : .ascending
|
||||
}
|
||||
}
|
||||
|
||||
final class TableSettings: ObservableObject {
|
||||
@Published var visibleColumns: Set<DownloadColumn> {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var columnWidths: [DownloadColumn: CGFloat] {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortColumn: DownloadColumn {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortDirection: SortDirection {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.TableSettings.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
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 == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
|
||||
columnWidths = stored.columnWidths
|
||||
sortColumn = stored.sortColumn
|
||||
sortDirection = stored.sortDirection
|
||||
} else {
|
||||
visibleColumns = defaultVisibleColumns
|
||||
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
|
||||
sortColumn = .dateAdded
|
||||
sortDirection = .descending
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredTableSettings(
|
||||
visibleColumns: visibleColumns,
|
||||
columnWidths: columnWidths,
|
||||
sortColumn: sortColumn,
|
||||
sortDirection: sortDirection
|
||||
)
|
||||
let storageKey = self.storageKey
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor in
|
||||
let data = await Task.detached(priority: .background) {
|
||||
try? JSONEncoder().encode(stored)
|
||||
}.value
|
||||
|
||||
guard !Task.isCancelled, let encoded = data else { return }
|
||||
UserDefaults.standard.set(encoded, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredTableSettings: Codable {
|
||||
var visibleColumns: Set<DownloadColumn>
|
||||
var columnWidths: [DownloadColumn: CGFloat]
|
||||
var sortColumn: DownloadColumn
|
||||
var sortDirection: SortDirection
|
||||
}
|
||||
|
||||
struct DownloadTable: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@@ -121,11 +11,12 @@ struct DownloadTable: View {
|
||||
let title: String
|
||||
var queueID: UUID?
|
||||
|
||||
@StateObject private var tableSettings = TableSettings()
|
||||
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
|
||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||
@State private var dragOffsets: [DownloadColumn: CGFloat] = [:]
|
||||
@State private var lastSelectedIndex: Int?
|
||||
@State private var draggedItemID: DownloadItem.ID?
|
||||
|
||||
var sortedItems: [DownloadItem] {
|
||||
items.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -143,38 +34,83 @@ struct DownloadTable: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
GeometryReader { proxy in
|
||||
let tableWidth = max(totalWidth, proxy.size.width)
|
||||
let trailingWidth = max(0, tableWidth - totalWidth)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
VStack(spacing: 0) {
|
||||
tableHeader(trailingWidth: trailingWidth)
|
||||
Divider()
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(sortedItems) { item in
|
||||
tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.frame(width: tableWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.defaultScrollAnchor(.topLeading)
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
TableColumn("File Name", value: \.fileName) { item in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor(for: item.category))
|
||||
.frame(width: 22)
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading)
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
TableColumn("Size", value: \.sortableSize) { item in
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
Text(item.status.rawValue)
|
||||
}
|
||||
.width(min: 80, ideal: 105)
|
||||
|
||||
TableColumn("Speed", value: \.speedText) { item in
|
||||
Text(item.speedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.etaText) { item in
|
||||
Text(item.etaText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
for itemID in itemIDs {
|
||||
if let item = controller.downloads.first(where: { $0.id == itemID }) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Download",
|
||||
@@ -215,341 +151,157 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
|
||||
DownloadRow(
|
||||
item: item,
|
||||
priorityNumber: priorityNumber(for: item),
|
||||
visibleColumns: orderedVisibleColumns,
|
||||
columnWidth: { width(for: $0) },
|
||||
trailingWidth: trailingWidth
|
||||
)
|
||||
.id(item.id)
|
||||
.frame(width: tableWidth, alignment: .leading)
|
||||
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
let index = sortedItems.firstIndex(where: { $0.id == item.id })
|
||||
|
||||
if NSEvent.modifierFlags.contains(.command) {
|
||||
if selection.contains(item.id) {
|
||||
selection.remove(item.id)
|
||||
} else {
|
||||
selection.insert(item.id)
|
||||
}
|
||||
lastSelectedIndex = index
|
||||
} else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index {
|
||||
let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex)
|
||||
let rangeIds = range.map { sortedItems[$0].id }
|
||||
selection.formUnion(rangeIds)
|
||||
} else {
|
||||
selection = [item.id]
|
||||
lastSelectedIndex = index
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
rowContextMenu(for: item)
|
||||
}
|
||||
.onDrag {
|
||||
draggedItemID = item.id
|
||||
return NSItemProvider(object: dragPayload(for: item) as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [.text],
|
||||
delegate: QueueDropDelegate(
|
||||
item: item,
|
||||
queueID: queueID,
|
||||
draggedItemID: $draggedItemID,
|
||||
controller: controller
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tableHeader(trailingWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(orderedVisibleColumns) { column in
|
||||
ZStack(alignment: .trailing) {
|
||||
headerContent(for: column)
|
||||
|
||||
Rectangle()
|
||||
.fill(.secondary.opacity(0.18))
|
||||
.frame(width: 1, height: 20)
|
||||
.frame(width: 8, height: 34)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { isHovering in
|
||||
if isHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
dragOffsets[column] = value.translation.width
|
||||
}
|
||||
.onEnded { value in
|
||||
let baseWidth = tableSettings.columnWidths[column] ?? column.width
|
||||
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
|
||||
dragOffsets[column] = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: width(for: column), height: 34)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth, height: 34)
|
||||
}
|
||||
}
|
||||
.background(.bar)
|
||||
.contextMenu {
|
||||
Section("Columns") {
|
||||
ForEach(availableColumns) { column in
|
||||
Toggle(column.rawValue, isOn: Binding(
|
||||
get: { tableSettings.visibleColumns.contains(column) },
|
||||
set: { isVisible in
|
||||
if isVisible {
|
||||
tableSettings.visibleColumns.insert(column)
|
||||
} else if tableSettings.visibleColumns.count > 1 {
|
||||
tableSettings.visibleColumns.remove(column)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
if queueID == nil {
|
||||
Section("Sort By") {
|
||||
ForEach(availableColumns) { column in
|
||||
Button(column.rawValue) {
|
||||
tableSettings.sortColumn = column
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for item: DownloadItem) -> some View {
|
||||
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
if !targetItems.isEmpty {
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Move to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
|
||||
NSPasteboard.general.setString(urls, forType: .string)
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = Set(targetItems.map(\.id))
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedVisibleColumns: [DownloadColumn] {
|
||||
var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
|
||||
if queueID != nil, !columns.contains(.priority) {
|
||||
columns.insert(.priority, at: 0)
|
||||
} else if queueID == nil {
|
||||
columns.removeAll { $0 == .priority }
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
private var availableColumns: [DownloadColumn] {
|
||||
DownloadColumn.allCases.filter { $0 != .priority }
|
||||
}
|
||||
|
||||
private var totalWidth: CGFloat {
|
||||
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
|
||||
}
|
||||
|
||||
private func width(for column: DownloadColumn) -> CGFloat {
|
||||
let baseWidth = tableSettings.columnWidths[column] ?? column.width
|
||||
let offset = dragOffsets[column] ?? 0
|
||||
return max(70, baseWidth + offset)
|
||||
}
|
||||
|
||||
private var sortedItems: [DownloadItem] {
|
||||
if queueID != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.sorted { lhs, rhs in
|
||||
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
|
||||
if result == .orderedSame {
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Move to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
|
||||
NSPasteboard.general.setString(urls, forType: .string)
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = itemIDs
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
|
||||
switch column {
|
||||
case .priority:
|
||||
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
|
||||
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
|
||||
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
|
||||
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
|
||||
case .progress: return compare(lhs.progress, rhs.progress)
|
||||
case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
|
||||
case .dateAdded: return compare(lhs.createdAt, rhs.createdAt)
|
||||
case .category: return compare(lhs.category.rawValue, rhs.category.rawValue)
|
||||
case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
|
||||
case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount)
|
||||
case .speed: return compare(lhs.speedText, rhs.speedText)
|
||||
case .eta: return compare(lhs.etaText, rhs.etaText)
|
||||
case .destination: return compare(lhs.destinationPath, rhs.destinationPath)
|
||||
case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString)
|
||||
case .message: return compare(lhs.message, rhs.message)
|
||||
private func categoryColor(for category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(for status: DownloadStatus) -> Color {
|
||||
switch status {
|
||||
case .queued: return .secondary
|
||||
case .downloading: return .blue
|
||||
case .paused: return .orange
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .canceled: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func headerContent(for column: DownloadColumn) -> some View {
|
||||
let content = HStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
Text(column.rawValue)
|
||||
.font(.caption.weight(.semibold))
|
||||
private func progressBarCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.center)
|
||||
.layoutPriority(1)
|
||||
if queueID == nil && tableSettings.sortColumn == column {
|
||||
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(width: width(for: column), height: 34, alignment: .center)
|
||||
.clipped()
|
||||
|
||||
if queueID == nil {
|
||||
Button {
|
||||
if tableSettings.sortColumn == column {
|
||||
tableSettings.sortDirection.toggle()
|
||||
} else {
|
||||
tableSettings.sortColumn = column
|
||||
tableSettings.sortDirection = .ascending
|
||||
}
|
||||
} label: {
|
||||
content
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
content
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor(for: item.status))
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityNumber(for item: DownloadItem) -> Int? {
|
||||
guard queueID != nil,
|
||||
let index = items.firstIndex(where: { $0.id == item.id }) else {
|
||||
return nil
|
||||
}
|
||||
return index + 1
|
||||
}
|
||||
|
||||
private func dragPayload(for item: DownloadItem) -> String {
|
||||
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
|
||||
return draggedIDs
|
||||
.map(\.uuidString)
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
|
||||
if lhs < rhs { return .orderedAscending }
|
||||
if lhs > rhs { return .orderedDescending }
|
||||
return .orderedSame
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private func showInFinder(_ item: DownloadItem) {
|
||||
@@ -582,184 +334,3 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
let item: DownloadItem
|
||||
let priorityNumber: Int?
|
||||
let visibleColumns: [DownloadColumn]
|
||||
let columnWidth: (DownloadColumn) -> CGFloat
|
||||
let trailingWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
ForEach(visibleColumns) { column in
|
||||
cell(for: column)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, settings.listRowDensity.verticalPadding)
|
||||
.frame(width: columnWidth(column), alignment: alignment(for: column))
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(for column: DownloadColumn) -> some View {
|
||||
switch column {
|
||||
case .priority:
|
||||
Text(priorityNumber.map(String.init) ?? "-")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
case .fileName:
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
case .size:
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .status, .progress:
|
||||
progressBarCell()
|
||||
case .lastTry:
|
||||
Text(formatted(item.lastTryAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .dateAdded:
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .category:
|
||||
Text(item.category.rawValue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .connections:
|
||||
Text("\(item.connectionsPerServer)")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
case .liveConnections:
|
||||
Text("\(item.connectionCount)")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
case .speed:
|
||||
Text(item.speedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .eta:
|
||||
Text(item.etaText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .destination:
|
||||
Text(item.destinationPath)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .url:
|
||||
Text(item.url.absoluteString)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .message:
|
||||
Text(item.message)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
private func alignment(for column: DownloadColumn) -> Alignment {
|
||||
column == .fileName ? .leading : .center
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch item.category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch item.status {
|
||||
case .queued: return .secondary
|
||||
case .downloading: return .blue
|
||||
case .paused: return .orange
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .canceled: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressBarCell() -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor)
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueDropDelegate: DropDelegate {
|
||||
let item: DownloadItem
|
||||
let queueID: UUID?
|
||||
@Binding var draggedItemID: DownloadItem.ID?
|
||||
let controller: DownloadController
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard let queueID,
|
||||
let draggedItemID,
|
||||
draggedItemID != item.id else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.moveDownload(draggedItemID, before: item.id, in: queueID)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
draggedItemID = nil
|
||||
return queueID != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ struct FirelinkApp: App {
|
||||
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Add Downloads...") {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Start Queue") {
|
||||
controller.startQueue()
|
||||
}
|
||||
|
||||
@@ -17,14 +17,24 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
init?(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: Constants.port)
|
||||
|
||||
do {
|
||||
listener = try NWListener(using: parameters)
|
||||
} catch {
|
||||
print("Failed to create listener: \(error)")
|
||||
var createdListener: NWListener?
|
||||
for portValue in 6412...6422 {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||
do {
|
||||
createdListener = try NWListener(using: parameters)
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let createdListener else {
|
||||
print("Failed to create listener on ports 6412-6422")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.listener = createdListener
|
||||
}
|
||||
|
||||
func start() {
|
||||
|
||||
@@ -191,6 +191,10 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
var sortableSize: Int64 {
|
||||
sizeBytes ?? 0
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
@@ -209,7 +213,9 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
|
||||
var redactedForPersistence: DownloadItem {
|
||||
var item = self
|
||||
item.credentials = nil
|
||||
if item.credentials != nil {
|
||||
item.credentials?.password = ""
|
||||
}
|
||||
item.cookieHeader = nil
|
||||
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
||||
return item
|
||||
|
||||
@@ -1,107 +1,52 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case locations = "Locations"
|
||||
case lookAndFeel = "Look and feel"
|
||||
case network = "Network"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case integration = "Integrations"
|
||||
case about = "About"
|
||||
|
||||
static let orderedCases: [SettingsSection] = [
|
||||
.downloads,
|
||||
.locations,
|
||||
.lookAndFeel,
|
||||
.network,
|
||||
.siteLogins,
|
||||
.power,
|
||||
.engine,
|
||||
.integration,
|
||||
.about
|
||||
]
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .lookAndFeel: "paintpalette"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .integration: "puzzlepiece.extension"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var groupTitle: String {
|
||||
switch self {
|
||||
case .engine, .integration, .about:
|
||||
"App"
|
||||
default:
|
||||
"Preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var selection: SettingsSection = .downloads
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Settings")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 26)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
activeTab = filter
|
||||
} label: {
|
||||
Text(filter.rawValue)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
settingsSidebar
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(activeTab.rawValue)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
.padding(28)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.themeBackground(settings.appTheme.theme.background)
|
||||
}
|
||||
|
||||
private var settingsSidebar: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Preferences") {
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
|
||||
Section("App") {
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.frame(width: 210)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch selection {
|
||||
switch activeTab {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .lookAndFeel:
|
||||
@@ -488,41 +433,9 @@ private struct DownloadSettingsPane: View {
|
||||
.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 {
|
||||
|
||||
@@ -21,10 +21,37 @@ enum DownloadSidebarFilter: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case lookAndFeel = "Look and feel"
|
||||
case network = "Network"
|
||||
case locations = "Locations"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case integration = "Integrations"
|
||||
case about = "About"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .lookAndFeel: "paintpalette"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .integration: "puzzlepiece.extension"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case downloads(DownloadSidebarFilter)
|
||||
case queue(UUID)
|
||||
case scheduler
|
||||
case speedLimiter
|
||||
case settings
|
||||
}
|
||||
|
||||
@@ -75,9 +102,33 @@ struct SidebarView: View {
|
||||
Section("Tools") {
|
||||
Label("Scheduler", systemImage: "calendar.badge.clock")
|
||||
.tag(SidebarSelection.scheduler)
|
||||
Label("Speed Limiter", systemImage: "speedometer")
|
||||
.tag(SidebarSelection.speedLimiter)
|
||||
}
|
||||
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(selection == .settings ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
.alert("Rename Queue", isPresented: Binding(
|
||||
get: { queueBeingRenamed != nil },
|
||||
set: { isPresented in
|
||||
@@ -122,30 +173,6 @@ struct SidebarView: View {
|
||||
} message: { queue in
|
||||
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
if selection == .settings {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.accentColor.opacity(0.14))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private func folderRow(for category: DownloadCategory) -> some View {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SpeedLimiterView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var speedLimitKiBPerSecond: Int = 1024
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
limitSelectionSection
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Speed Limiter")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save Limit") {
|
||||
saveState()
|
||||
withAnimation(.spring()) {
|
||||
showSaveToast = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation {
|
||||
showSaveToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var limitSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Global Speed Limit")
|
||||
.font(.headline)
|
||||
|
||||
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
|
||||
Text("Maximum Speed:")
|
||||
}
|
||||
|
||||
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Helpful presets
|
||||
HStack(spacing: 12) {
|
||||
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
|
||||
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
|
||||
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Speed Limit Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
|
||||
|
||||
private func loadState() {
|
||||
let currentLimit = settings.globalSpeedLimitKiBPerSecond
|
||||
isEnabled = currentLimit > 0
|
||||
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
// Clamp to ensure it doesn't break aria2
|
||||
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
|
||||
speedLimitKiBPerSecond = clampedSpeed
|
||||
|
||||
lastCustomSpeedLimit = clampedSpeed
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user