mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat: initialize firelink download manager
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
.build/
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserdata/
|
||||
*.xcuserstate
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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).
|
||||
Executable
+50
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user