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