mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
feat: add download table controls
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user