commit 5e063f96e5e5037b9f274e632049b1b42f3a24a7 Author: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon Jun 1 04:04:04 2026 +0330 feat: initialize firelink download manager diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50c32fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.build/ +build/ +DerivedData/ +*.xcuserdata/ +*.xcuserstate diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9ba35b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9a44e6 --- /dev/null +++ b/Makefile @@ -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 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7aa2042 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bd047e --- /dev/null +++ b/README.md @@ -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). diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh new file mode 100755 index 0000000..40a3c51 --- /dev/null +++ b/Scripts/create_app_bundle.sh @@ -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" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + local.firelink.swiftui + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $APP_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.utilities + NSHighResolutionCapable + + + +PLIST + +echo "Created $APP_DIR" diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift new file mode 100644 index 0000000..b93f932 --- /dev/null +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -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 + ) 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.. 1 else { + return nil + } + guard let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[captureRange]) + } +} diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift new file mode 100644 index 0000000..8d81227 --- /dev/null +++ b/Sources/Firelink/ContentView.swift @@ -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) + } +} diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift new file mode 100644 index 0000000..13ad4f3 --- /dev/null +++ b/Sources/Firelink/DownloadController.swift @@ -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]) + } +} diff --git a/Sources/Firelink/FileClassifier.swift b/Sources/Firelink/FileClassifier.swift new file mode 100644 index 0000000..674bc2e --- /dev/null +++ b/Sources/Firelink/FileClassifier.swift @@ -0,0 +1,66 @@ +import Foundation + +enum FileClassifier { + private static let musicExtensions: Set = [ + "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 = [ + "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 = [ + "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 = [ + "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 = [ + "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" + } +} diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift new file mode 100644 index 0000000..0aa9cce --- /dev/null +++ b/Sources/Firelink/FirelinkApp.swift @@ -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]) + } + } + } +} diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift new file mode 100644 index 0000000..e62c491 --- /dev/null +++ b/Sources/Firelink/Models.swift @@ -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 +}