feat: add download table controls

This commit is contained in:
nimbold
2026-06-01 05:08:07 +03:30
parent c8624c7c77
commit 2e78bfe588
11 changed files with 1267 additions and 106 deletions
+5 -3
View File
@@ -7,11 +7,13 @@ This project is early, but it already has a working native prototype and an `ari
## Features
- Native SwiftUI macOS interface.
- Segmented downloads with 16-32 requested parts per file.
- Segmented downloads with a per-file connection count that also controls the split count.
- Multiple files downloading at the same time.
- Queue-based downloads with drag-and-drop priority ordering.
- Native macOS Settings window, available from App menu > Settings and the main toolbar.
- Configurable per-server connection count.
- Configurable default per-server connection count.
- Configurable parallel file download limit in Settings.
- Per-batch connection controls in the Add Downloads window.
- Automatic save folders under `~/Downloads`:
- `Musics`
- `Movies`
@@ -31,7 +33,7 @@ This project is early, but it already has a working native prototype and an `ari
This first version uses `aria2c` as the download engine. It is a better fit than plain `curl` for the requested IDM/FDM-style behavior because it has segmented downloads, resumable transfers, concurrent downloads, HTTP/FTP/SFTP support, and username/password options built in.
The UI allows 16-32 requested parts. For ordinary same-host HTTP downloads, Firelink currently caps `aria2c`'s per-server connection count at 16 while still setting the requested split count. This keeps behavior aligned with common server limits and `aria2c`'s stable controls.
Firelink uses one per-file connection value for both `aria2c` split count and same-server connection count. That keeps the download behavior close to the familiar IDM-style model: choosing 8 connections splits the file into 8 parallel segments.
## Requirements
+402
View File
@@ -0,0 +1,402 @@
import SwiftUI
struct AddDownloadsView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
@State private var linkText = ""
@State private var pendingDownloads: [PendingDownload] = []
@State private var connectionsPerServer = 16.0
@State private var overrideDestination = false
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
linkSection
optionsSection
summarySection
previewSection
}
.padding(20)
}
Divider()
actionBar
}
.frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
}
.onDisappear {
metadataTask?.cancel()
}
}
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.headline)
TextEditor(text: $linkText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 140)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
.foregroundStyle(.secondary)
Spacer()
Button {
refreshMetadata(for: linkText)
} label: {
Label("Refresh Metadata", systemImage: "arrow.clockwise")
}
.disabled(DownloadURLParser.parse(linkText).isEmpty)
}
.font(.caption)
}
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.headline)
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 10) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.disabled(!overrideDestination)
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
.disabled(!overrideDestination)
}
}
GridRow {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 220)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 130, alignment: .leading)
}
Text("Firelink splits each file into this many parallel segments.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var summarySection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
}
}
private var previewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.headline)
Table(pendingDownloads) {
TableColumn("File") { item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
TableColumn("Size") { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(95)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
}
.width(130)
}
.frame(minHeight: 230)
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button("Cancel") {
dismiss()
}
Button {
addDownloads(start: false)
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(pendingDownloads.isEmpty)
Button {
addDownloads(start: true)
} label: {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(pendingDownloads.isEmpty)
}
.padding(14)
.background(.bar)
}
private var requiredSpaceText: String {
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
guard knownBytes > 0 else { return "Unknown" }
return ByteFormatter.string(knownBytes)
}
private var unknownSizeCount: Int {
pendingDownloads.filter { $0.sizeBytes == nil }.count
}
private var freeSpaceText: String {
guard let bytes = availableCapacity() else { return "Unknown" }
return ByteFormatter.string(bytes)
}
private var actionMessage: String {
if pendingDownloads.isEmpty {
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
}
if unknownSizeCount > 0 {
return "Some servers did not report file size before download."
}
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await MainActor.run {
refreshMetadata(for: text)
}
}
}
private func refreshMetadata(for text: String) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
pendingDownloads = urls.map { url in
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
url: url,
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
)
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
}
}
}
}
}
private func selectDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if let currentURL = overrideDirectory {
panel.directoryURL = currentURL
}
if panel.runModal() == .OK, let url = panel.url {
destinationPath = url.path
overrideDestination = true
}
}
private func addDownloads(start: Bool) {
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start
)
dismiss()
}
private var overrideDirectory: URL? {
guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true)
}
private func destinationDirectory(for item: PendingDownload) -> URL {
overrideDirectory ?? item.defaultDirectory
}
private func availableCapacity() -> Int64? {
let urls = pendingDownloads.isEmpty
? [settings.destinationDirectory(for: .other)]
: pendingDownloads.map { destinationDirectory(for: $0) }
return urls.compactMap { url in
let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [
.volumeAvailableCapacityForImportantUsageKey,
.volumeAvailableCapacityKey
])
if let important = values?.volumeAvailableCapacityForImportantUsage {
return important
}
if let available = values?.volumeAvailableCapacity {
return Int64(available)
}
return nil
}
.min()
}
private func existingVolumeURL(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
private func categoryColor(_ category: DownloadCategory) -> Color {
switch category {
case .musics: .pink
case .movies: .indigo
case .compressed: .orange
case .pictures: .teal
case .documents: .blue
case .other: .gray
}
}
}
private struct SummaryTile: View {
let title: String
let value: String
let symbolName: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.title3)
.foregroundStyle(.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.headline.monospacedDigit())
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(width: 190)
.frame(minHeight: 64)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private struct MetadataStatusView: View {
let state: PendingDownload.MetadataState
var body: some View {
switch state {
case .pending:
Label("Pending", systemImage: "clock")
.foregroundStyle(.secondary)
case .loading:
HStack {
ProgressView()
.controlSize(.small)
Text("Checking")
}
.foregroundStyle(.secondary)
case .loaded:
Label("Ready", systemImage: "checkmark.circle")
.foregroundStyle(.green)
case .failed:
Label("Unknown", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
}
+22
View File
@@ -18,6 +18,16 @@ final class AppSettings: ObservableObject {
}
}
@Published var maxConcurrentDownloads: Int {
didSet {
let clamped = min(max(maxConcurrentDownloads, 1), 12)
if maxConcurrentDownloads != clamped {
maxConcurrentDownloads = clamped
}
save()
}
}
@Published var preventsSleepWhileDownloading: Bool {
didSet { save() }
}
@@ -41,11 +51,13 @@ final class AppSettings: ObservableObject {
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
perServerConnections = 16
maxConcurrentDownloads = 3
preventsSleepWhileDownloading = true
siteLogins = []
downloadDirectories = Self.defaultDirectories()
@@ -106,9 +118,18 @@ final class AppSettings: ObservableObject {
return DownloadCredentials(username: login.username, password: password)
}
func credentials(for login: SiteLogin) -> DownloadCredentials? {
guard let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
private func save() {
let stored = StoredSettings(
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
@@ -165,6 +186,7 @@ final class AppSettings: ObservableObject {
private struct StoredSettings: Codable {
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var preventsSleepWhileDownloading: Bool
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
+5 -6
View File
@@ -55,7 +55,6 @@ final class Aria2DownloadEngine {
func start(
item: DownloadItem,
perServerConnections: Int,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
@@ -122,7 +121,7 @@ final class Aria2DownloadEngine {
do {
try process.run()
if let input = inputFileContent(for: item, perServerConnections: perServerConnections).data(using: .utf8) {
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
@@ -151,14 +150,14 @@ final class Aria2DownloadEngine {
]
}
private func inputFileContent(for item: DownloadItem, perServerConnections: Int) -> String {
let sameHostConnections = min(max(perServerConnections, 1), item.parts)
private func inputFileContent(for item: DownloadItem) -> String {
let connections = min(max(item.connectionsPerServer, 1), 16)
var lines = [
sanitizedOptionValue(item.url.absoluteString),
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName))",
" split=\(item.parts)",
" max-connection-per-server=\(sameHostConnections)"
" split=\(connections)",
" max-connection-per-server=\(connections)"
]
if let credentials = item.credentials, !credentials.isEmpty {
+373 -88
View File
@@ -1,29 +1,35 @@
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@State private var urlText = ""
@State private var parts = 16.0
@Environment(\.openWindow) private var openWindow
@State private var selection: DownloadItem.ID?
@State private var sidebarFilter: DownloadSidebarFilter = .all
var body: some View {
NavigationSplitView {
SidebarView()
SidebarView(selection: $sidebarFilter)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
} detail: {
VStack(spacing: 0) {
AddDownloadBar(
urlText: $urlText,
parts: $parts,
addAction: addDownload
DownloadTable(
items: filteredDownloads,
selection: $selection,
title: sidebarFilter.title
)
Divider()
DownloadTable(selection: $selection)
Divider()
StatusBar()
}
.toolbar {
ToolbarItemGroup {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
Button {
controller.startQueue()
} label: {
@@ -39,7 +45,7 @@ struct ContentView: View {
Button {
controller.pause(selectedItem)
} label: {
Label("Pause", systemImage: "pause.fill")
Label("Stop", systemImage: "stop.fill")
}
} else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled {
Button {
@@ -65,36 +71,73 @@ struct ContentView: View {
return controller.downloads.first(where: { $0.id == selection })
}
private func addDownload() {
controller.add(
urlText: urlText,
parts: Int(parts)
)
if controller.engineMessage.hasPrefix("Added") {
urlText = ""
private var filteredDownloads: [DownloadItem] {
switch sidebarFilter {
case .all:
controller.downloads
case .queued:
controller.downloads.filter { $0.status == .queued }
case .active:
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .failed:
controller.downloads.filter { $0.status == .failed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
}
}
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case failed
case category(DownloadCategory)
var title: String {
switch self {
case .all: "All Downloads"
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .failed: "Failed"
case .category(let category): category.rawValue
}
}
}
private struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: DownloadSidebarFilter
var body: some View {
List {
List(selection: $selection) {
Section("Library") {
Label("All", systemImage: "tray.full")
.badge(controller.downloads.count)
.tag(DownloadSidebarFilter.all)
Label("Queue", systemImage: "list.bullet")
.badge(controller.queuedCount)
.tag(DownloadSidebarFilter.queued)
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(DownloadSidebarFilter.active)
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(DownloadSidebarFilter.completed)
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
.tag(DownloadSidebarFilter.failed)
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(DownloadSidebarFilter.category(category))
}
}
@@ -104,79 +147,307 @@ private struct SidebarView: View {
.foregroundStyle(controller.hasAria2 ? .green : .orange)
Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing")
}
Stepper("Parallel files: \(controller.maxConcurrentDownloads)", value: $controller.maxConcurrentDownloads, in: 1...12)
}
}
.listStyle(.sidebar)
}
}
private struct AddDownloadBar: View {
@Binding var urlText: String
@Binding var parts: Double
let addAction: () -> Void
enum DownloadColumn: String, CaseIterable, Identifiable {
case fileName = "File name"
case size = "Size"
case status = "Status"
case progress = "Progress"
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 body: some View {
VStack(spacing: 12) {
HStack(spacing: 10) {
TextField("Download URL", text: $urlText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
var id: String { rawValue }
Button(action: addAction) {
Label("Add", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.disabled(urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
HStack(spacing: 12) {
HStack {
Image(systemName: "square.split.2x2")
Slider(value: $parts, in: 16...32, step: 1)
.frame(width: 160)
Text("\(Int(parts)) parts")
.monospacedDigit()
.frame(width: 62, alignment: .trailing)
}
Spacer()
}
.font(.callout)
var width: CGFloat {
switch self {
case .fileName: 320
case .size: 100
case .status: 105
case .progress: 95
case .lastTry, .dateAdded: 155
case .category: 105
case .connections, .liveConnections: 95
case .speed, .eta: 90
case .destination: 240
case .url: 280
case .message: 220
}
.padding(14)
.background(.background)
}
}
enum SortDirection {
case ascending
case descending
mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
private struct DownloadTable: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
let items: [DownloadItem]
@Binding var selection: DownloadItem.ID?
let title: String
@State private var visibleColumns: Set<DownloadColumn> = [
.fileName, .size, .status, .progress, .lastTry, .dateAdded, .speed, .eta, .message
]
@State private var sortColumn: DownloadColumn = .dateAdded
@State private var sortDirection: SortDirection = .descending
var body: some View {
List(selection: $selection) {
ForEach(controller.downloads) { item in
DownloadRow(item: item)
.tag(item.id)
VStack(spacing: 0) {
HStack {
Text(title)
.font(.headline)
Text("\(items.count)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 5))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
tableHeader
Divider()
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0) {
ForEach(sortedItems) { item in
DownloadRow(item: item, visibleColumns: orderedVisibleColumns)
.id(item.id)
.background(selection == item.id ? Color.accentColor.opacity(0.12) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
selection = item.id
}
.contextMenu {
rowContextMenu(for: item)
}
Divider()
}
}
.frame(minWidth: totalWidth, alignment: .topLeading)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add to paste one or more links.")
)
}
}
.onMove(perform: controller.move)
.onDelete(perform: controller.remove)
}
.listStyle(.inset)
}
private var tableHeader: some View {
HStack(spacing: 0) {
ForEach(orderedVisibleColumns) { column in
Button {
if sortColumn == column {
sortDirection.toggle()
} else {
sortColumn = column
sortDirection = .ascending
}
} label: {
HStack(spacing: 4) {
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
if sortColumn == column {
Image(systemName: sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(width: column.width, height: 34, alignment: .leading)
}
.buttonStyle(.plain)
}
}
.frame(minWidth: totalWidth, alignment: .leading)
.background(.bar)
.contextMenu {
Section("Columns") {
ForEach(DownloadColumn.allCases) { column in
Toggle(column.rawValue, isOn: Binding(
get: { visibleColumns.contains(column) },
set: { isVisible in
if isVisible {
visibleColumns.insert(column)
} else if visibleColumns.count > 1 {
visibleColumns.remove(column)
}
}
))
}
}
Section("Sort By") {
ForEach(DownloadColumn.allCases) { column in
Button(column.rawValue) {
sortColumn = column
}
}
}
}
}
@ViewBuilder
private func rowContextMenu(for item: DownloadItem) -> some View {
Button {
openWindow(value: item.id)
} label: {
Label("Properties", systemImage: "info.circle")
}
Button {
showInFinder(item)
} label: {
Label("Show in Finder", systemImage: "finder")
}
Divider()
Button {
controller.resume(item)
} label: {
Label("Resume", systemImage: "arrow.clockwise")
}
.disabled(item.status == .downloading)
Button {
controller.pause(item)
} label: {
Label("Stop", systemImage: "stop.fill")
}
.disabled(item.status != .downloading)
Button {
controller.queue(item)
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(item.status == .downloading || item.status == .queued)
Divider()
Button(role: .destructive) {
controller.delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
}
private var orderedVisibleColumns: [DownloadColumn] {
DownloadColumn.allCases.filter { visibleColumns.contains($0) }
}
private var totalWidth: CGFloat {
orderedVisibleColumns.map(\.width).reduce(0, +)
}
private var sortedItems: [DownloadItem] {
items.sorted { lhs, rhs in
let result = compare(lhs, rhs, by: sortColumn)
if result == .orderedSame {
return lhs.id.uuidString < rhs.id.uuidString
}
return sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
}
}
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
switch column {
case .fileName: lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
case .size: compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
case .status: compare(lhs.status.rawValue, rhs.status.rawValue)
case .progress: compare(lhs.progress, rhs.progress)
case .lastTry: compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
case .dateAdded: compare(lhs.createdAt, rhs.createdAt)
case .category: compare(lhs.category.rawValue, rhs.category.rawValue)
case .connections: compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
case .liveConnections: compare(lhs.connectionCount, rhs.connectionCount)
case .speed: compare(lhs.speedText, rhs.speedText)
case .eta: compare(lhs.etaText, rhs.etaText)
case .destination: compare(lhs.destinationPath, rhs.destinationPath)
case .url: compare(lhs.url.absoluteString, rhs.url.absoluteString)
case .message: compare(lhs.message, rhs.message)
}
}
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
return .orderedSame
}
private func showInFinder(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
private func existingFolder(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
}
private struct DownloadRow: View {
let item: DownloadItem
let visibleColumns: [DownloadColumn]
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
HStack(alignment: .top, spacing: 0) {
ForEach(visibleColumns) { column in
cell(for: column)
.frame(width: column.width, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
}
@ViewBuilder
private func cell(for column: DownloadColumn) -> some View {
switch column {
case .fileName:
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor)
.frame(width: 24)
.frame(width: 22)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.fileName)
@@ -190,42 +461,51 @@ private struct DownloadRow: View {
.foregroundStyle(statusColor)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
ProgressView(value: item.progress)
.progressViewStyle(.linear)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
metric("Parts", "\(item.parts)")
metric("CN", "\(item.connectionCount)")
metric("Speed", item.speedText)
metric("ETA", item.etaText)
metric("Size", item.bytesText)
Text(item.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.frame(width: 220, alignment: .leading)
}
}
.padding(.vertical, 7)
}
private func metric(_ label: String, _ value: String) -> some View {
VStack(alignment: .trailing, spacing: 2) {
Text(label)
.font(.caption2)
case .size:
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
case .status:
Text(item.status.rawValue)
case .progress:
Text(item.progress, format: .percent.precision(.fractionLength(0)))
.monospacedDigit()
case .lastTry:
Text(formatted(item.lastTryAt))
case .dateAdded:
Text(formatted(item.createdAt))
case .category:
Text(item.category.rawValue)
case .connections:
Text("\(item.connectionsPerServer)")
.monospacedDigit()
case .liveConnections:
Text("\(item.connectionCount)")
.monospacedDigit()
case .speed:
Text(item.speedText)
case .eta:
Text(item.etaText)
case .destination:
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.lineLimit(2)
case .url:
Text(item.url.absoluteString)
.font(.system(.caption, design: .monospaced))
.lineLimit(2)
case .message:
Text(item.message)
.foregroundStyle(.secondary)
Text(value)
.font(.callout.monospacedDigit())
.lineLimit(1)
.lineLimit(2)
}
.frame(width: 72, alignment: .trailing)
}
private var categoryColor: Color {
@@ -249,6 +529,11 @@ private struct DownloadRow: View {
case .canceled: .gray
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private struct StatusBar: View {
+76 -5
View File
@@ -4,7 +4,6 @@ import Foundation
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var maxConcurrentDownloads = 3
@Published var engineMessage = ""
private let settings: AppSettings
@@ -46,7 +45,7 @@ final class DownloadController: ObservableObject {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, parts: Int) {
func add(urlText: String, connectionsPerServer: Int? = nil) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
@@ -61,7 +60,7 @@ final class DownloadController: ObservableObject {
fileName: fileName,
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
parts: min(max(parts, 16), 32),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
credentials: settings.credentials(for: url)
)
@@ -69,6 +68,36 @@ final class DownloadController: ObservableObject {
engineMessage = "Added \(fileName) to \(category.rawValue)."
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool
) {
let clampedConnections = min(max(connectionsPerServer, 1), 16)
let items = pendingDownloads.map { pending in
DownloadItem(
url: pending.url,
fileName: pending.fileName,
category: pending.category,
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: settings.credentials(for: pending.url),
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue"
)
}
downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
if startImmediately {
startQueue()
}
}
func startQueue() {
engineMessage = ""
pumpQueue()
@@ -85,6 +114,22 @@ final class DownloadController: ObservableObject {
pumpQueue()
}
func queue(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .queued
if item.status != .paused {
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
}
$0.message = "Added to queue"
}
updateSleepActivity()
}
func resume(_ item: DownloadItem) {
update(item.id) {
$0.status = .queued
@@ -114,6 +159,13 @@ final class DownloadController: ObservableObject {
updateSleepActivity()
}
func delete(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
downloads.removeAll { $0.id == item.id }
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
}
@@ -124,7 +176,7 @@ final class DownloadController: ObservableObject {
return
}
while activeCount < maxConcurrentDownloads,
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { $0.status == .queued }) {
start(next)
}
@@ -133,6 +185,7 @@ final class DownloadController: ObservableObject {
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
$0.lastTryAt = Date()
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
@@ -141,7 +194,6 @@ final class DownloadController: ObservableObject {
do {
let handle = try engine.start(
item: item,
perServerConnections: settings.perServerConnections,
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
@@ -204,6 +256,25 @@ final class DownloadController: ObservableObject {
mutate(&downloads[index])
}
func updateDownload(
id: UUID,
url: URL,
fileName: String,
destinationDirectory: URL,
connectionsPerServer: Int,
credentials: DownloadCredentials?
) {
update(id) {
$0.url = url
$0.fileName = fileName
$0.category = FileClassifier.category(forFileName: fileName)
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
$0.message = "Properties updated"
}
}
private func updateSleepActivity() {
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
@@ -0,0 +1,112 @@
import Foundation
enum DownloadURLParser {
static func parse(_ text: String) -> [URL] {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
let tokenized = text
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")))
.compactMap { token -> URL? in
let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()"))
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
return nil
}
return url
}
var seen = Set<String>()
return (detected + tokenized).filter { url in
guard let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
return false
}
return seen.insert(url.absoluteString).inserted
}
}
}
enum DownloadMetadataFetcher {
static func fetch(for url: URL, settings: AppSettings) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
var pending = PendingDownload(
url: url,
fileName: initialName,
category: initialCategory,
defaultDirectory: initialDirectory,
state: .loading
)
guard url.scheme?.lowercased().hasPrefix("http") == true else {
pending.state = .loaded
return pending
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
pending.state = .loaded
return pending
}
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
let fileName = fileName(fromContentDisposition: disposition) {
pending.fileName = fileName
pending.category = FileClassifier.category(forFileName: fileName)
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
}
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let bytes = Int64(contentLength) {
pending.sizeBytes = bytes
} else if response.expectedContentLength > 0 {
pending.sizeBytes = response.expectedContentLength
}
pending.mimeType = httpResponse.mimeType
pending.state = .loaded
} catch {
pending.state = .failed(error.localizedDescription)
}
return pending
}
private static func fileName(fromContentDisposition header: String) -> String? {
let parts = header.components(separatedBy: ";")
for part in parts {
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("filename*="),
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if trimmed.lowercased().hasPrefix("filename=") {
return String(trimmed.dropFirst("filename=".count))
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
return nil
}
}
enum ByteFormatter {
static func string(_ bytes: Int64?) -> String {
guard let bytes else { return "Unknown" }
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.includesUnit = true
formatter.includesCount = true
return formatter.string(fromByteCount: bytes)
}
}
@@ -0,0 +1,216 @@
import AppKit
import SwiftUI
struct DownloadPropertiesWindow: View {
@EnvironmentObject private var controller: DownloadController
let downloadID: UUID
var body: some View {
if let item = controller.downloads.first(where: { $0.id == downloadID }) {
DownloadPropertiesView(item: item)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.frame(width: 420, height: 240)
}
}
}
struct DownloadPropertiesView: View {
enum LoginMode: String, CaseIterable, Identifiable {
case matching = "Matching site login"
case custom = "Custom credentials"
case none = "No login"
var id: String { rawValue }
}
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
let item: DownloadItem
@State private var urlText: String
@State private var fileName: String
@State private var destinationPath: String
@State private var connections: Int
@State private var loginMode: LoginMode
@State private var username: String
@State private var password: String
@State private var errorMessage = ""
init(item: DownloadItem) {
self.item = item
_urlText = State(initialValue: item.url.absoluteString)
_fileName = State(initialValue: item.fileName)
_destinationPath = State(initialValue: item.destinationDirectory.path)
_connections = State(initialValue: item.connectionsPerServer)
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
_password = State(initialValue: credentials.password)
} else {
_loginMode = State(initialValue: .matching)
_username = State(initialValue: "")
_password = State(initialValue: "")
}
}
var body: some View {
VStack(spacing: 0) {
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.body, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
TextField("Save location", text: $destinationPath)
.font(.system(.body, design: .monospaced))
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
}
Section("Site Login") {
Picker("Login", selection: $loginMode) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
if loginMode == .matching {
Text(matchingLoginText)
.font(.caption)
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
SecureField("Password", text: $password)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
}
.formStyle(.grouped)
Divider()
HStack {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
.lineLimit(1)
Spacer()
Button("Cancel") {
dismiss()
}
Button {
save()
} label: {
Label("Save", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
}
.padding(14)
.background(.bar)
}
.frame(width: 620, height: 560)
}
private var matchingLoginText: String {
guard let url = URL(string: urlText),
let credentials = settings.credentials(for: url) else {
return "No matching saved login for this URL."
}
return "Will use saved login for \(credentials.username)."
}
private func selectDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSString(string: destinationPath).expandingTildeInPath)
if panel.runModal() == .OK, let url = panel.url {
destinationPath = url.path
}
}
private func save() {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
errorMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
return
}
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanFileName.isEmpty else {
errorMessage = "File name cannot be empty."
return
}
let destination = URL(
fileURLWithPath: NSString(string: destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)).expandingTildeInPath,
isDirectory: true
)
let credentials: DownloadCredentials?
switch loginMode {
case .matching:
credentials = settings.credentials(for: url)
case .custom:
let custom = DownloadCredentials(username: username, password: password)
credentials = custom.isEmpty ? nil : custom
case .none:
credentials = nil
}
controller.updateDownload(
id: item.id,
url: url,
fileName: cleanFileName,
destinationDirectory: destination,
connectionsPerServer: connections,
credentials: credentials
)
dismiss()
}
}
private struct InfoGrid: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
info("Status", item.status.rawValue)
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
info("Size", ByteFormatter.string(item.sizeBytes))
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
info("Destination", item.destinationPath)
}
}
private func info(_ label: String, _ value: String) -> some View {
GridRow {
Text(label)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(2)
}
}
}
+19
View File
@@ -19,6 +19,25 @@ struct FirelinkApp: App {
.frame(minWidth: 980, minHeight: 640)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
}
}
.windowResizability(.contentSize)
.commands {
CommandGroup(after: .newItem) {
Button("Start Queue") {
+25 -1
View File
@@ -45,16 +45,18 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var fileName: String
var category: DownloadCategory
var destinationDirectory: URL
var parts: Int
var connectionsPerServer: Int
var credentials: DownloadCredentials?
var status: DownloadStatus = .queued
var progress: Double = 0
var speedText: String = "-"
var etaText: String = "-"
var connectionCount: Int = 0
var sizeBytes: Int64?
var bytesText: String = "-"
var message: String = ""
var createdAt = Date()
var lastTryAt: Date?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
@@ -68,3 +70,25 @@ struct DownloadProgress: Equatable, Sendable {
var etaText: String
var connectionCount: Int
}
struct PendingDownload: Identifiable, Equatable, Sendable {
enum MetadataState: Equatable, Sendable {
case pending
case loading
case loaded
case failed(String)
}
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var defaultDirectory: URL
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path
}
}
+12 -3
View File
@@ -17,7 +17,7 @@ struct SettingsView: View {
SiteLoginsSettingsPane()
.tabItem {
Label("Site Logins", systemImage: "person.crop.circle.badge.key")
Label("Site Logins", systemImage: "key.fill")
}
PowerSettingsPane()
@@ -37,11 +37,20 @@ private struct DownloadSettingsPane: View {
Form {
Section {
Stepper(
"Connections per server: \(settings.perServerConnections)",
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used by aria2 for each server. Firelink still lets each download request 16-32 parts.")
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}