mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +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
|
## Features
|
||||||
|
|
||||||
- Native SwiftUI macOS interface.
|
- 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.
|
- Multiple files downloading at the same time.
|
||||||
- Queue-based downloads with drag-and-drop priority ordering.
|
- Queue-based downloads with drag-and-drop priority ordering.
|
||||||
- Native macOS Settings window, available from App menu > Settings and the main toolbar.
|
- 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`:
|
- Automatic save folders under `~/Downloads`:
|
||||||
- `Musics`
|
- `Musics`
|
||||||
- `Movies`
|
- `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.
|
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
|
## 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 {
|
@Published var preventsSleepWhileDownloading: Bool {
|
||||||
didSet { save() }
|
didSet { save() }
|
||||||
}
|
}
|
||||||
@@ -41,11 +51,13 @@ final class AppSettings: ObservableObject {
|
|||||||
if let data = defaults.data(forKey: storageKey),
|
if let data = defaults.data(forKey: storageKey),
|
||||||
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
||||||
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
||||||
|
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||||
siteLogins = stored.siteLogins
|
siteLogins = stored.siteLogins
|
||||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||||
} else {
|
} else {
|
||||||
perServerConnections = 16
|
perServerConnections = 16
|
||||||
|
maxConcurrentDownloads = 3
|
||||||
preventsSleepWhileDownloading = true
|
preventsSleepWhileDownloading = true
|
||||||
siteLogins = []
|
siteLogins = []
|
||||||
downloadDirectories = Self.defaultDirectories()
|
downloadDirectories = Self.defaultDirectories()
|
||||||
@@ -106,9 +118,18 @@ final class AppSettings: ObservableObject {
|
|||||||
return DownloadCredentials(username: login.username, password: password)
|
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() {
|
private func save() {
|
||||||
let stored = StoredSettings(
|
let stored = StoredSettings(
|
||||||
perServerConnections: perServerConnections,
|
perServerConnections: perServerConnections,
|
||||||
|
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||||
siteLogins: siteLogins
|
siteLogins: siteLogins
|
||||||
@@ -165,6 +186,7 @@ final class AppSettings: ObservableObject {
|
|||||||
|
|
||||||
private struct StoredSettings: Codable {
|
private struct StoredSettings: Codable {
|
||||||
var perServerConnections: Int
|
var perServerConnections: Int
|
||||||
|
var maxConcurrentDownloads: Int?
|
||||||
var preventsSleepWhileDownloading: Bool
|
var preventsSleepWhileDownloading: Bool
|
||||||
var downloadDirectories: [String: String]
|
var downloadDirectories: [String: String]
|
||||||
var siteLogins: [SiteLogin]
|
var siteLogins: [SiteLogin]
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ final class Aria2DownloadEngine {
|
|||||||
|
|
||||||
func start(
|
func start(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
perServerConnections: Int,
|
|
||||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||||
) throws -> Handle {
|
) throws -> Handle {
|
||||||
@@ -122,7 +121,7 @@ final class Aria2DownloadEngine {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
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.write(input)
|
||||||
}
|
}
|
||||||
inputPipe.fileHandleForWriting.closeFile()
|
inputPipe.fileHandleForWriting.closeFile()
|
||||||
@@ -151,14 +150,14 @@ final class Aria2DownloadEngine {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inputFileContent(for item: DownloadItem, perServerConnections: Int) -> String {
|
private func inputFileContent(for item: DownloadItem) -> String {
|
||||||
let sameHostConnections = min(max(perServerConnections, 1), item.parts)
|
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||||
var lines = [
|
var lines = [
|
||||||
sanitizedOptionValue(item.url.absoluteString),
|
sanitizedOptionValue(item.url.absoluteString),
|
||||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||||
" out=\(sanitizedOptionValue(item.fileName))",
|
" out=\(sanitizedOptionValue(item.fileName))",
|
||||||
" split=\(item.parts)",
|
" split=\(connections)",
|
||||||
" max-connection-per-server=\(sameHostConnections)"
|
" max-connection-per-server=\(connections)"
|
||||||
]
|
]
|
||||||
|
|
||||||
if let credentials = item.credentials, !credentials.isEmpty {
|
if let credentials = item.credentials, !credentials.isEmpty {
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var controller: DownloadController
|
@EnvironmentObject private var controller: DownloadController
|
||||||
@State private var urlText = ""
|
@Environment(\.openWindow) private var openWindow
|
||||||
@State private var parts = 16.0
|
|
||||||
@State private var selection: DownloadItem.ID?
|
@State private var selection: DownloadItem.ID?
|
||||||
|
@State private var sidebarFilter: DownloadSidebarFilter = .all
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView()
|
SidebarView(selection: $sidebarFilter)
|
||||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
|
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
|
||||||
} detail: {
|
} detail: {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
AddDownloadBar(
|
DownloadTable(
|
||||||
urlText: $urlText,
|
items: filteredDownloads,
|
||||||
parts: $parts,
|
selection: $selection,
|
||||||
addAction: addDownload
|
title: sidebarFilter.title
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
DownloadTable(selection: $selection)
|
|
||||||
Divider()
|
|
||||||
StatusBar()
|
StatusBar()
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup {
|
ToolbarItemGroup {
|
||||||
|
Button {
|
||||||
|
openWindow(id: "add-downloads")
|
||||||
|
} label: {
|
||||||
|
Label("Add", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
controller.startQueue()
|
controller.startQueue()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -39,7 +45,7 @@ struct ContentView: View {
|
|||||||
Button {
|
Button {
|
||||||
controller.pause(selectedItem)
|
controller.pause(selectedItem)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Pause", systemImage: "pause.fill")
|
Label("Stop", systemImage: "stop.fill")
|
||||||
}
|
}
|
||||||
} else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled {
|
} else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled {
|
||||||
Button {
|
Button {
|
||||||
@@ -65,36 +71,73 @@ struct ContentView: View {
|
|||||||
return controller.downloads.first(where: { $0.id == selection })
|
return controller.downloads.first(where: { $0.id == selection })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addDownload() {
|
private var filteredDownloads: [DownloadItem] {
|
||||||
controller.add(
|
switch sidebarFilter {
|
||||||
urlText: urlText,
|
case .all:
|
||||||
parts: Int(parts)
|
controller.downloads
|
||||||
)
|
case .queued:
|
||||||
if controller.engineMessage.hasPrefix("Added") {
|
controller.downloads.filter { $0.status == .queued }
|
||||||
urlText = ""
|
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 {
|
private struct SidebarView: View {
|
||||||
@EnvironmentObject private var controller: DownloadController
|
@EnvironmentObject private var controller: DownloadController
|
||||||
|
@Binding var selection: DownloadSidebarFilter
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List(selection: $selection) {
|
||||||
Section("Library") {
|
Section("Library") {
|
||||||
|
Label("All", systemImage: "tray.full")
|
||||||
|
.badge(controller.downloads.count)
|
||||||
|
.tag(DownloadSidebarFilter.all)
|
||||||
Label("Queue", systemImage: "list.bullet")
|
Label("Queue", systemImage: "list.bullet")
|
||||||
.badge(controller.queuedCount)
|
.badge(controller.queuedCount)
|
||||||
|
.tag(DownloadSidebarFilter.queued)
|
||||||
Label("Active", systemImage: "bolt.fill")
|
Label("Active", systemImage: "bolt.fill")
|
||||||
.badge(controller.activeCount)
|
.badge(controller.activeCount)
|
||||||
|
.tag(DownloadSidebarFilter.active)
|
||||||
Label("Completed", systemImage: "checkmark.circle")
|
Label("Completed", systemImage: "checkmark.circle")
|
||||||
.badge(controller.completedCount)
|
.badge(controller.completedCount)
|
||||||
|
.tag(DownloadSidebarFilter.completed)
|
||||||
Label("Failed", systemImage: "exclamationmark.triangle")
|
Label("Failed", systemImage: "exclamationmark.triangle")
|
||||||
.badge(controller.failedCount)
|
.badge(controller.failedCount)
|
||||||
|
.tag(DownloadSidebarFilter.failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Folders") {
|
Section("Folders") {
|
||||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||||
Label(category.rawValue, systemImage: category.symbolName)
|
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)
|
.foregroundStyle(controller.hasAria2 ? .green : .orange)
|
||||||
Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing")
|
Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing")
|
||||||
}
|
}
|
||||||
Stepper("Parallel files: \(controller.maxConcurrentDownloads)", value: $controller.maxConcurrentDownloads, in: 1...12)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AddDownloadBar: View {
|
enum DownloadColumn: String, CaseIterable, Identifiable {
|
||||||
@Binding var urlText: String
|
case fileName = "File name"
|
||||||
@Binding var parts: Double
|
case size = "Size"
|
||||||
let addAction: () -> Void
|
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 {
|
var id: String { rawValue }
|
||||||
VStack(spacing: 12) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
TextField("Download URL", text: $urlText)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
|
|
||||||
Button(action: addAction) {
|
var width: CGFloat {
|
||||||
Label("Add", systemImage: "plus")
|
switch self {
|
||||||
}
|
case .fileName: 320
|
||||||
.buttonStyle(.borderedProminent)
|
case .size: 100
|
||||||
.disabled(urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
case .status: 105
|
||||||
}
|
case .progress: 95
|
||||||
|
case .lastTry, .dateAdded: 155
|
||||||
HStack(spacing: 12) {
|
case .category: 105
|
||||||
HStack {
|
case .connections, .liveConnections: 95
|
||||||
Image(systemName: "square.split.2x2")
|
case .speed, .eta: 90
|
||||||
Slider(value: $parts, in: 16...32, step: 1)
|
case .destination: 240
|
||||||
.frame(width: 160)
|
case .url: 280
|
||||||
Text("\(Int(parts)) parts")
|
case .message: 220
|
||||||
.monospacedDigit()
|
|
||||||
.frame(width: 62, alignment: .trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.font(.callout)
|
|
||||||
}
|
}
|
||||||
.padding(14)
|
}
|
||||||
.background(.background)
|
}
|
||||||
|
|
||||||
|
enum SortDirection {
|
||||||
|
case ascending
|
||||||
|
case descending
|
||||||
|
|
||||||
|
mutating func toggle() {
|
||||||
|
self = self == .ascending ? .descending : .ascending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DownloadTable: View {
|
private struct DownloadTable: View {
|
||||||
@EnvironmentObject private var controller: DownloadController
|
@EnvironmentObject private var controller: DownloadController
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
let items: [DownloadItem]
|
||||||
@Binding var selection: DownloadItem.ID?
|
@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 {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
VStack(spacing: 0) {
|
||||||
ForEach(controller.downloads) { item in
|
HStack {
|
||||||
DownloadRow(item: item)
|
Text(title)
|
||||||
.tag(item.id)
|
.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 {
|
private struct DownloadRow: View {
|
||||||
let item: DownloadItem
|
let item: DownloadItem
|
||||||
|
let visibleColumns: [DownloadColumn]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
GridRow {
|
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)
|
Image(systemName: item.category.symbolName)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(categoryColor)
|
.foregroundStyle(categoryColor)
|
||||||
.frame(width: 24)
|
.frame(width: 22)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.fileName)
|
Text(item.fileName)
|
||||||
@@ -190,42 +461,51 @@ private struct DownloadRow: View {
|
|||||||
.foregroundStyle(statusColor)
|
.foregroundStyle(statusColor)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressView(value: item.progress)
|
ProgressView(value: item.progress)
|
||||||
.progressViewStyle(.linear)
|
.progressViewStyle(.linear)
|
||||||
|
|
||||||
Text(item.url.absoluteString)
|
Text(item.url.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.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)
|
|
||||||
}
|
}
|
||||||
}
|
case .size:
|
||||||
.padding(.vertical, 7)
|
Text(ByteFormatter.string(item.sizeBytes))
|
||||||
}
|
.monospacedDigit()
|
||||||
|
case .status:
|
||||||
private func metric(_ label: String, _ value: String) -> some View {
|
Text(item.status.rawValue)
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
case .progress:
|
||||||
Text(label)
|
Text(item.progress, format: .percent.precision(.fractionLength(0)))
|
||||||
.font(.caption2)
|
.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)
|
.foregroundStyle(.secondary)
|
||||||
Text(value)
|
.lineLimit(2)
|
||||||
.font(.callout.monospacedDigit())
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.frame(width: 72, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var categoryColor: Color {
|
private var categoryColor: Color {
|
||||||
@@ -249,6 +529,11 @@ private struct DownloadRow: View {
|
|||||||
case .canceled: .gray
|
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 {
|
private struct StatusBar: View {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class DownloadController: ObservableObject {
|
final class DownloadController: ObservableObject {
|
||||||
@Published var downloads: [DownloadItem] = []
|
@Published var downloads: [DownloadItem] = []
|
||||||
@Published var maxConcurrentDownloads = 3
|
|
||||||
@Published var engineMessage = ""
|
@Published var engineMessage = ""
|
||||||
|
|
||||||
private let settings: AppSettings
|
private let settings: AppSettings
|
||||||
@@ -46,7 +45,7 @@ final class DownloadController: ObservableObject {
|
|||||||
Aria2DownloadEngine.findExecutable() != nil
|
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)),
|
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
let scheme = url.scheme?.lowercased(),
|
let scheme = url.scheme?.lowercased(),
|
||||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||||
@@ -61,7 +60,7 @@ final class DownloadController: ObservableObject {
|
|||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
category: category,
|
category: category,
|
||||||
destinationDirectory: settings.destinationDirectory(for: 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)
|
credentials: settings.credentials(for: url)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,6 +68,36 @@ final class DownloadController: ObservableObject {
|
|||||||
engineMessage = "Added \(fileName) to \(category.rawValue)."
|
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() {
|
func startQueue() {
|
||||||
engineMessage = ""
|
engineMessage = ""
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
@@ -85,6 +114,22 @@ final class DownloadController: ObservableObject {
|
|||||||
pumpQueue()
|
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) {
|
func resume(_ item: DownloadItem) {
|
||||||
update(item.id) {
|
update(item.id) {
|
||||||
$0.status = .queued
|
$0.status = .queued
|
||||||
@@ -114,6 +159,13 @@ final class DownloadController: ObservableObject {
|
|||||||
updateSleepActivity()
|
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) {
|
func move(from source: IndexSet, to destination: Int) {
|
||||||
downloads.move(fromOffsets: source, toOffset: destination)
|
downloads.move(fromOffsets: source, toOffset: destination)
|
||||||
}
|
}
|
||||||
@@ -124,7 +176,7 @@ final class DownloadController: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
while activeCount < maxConcurrentDownloads,
|
while activeCount < settings.maxConcurrentDownloads,
|
||||||
let next = downloads.first(where: { $0.status == .queued }) {
|
let next = downloads.first(where: { $0.status == .queued }) {
|
||||||
start(next)
|
start(next)
|
||||||
}
|
}
|
||||||
@@ -133,6 +185,7 @@ final class DownloadController: ObservableObject {
|
|||||||
private func start(_ item: DownloadItem) {
|
private func start(_ item: DownloadItem) {
|
||||||
update(item.id) {
|
update(item.id) {
|
||||||
$0.status = .downloading
|
$0.status = .downloading
|
||||||
|
$0.lastTryAt = Date()
|
||||||
$0.message = "Starting"
|
$0.message = "Starting"
|
||||||
$0.speedText = "-"
|
$0.speedText = "-"
|
||||||
$0.etaText = "-"
|
$0.etaText = "-"
|
||||||
@@ -141,7 +194,6 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let handle = try engine.start(
|
let handle = try engine.start(
|
||||||
item: item,
|
item: item,
|
||||||
perServerConnections: settings.perServerConnections,
|
|
||||||
progress: { [weak self] progress in
|
progress: { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.update(item.id) {
|
self?.update(item.id) {
|
||||||
@@ -204,6 +256,25 @@ final class DownloadController: ObservableObject {
|
|||||||
mutate(&downloads[index])
|
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() {
|
private func updateSleepActivity() {
|
||||||
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
|
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)
|
.frame(minWidth: 980, minHeight: 640)
|
||||||
}
|
}
|
||||||
.windowStyle(.titleBar)
|
.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 {
|
.commands {
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
Button("Start Queue") {
|
Button("Start Queue") {
|
||||||
|
|||||||
@@ -45,16 +45,18 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
|||||||
var fileName: String
|
var fileName: String
|
||||||
var category: DownloadCategory
|
var category: DownloadCategory
|
||||||
var destinationDirectory: URL
|
var destinationDirectory: URL
|
||||||
var parts: Int
|
var connectionsPerServer: Int
|
||||||
var credentials: DownloadCredentials?
|
var credentials: DownloadCredentials?
|
||||||
var status: DownloadStatus = .queued
|
var status: DownloadStatus = .queued
|
||||||
var progress: Double = 0
|
var progress: Double = 0
|
||||||
var speedText: String = "-"
|
var speedText: String = "-"
|
||||||
var etaText: String = "-"
|
var etaText: String = "-"
|
||||||
var connectionCount: Int = 0
|
var connectionCount: Int = 0
|
||||||
|
var sizeBytes: Int64?
|
||||||
var bytesText: String = "-"
|
var bytesText: String = "-"
|
||||||
var message: String = ""
|
var message: String = ""
|
||||||
var createdAt = Date()
|
var createdAt = Date()
|
||||||
|
var lastTryAt: Date?
|
||||||
|
|
||||||
var destinationPath: String {
|
var destinationPath: String {
|
||||||
destinationDirectory.appendingPathComponent(fileName).path
|
destinationDirectory.appendingPathComponent(fileName).path
|
||||||
@@ -68,3 +70,25 @@ struct DownloadProgress: Equatable, Sendable {
|
|||||||
var etaText: String
|
var etaText: String
|
||||||
var connectionCount: Int
|
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()
|
SiteLoginsSettingsPane()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Site Logins", systemImage: "person.crop.circle.badge.key")
|
Label("Site Logins", systemImage: "key.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
PowerSettingsPane()
|
PowerSettingsPane()
|
||||||
@@ -37,11 +37,20 @@ private struct DownloadSettingsPane: View {
|
|||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
Stepper(
|
Stepper(
|
||||||
"Connections per server: \(settings.perServerConnections)",
|
"Default connections per server: \(settings.perServerConnections)",
|
||||||
value: $settings.perServerConnections,
|
value: $settings.perServerConnections,
|
||||||
in: 1...16
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user