feat: initialize firelink download manager

This commit is contained in:
nimbold
2026-06-01 04:04:04 +03:30
commit 5e063f96e5
12 changed files with 1054 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
import Foundation
final class Aria2DownloadEngine {
struct Handle {
let processIdentifier: Int32
let cancel: @Sendable () -> Void
}
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
var errorDescription: String? {
switch self {
case .executableNotFound:
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
case .launchFailed(let details):
"Could not start aria2c: \(details)"
}
}
}
private let executableURL: URL?
init(executableURL: URL? = Aria2DownloadEngine.findExecutable()) {
self.executableURL = executableURL
}
static func findExecutable() -> URL? {
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
}
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for folder in path.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
if FileManager.default.isExecutableFile(atPath: candidate.path) {
return candidate
}
}
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
return nil
}
func start(
item: DownloadItem,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
guard let executableURL else {
throw EngineError.executableNotFound
}
try FileManager.default.createDirectory(
at: item.destinationDirectory,
withIntermediateDirectories: true
)
let process = Process()
process.executableURL = executableURL
process.arguments = arguments(for: item)
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = errorPipe
let parser = Aria2ProgressParser()
let outputBuffer = LockedDataBuffer()
let errorBuffer = LockedDataBuffer()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
outputBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
for update in parser.parse(text) {
progress(update)
}
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completion(.success(()))
return
}
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
}
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
throw EngineError.launchFailed(error.localizedDescription)
}
return Handle(processIdentifier: process.processIdentifier) {
if process.isRunning {
process.terminate()
}
}
}
private func arguments(for item: DownloadItem) -> [String] {
[
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
"--summary-interval=1",
"--console-log-level=warn",
"--download-result=hide",
"--file-allocation=none",
"--min-split-size=1M",
"--input-file=-"
]
}
private func inputFileContent(for item: DownloadItem) -> String {
let sameHostConnections = min(item.parts, 16)
var lines = [
sanitizedOptionValue(item.url.absoluteString),
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName))",
" split=\(item.parts)",
" max-connection-per-server=\(sameHostConnections)"
]
if let credentials = item.credentials, !credentials.isEmpty {
let scheme = item.url.scheme?.lowercased()
if scheme == "ftp" || scheme == "sftp" {
lines.append(" ftp-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" ftp-passwd=\(sanitizedOptionValue(credentials.password))")
} else {
lines.append(" http-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" http-passwd=\(sanitizedOptionValue(credentials.password))")
lines.append(" http-auth-challenge=true")
}
}
return lines.joined(separator: "\n") + "\n"
}
private func sanitizedOptionValue(_ value: String) -> String {
value.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
}
}
final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var storage = Data()
var data: Data {
lock.withLock { storage }
}
func append(_ data: Data) {
lock.withLock {
storage.append(data)
}
}
}
final class Aria2ProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
private let speedRegex = try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)
private let etaRegex = try? NSRegularExpression(pattern: #"ETA:([^\s\]]+)"#)
private let bytesRegex = try? NSRegularExpression(pattern: #"\s([0-9.]+(?:KiB|MiB|GiB|TiB|B)?/[0-9.]+(?:KiB|MiB|GiB|TiB|B)?)\("#)
func parse(_ text: String) -> [DownloadProgress] {
text
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.compactMap { parseLine(String($0)) }
}
private func parseLine(_ line: String) -> DownloadProgress? {
guard line.contains("%") else { return nil }
let percentage = firstCapture(in: line, regex: percentageRegex).flatMap(Double.init) ?? 0
let connections = firstCapture(in: line, regex: connectionRegex).flatMap(Int.init) ?? 0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let bytes = firstCapture(in: line, regex: bytesRegex) ?? "-"
return DownloadProgress(
fraction: min(max(percentage / 100, 0), 1),
bytesText: bytes,
speedText: speed,
etaText: eta,
connectionCount: connections
)
}
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
guard let regex else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
return nil
}
guard let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
+288
View File
@@ -0,0 +1,288 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@State private var urlText = ""
@State private var username = ""
@State private var password = ""
@State private var parts = 16.0
@State private var selection: DownloadItem.ID?
var body: some View {
NavigationSplitView {
SidebarView()
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
} detail: {
VStack(spacing: 0) {
AddDownloadBar(
urlText: $urlText,
username: $username,
password: $password,
parts: $parts,
addAction: addDownload
)
Divider()
DownloadTable(selection: $selection)
Divider()
StatusBar()
}
.toolbar {
ToolbarItemGroup {
Button {
controller.startQueue()
} label: {
Label("Start Queue", systemImage: "play.fill")
}
if let selectedItem {
if selectedItem.status == .downloading {
Button {
controller.pause(selectedItem)
} label: {
Label("Pause", systemImage: "pause.fill")
}
} else if selectedItem.status == .paused || selectedItem.status == .failed || selectedItem.status == .canceled {
Button {
controller.resume(selectedItem)
} label: {
Label("Resume", systemImage: "arrow.clockwise")
}
}
Button(role: .destructive) {
controller.cancel(selectedItem)
} label: {
Label("Cancel", systemImage: "xmark")
}
}
}
}
}
}
private var selectedItem: DownloadItem? {
guard let selection else { return nil }
return controller.downloads.first(where: { $0.id == selection })
}
private func addDownload() {
controller.add(
urlText: urlText,
parts: Int(parts),
username: username,
password: password
)
if controller.engineMessage.hasPrefix("Added") {
urlText = ""
username = ""
password = ""
}
}
}
private struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
var body: some View {
List {
Section("Library") {
Label("Queue", systemImage: "list.bullet")
.badge(controller.queuedCount)
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName)
}
}
Section("Engine") {
HStack {
Image(systemName: controller.hasAria2 ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.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 username: String
@Binding var password: String
@Binding var parts: Double
let addAction: () -> Void
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 10) {
TextField("Download URL", text: $urlText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
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)
}
Divider()
.frame(height: 22)
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.frame(width: 180)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(width: 180)
Spacer()
}
.font(.callout)
}
.padding(14)
.background(.background)
}
}
private struct DownloadTable: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: DownloadItem.ID?
var body: some View {
List(selection: $selection) {
ForEach(controller.downloads) { item in
DownloadRow(item: item)
.tag(item.id)
}
.onMove(perform: controller.move)
.onDelete(perform: controller.remove)
}
.listStyle(.inset)
}
}
private struct DownloadRow: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
Text(item.status.rawValue)
.font(.caption)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(statusColor.opacity(0.14))
.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)
.foregroundStyle(.secondary)
Text(value)
.font(.callout.monospacedDigit())
.lineLimit(1)
}
.frame(width: 72, alignment: .trailing)
}
private var categoryColor: Color {
switch item.category {
case .musics: .pink
case .movies: .indigo
case .compressed: .orange
case .pictures: .teal
case .documents: .blue
case .other: .gray
}
}
private var statusColor: Color {
switch item.status {
case .queued: .secondary
case .downloading: .blue
case .paused: .orange
case .completed: .green
case .failed: .red
case .canceled: .gray
}
}
}
private struct StatusBar: View {
@EnvironmentObject private var controller: DownloadController
var body: some View {
HStack {
Text(controller.engineMessage.isEmpty ? "Ready" : controller.engineMessage)
.foregroundStyle(controller.hasAria2 ? Color.secondary : Color.orange)
.lineLimit(1)
Spacer()
Text("\(controller.activeCount) active")
Text("\(controller.queuedCount) queued")
Text("\(controller.completedCount) done")
}
.font(.caption)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
}
}
+184
View File
@@ -0,0 +1,184 @@
import Combine
import Foundation
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var maxConcurrentDownloads = 3
@Published var engineMessage = ""
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
var activeCount: Int {
downloads.filter { $0.status == .downloading }.count
}
var queuedCount: Int {
downloads.filter { $0.status == .queued }.count
}
var completedCount: Int {
downloads.filter { $0.status == .completed }.count
}
var failedCount: Int {
downloads.filter { $0.status == .failed }.count
}
var hasAria2: Bool {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, parts: Int, username: String, password: String) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
engineMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
return
}
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
let credentials = DownloadCredentials(username: username, password: password)
let item = DownloadItem(
url: url,
fileName: fileName,
category: category,
destinationDirectory: FileClassifier.destinationDirectory(for: category),
parts: min(max(parts, 16), 32),
credentials: credentials.isEmpty ? nil : credentials
)
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
}
func startQueue() {
engineMessage = ""
pumpQueue()
}
func pause(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
}
pumpQueue()
}
func resume(_ item: DownloadItem) {
update(item.id) {
$0.status = .queued
$0.message = ""
}
pumpQueue()
}
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
}
pumpQueue()
}
func remove(at offsets: IndexSet) {
for index in offsets {
let item = downloads[index]
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
}
downloads.remove(atOffsets: offsets)
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
}
private func pumpQueue() {
guard hasAria2 else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return
}
while activeCount < maxConcurrentDownloads,
let next = downloads.first(where: { $0.status == .queued }) {
start(next)
}
}
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
}
do {
let handle = try engine.start(
item: item,
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
},
completion: { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.activeHandles[item.id] = nil
switch result {
case .success:
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
}
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
}
self.pumpQueue()
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.message = "Process \(handle.processIdentifier)"
}
} catch {
update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
pumpQueue()
}
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
enum FileClassifier {
private static let musicExtensions: Set<String> = [
"aac", "aif", "aiff", "alac", "amr", "ape", "au", "caf", "flac", "m4a",
"m4b", "mid", "midi", "mp3", "oga", "ogg", "opus", "ra", "wav", "weba",
"wma"
]
private static let movieExtensions: Set<String> = [
"3g2", "3gp", "avi", "divx", "f4v", "flv", "m2ts", "m4v", "mkv", "mov",
"mp4", "mpeg", "mpg", "mts", "ogm", "ogv", "rm", "rmvb", "ts", "vob",
"webm", "wmv"
]
private static let compressedExtensions: Set<String> = [
"7z", "ace", "alz", "apk", "appx", "ar", "arc", "arj", "bz", "bz2",
"cab", "cpio", "deb", "dmg", "gz", "gzip", "iso", "jar", "lha", "lzh",
"lz", "lz4", "lzip", "lzma", "pak", "pkg", "rar", "rpm", "sit", "sitx",
"tar", "tbz", "tbz2", "tgz", "tlz", "txz", "war", "whl", "xar", "xz",
"z", "zip", "zipx", "zst"
]
private static let pictureExtensions: Set<String> = [
"ai", "apng", "avif", "bmp", "cr2", "cr3", "dng", "emf", "eps", "gif",
"heic", "heif", "ico", "indd", "jfif", "jpeg", "jpg", "jxl", "nef",
"orf", "pbm", "pgm", "png", "pnm", "ppm", "psd", "raw", "rw2",
"svg", "tga", "tif", "tiff", "webp", "wmf"
]
private static let documentExtensions: Set<String> = [
"azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx",
"epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf",
"numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx",
"ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm",
"xlsx", "xml", "xps", "yaml", "yml"
]
static func category(forFileName fileName: String) -> DownloadCategory {
let ext = (fileName as NSString).pathExtension.lowercased()
guard !ext.isEmpty else { return .other }
if musicExtensions.contains(ext) { return .musics }
if movieExtensions.contains(ext) { return .movies }
if compressedExtensions.contains(ext) { return .compressed }
if pictureExtensions.contains(ext) { return .pictures }
if documentExtensions.contains(ext) { return .documents }
return .other
}
static func destinationDirectory(for category: DownloadCategory) -> URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
}
static func fileName(from url: URL) -> String {
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
if !pathName.isEmpty, pathName != "/" {
return pathName
}
let host = url.host(percentEncoded: false) ?? "download"
return "\(host)-download"
}
}
+23
View File
@@ -0,0 +1,23 @@
import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var controller = DownloadController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(controller)
.frame(minWidth: 980, minHeight: 640)
}
.windowStyle(.titleBar)
.commands {
CommandGroup(after: .newItem) {
Button("Start Queue") {
controller.startQueue()
}
.keyboardShortcut("r", modifiers: [.command])
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
enum DownloadStatus: String, Codable, CaseIterable, Sendable {
case queued = "Queued"
case downloading = "Downloading"
case paused = "Paused"
case completed = "Completed"
case failed = "Failed"
case canceled = "Canceled"
}
enum DownloadCategory: String, Codable, CaseIterable, Sendable {
case musics = "Musics"
case movies = "Movies"
case compressed = "Compressed"
case pictures = "Pictures"
case documents = "Documents"
case other = "Other"
var symbolName: String {
switch self {
case .musics: "music.note"
case .movies: "film"
case .compressed: "archivebox"
case .pictures: "photo"
case .documents: "doc.text"
case .other: "folder"
}
}
}
struct DownloadCredentials: Codable, Equatable, Sendable {
var username: String
var password: String
var isEmpty: Bool {
username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
password.isEmpty
}
}
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var destinationDirectory: URL
var parts: Int
var credentials: DownloadCredentials?
var status: DownloadStatus = .queued
var progress: Double = 0
var speedText: String = "-"
var etaText: String = "-"
var connectionCount: Int = 0
var bytesText: String = "-"
var message: String = ""
var createdAt = Date()
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
}
struct DownloadProgress: Equatable, Sendable {
var fraction: Double
var bytesText: String
var speedText: String
var etaText: String
var connectionCount: Int
}