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
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
.build/
build/
DerivedData/
*.xcuserdata/
*.xcuserstate
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 nimbold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
.PHONY: build app run clean
build:
swift build -c release
app:
Scripts/create_app_bundle.sh
run:
swift run Firelink
clean:
swift package clean
rm -rf build
+19
View File
@@ -0,0 +1,19 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Firelink",
platforms: [
.macOS(.v14)
],
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
targets: [
.executableTarget(
name: "Firelink",
path: "Sources/Firelink"
)
]
)
+72
View File
@@ -0,0 +1,72 @@
# Firelink
Firelink is a clean SwiftUI download manager for Apple Silicon Macs. The goal is to bring the practical parts of IDM/FDM-style download management to macOS with a native interface, segmented downloads, queue control, automatic file organization, and credential-aware transfers.
This project is early, but it already has a working native prototype and an `aria2c`-backed download engine.
## Features
- Native SwiftUI macOS interface.
- Segmented downloads with 16-32 requested parts per file.
- Multiple files downloading at the same time.
- Queue-based downloads with drag-and-drop priority ordering.
- Automatic save folders under `~/Downloads`:
- `Musics`
- `Movies`
- `Compressed`
- `Pictures`
- `Documents`
- `Other`
- Broad file extension detection for audio, video, archive, image, and document formats.
- HTTP, HTTPS, FTP, and SFTP URL support through `aria2c`.
- Optional per-download username/password support for servers that require authentication.
- Pause, resume, cancel, delete, progress, speed, ETA, and connection count display.
- Release `.app` bundle script for local macOS builds.
## Engine
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.
## Requirements
Install the engine:
```sh
brew install aria2
```
- macOS 14 or newer.
- Apple Silicon Mac.
- Swift 6 toolchain.
- `aria2c` installed with Homebrew, or bundled into the app resources later.
## Run
```sh
swift run Firelink
```
Build a release `.app` bundle:
```sh
make app
open build/Firelink.app
```
Because the current machine only has Command Line Tools selected, this repository is set up as a Swift Package with a bundling script rather than a generated Xcode project. Opening the package in Xcode will still give you a native macOS app workflow.
## Roadmap
- Persist download history and queue state.
- Add Keychain-backed credential storage.
- Add browser integration and URL capture.
- Add scheduler rules and speed limits.
- Add checksum verification.
- Add richer failure recovery and retry policies.
- Add unit tests for file classification, queue behavior, and `aria2c` progress parsing.
## License
Firelink is released under the MIT License. See [LICENSE](LICENSE).
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
cd "$ROOT_DIR"
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>local.firelink.swiftui</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
echo "Created $APP_DIR"
+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
}