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:
nimbold
2026-06-04 05:29:15 +03:30
parent 28bb90e8c2
commit 7802b612a4
10 changed files with 487 additions and 781 deletions
+1 -1
View File
@@ -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)"
]
+3 -1
View File
@@ -32,8 +32,10 @@ struct ContentView: View {
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .speedLimiter:
SpeedLimiterView()
case .settings:
SettingsView()
SettingsPaneContainer()
}
}
+38
View File
@@ -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 {
+201 -630
View File
@@ -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
}
}
+7
View File
@@ -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()
}
+15 -5
View File
@@ -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() {
+7 -1
View File
@@ -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
+32 -119
View File
@@ -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 {
+51 -24
View File
@@ -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 {
+132
View File
@@ -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
}
}