Compare commits

...

18 Commits

Author SHA1 Message Date
nimbold f02a156b57 Fix CI runner: specify macos-26 for latest SDK 2026-06-02 06:54:25 +03:30
nimbold b54b5142f0 Fix CI release runner to use macos-latest 2026-06-02 06:44:02 +03:30
nimbold a6f2dc8324 docs: correct CHANGELOG.md to accurately reflect v0.3.0 vs v0.2.0 features 2026-06-02 06:32:44 +03:30
nimbold 5442174e70 chore: create CHANGELOG.md for v0.3.0 release 2026-06-02 06:25:44 +03:30
nimbold b91f6abdb0 build: bundle aria2c and dependencies via dylibbundler for zero-config distribution 2026-06-02 06:18:38 +03:30
nimbold cba94a7415 docs: use HTML badges inside centered header for reliable rendering 2026-06-02 06:08:01 +03:30
nimbold 7338ba0a9f docs: center app icon above title in README 2026-06-02 06:04:19 +03:30
nimbold 53d667c70e docs: add app icon to header and update roadmap 2026-06-02 05:58:47 +03:30
nimbold 31e0acd05c docs: update README with release features 2026-06-02 05:58:47 +03:30
Nima 4c7de3ab82 Merge pull request #1 from nimbold/codex/about-update-check
feat(settings): add about pane update checks
2026-06-02 05:26:30 +03:30
nimbold 2a7d359be8 feat(settings): add about pane update checks 2026-06-02 05:20:50 +03:30
nimbold fd6457ddc5 feat: improve queue management 2026-06-02 05:12:23 +03:30
nimbold ac039a50c8 feat: add queue management 2026-06-02 04:48:10 +03:30
nimbold dd348c46da feat: add download recovery 2026-06-02 04:29:14 +03:30
nimbold a4740190fd Add engine settings page 2026-06-02 04:22:47 +03:30
nimbold 22aeff4740 Add network proxy settings 2026-06-02 04:15:53 +03:30
nimbold fa018dff05 Move settings into main sidebar 2026-06-02 04:05:36 +03:30
nimbold 340ae6b0fe feat: add application icon 2026-06-02 03:54:50 +03:30
17 changed files with 1501 additions and 220 deletions
+4 -1
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
macos-arm64-dmg:
name: Build macOS ARM64 DMG
runs-on: macos-15
runs-on: macos-26
steps:
- name: Check out repository
@@ -41,6 +41,9 @@ jobs:
uname -a
swift --version
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.0] - 2026-06-02
### Added
- **Zero-Config Setup:** Firelink now automatically bundles the `aria2c` engine and all of its dynamic library dependencies internally via `dylibbundler`. End-users no longer need to install Homebrew or `aria2c` manually!
### Changed
- **README Redesign:** Modernized the README with a clean layout, centered App Icon header, and updated roadmap.
- **CI Releases:** The GitHub Actions DMG release pipeline now automatically fetches and packages dependencies during builds.
## [0.2.1] - 2026-06-02
### Changed
- Fixed CI release runner specifying macOS 26.
## [0.2.0] - 2026-06-01
### Added
- **In-App Update Checker:** Built-in GitHub release checks inside the Settings About pane.
- **Queue Management:** Advanced drag-and-drop priority ordering and queue management controls.
- **Download Recovery:** Built-in download recovery and automated retry policies.
- Initial core download engine with `aria2c` support.
- Native macOS Settings pane.
- Smart file categorization and organization based on extension detection.
- Keychain-secured authentication integration.
+17 -14
View File
@@ -1,11 +1,12 @@
<div align="center">
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
[![Swift Version](https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white)](https://swift.org)
[![Platform Support](https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white)](https://apple.com)
[![Engine](https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white)](https://aria2.github.io/)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
</div>
---
@@ -17,8 +18,9 @@
## ✨ Key Features
-**High-Speed Downloads:** Multi-segmented download engine powered by `aria2c` for concurrent connections and optimal bandwidth utilization. Supports HTTP, HTTPS, FTP, and SFTP.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and a native Settings panel.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and an in-app Settings page with a built-in update checker.
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
- 🛡️ **Reliability & Recovery:** Built-in download recovery and retry handling for interrupted or unstable transfers.
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
@@ -31,6 +33,7 @@
- Segmented downloads with per-file connection/split counts.
- Multi-threaded, parallel downloading with configurable limits.
- Support for HTTP, HTTPS, FTP, and SFTP transfers via `aria2c`.
- Automatic download recovery and retry handling for unstable connections.
- **Advanced Queue Control:**
- Drag-and-drop download reordering to manage priorities.
- Comprehensive download table with resizable, custom columns (Name, Size, Status, ETA, Dates).
@@ -46,6 +49,7 @@
- **Security & System:**
- Local macOS Keychain integration for site logins.
- Prevents system sleep while active transfers are in progress, preserving display sleep.
- Built-in update checker inside the Settings About pane.
- Release `.app` bundle build scripts ready for distribution.
</details>
@@ -63,13 +67,11 @@ Firelink leverages `aria2c` under the hood as its core download engine. Unlike s
## 🛠️ Requirements & Setup
### 1. Install Dependencies
Ensure you have **Homebrew** and the **Swift toolchain** installed:
```bash
brew install aria2
```
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon)
- **Toolchain:** Xcode 15+ / Swift 6.0 toolchain
### 1. Requirements
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon).
- **Engine:** `aria2c` is fully packaged and bundled internally for a true Zero-Config experience. No external installations are required.
*For Developers:* If you are building the project from source, you must have the **Swift 6.0 toolchain** (Xcode 15+) installed.
### 2. Build & Run
Run the application directly via the terminal:
@@ -99,11 +101,12 @@ git push origin v0.1.0
## 🗺️ Roadmap
- [ ] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
- [ ] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
- [ ] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
- [ ] **Distribution:** Notarized release builds, automated Sparkle updates, and Homebrew formulae.
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
- [ ] **Distribution:** Notarized `.app` releases and Homebrew formulae.
---
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+20
View File
@@ -10,6 +10,7 @@ APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
swift build -c "$CONFIGURATION"
@@ -17,6 +18,23 @@ swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
@@ -33,6 +51,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$ICON_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+5 -1
View File
@@ -11,6 +11,7 @@ struct AddDownloadsView: View {
@State private var overrideDestination = false
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID
var body: some View {
VStack(spacing: 0) {
@@ -33,6 +34,8 @@ struct AddDownloadsView: View {
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
linkText = text
controller.pendingPasteboardText = nil
@@ -290,7 +293,8 @@ struct AddDownloadsView: View {
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start
startImmediately: start,
queueID: targetQueueID
)
dismiss()
}
+79
View File
@@ -6,6 +6,63 @@ struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
var username: String
}
enum ProxyMode: String, Codable, CaseIterable, Sendable {
case none
case system
case custom
var title: String {
switch self {
case .none: "No proxy"
case .system: "Use system proxy"
case .custom: "Set proxy"
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
}
}
var uriScheme: String {
rawValue
}
}
struct ProxySettings: Codable, Equatable, Sendable {
var mode: ProxyMode = .none
var type: ProxyType = .http
var host = ""
var port = 8080
var normalized: ProxySettings {
var copy = self
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535)
return copy
}
var customProxyURI: String? {
let clean = normalized
guard !clean.host.isEmpty else { return nil }
return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)"
}
}
struct DownloadProxyConfiguration: Equatable, Sendable {
var mode: ProxyMode
var customProxyURI: String?
}
@MainActor
final class AppSettings: ObservableObject {
@Published var perServerConnections: Int {
@@ -32,6 +89,17 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
if proxySettings != normalized {
proxySettings = normalized
return
}
save()
}
}
@Published var downloadDirectories: [DownloadCategory: String] {
didSet { save() }
}
@@ -53,12 +121,14 @@ final class AppSettings: ObservableObject {
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
perServerConnections = 16
maxConcurrentDownloads = 3
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
downloadDirectories = Self.defaultDirectories()
}
@@ -83,6 +153,13 @@ final class AppSettings: ObservableObject {
downloadDirectories = Self.defaultDirectories()
}
var downloadProxyConfiguration: DownloadProxyConfiguration {
DownloadProxyConfiguration(
mode: proxySettings.mode,
customProxyURI: proxySettings.customProxyURI
)
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -131,6 +208,7 @@ final class AppSettings: ObservableObject {
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
@@ -188,6 +266,7 @@ private struct StoredSettings: Codable {
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
+132 -3
View File
@@ -1,4 +1,5 @@
import Foundation
import CFNetwork
final class Aria2DownloadEngine {
struct Handle {
@@ -9,6 +10,7 @@ final class Aria2DownloadEngine {
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
case unsupportedProxy(String)
var errorDescription: String? {
switch self {
@@ -16,6 +18,8 @@ final class Aria2DownloadEngine {
"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)"
case .unsupportedProxy(let details):
details
}
}
}
@@ -53,8 +57,35 @@ final class Aria2DownloadEngine {
return nil
}
static func versionString() -> String? {
guard let executableURL = findExecutable() else { return nil }
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}
func start(
item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
@@ -69,7 +100,7 @@ final class Aria2DownloadEngine {
let process = Process()
process.executableURL = executableURL
process.arguments = arguments(for: item)
process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration)
let inputPipe = Pipe()
let outputPipe = Pipe()
@@ -136,8 +167,8 @@ final class Aria2DownloadEngine {
}
}
private func arguments(for item: DownloadItem) -> [String] {
[
private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] {
var arguments = [
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
@@ -146,8 +177,106 @@ final class Aria2DownloadEngine {
"--download-result=hide",
"--file-allocation=none",
"--min-split-size=1M",
"--max-tries=10",
"--retry-wait=5",
"--connect-timeout=30",
"--timeout=60",
"--input-file=-"
]
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
switch configuration.mode {
case .none:
return clearedProxyArguments()
case .system:
switch systemProxyResolution(for: item.url) {
case .direct:
return clearedProxyArguments()
case .proxy(let proxyURI):
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
case .unsupported(let message):
throw EngineError.unsupportedProxy(message)
}
case .custom:
guard let proxyURI = configuration.customProxyURI else { return [] }
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
}
}
private func clearedProxyArguments() -> [String] {
[
"--all-proxy=",
"--http-proxy=",
"--https-proxy=",
"--ftp-proxy="
]
}
private func proxyArgumentName(for urlScheme: String?) -> String {
switch urlScheme?.lowercased() {
case "http": "--http-proxy"
case "https": "--https-proxy"
case "ftp": "--ftp-proxy"
default: "--all-proxy"
}
}
private enum SystemProxyResolution {
case direct
case proxy(String)
case unsupported(String)
}
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
return .direct
}
for proxy in proxies {
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
if type == kCFProxyTypeNone as String {
return .direct
}
if type == kCFProxyTypeSOCKS as String {
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
}
if type == kCFProxyTypeAutoConfigurationURL as String ||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
}
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
return .proxy(uri)
}
}
return .direct
}
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
!host.isEmpty else {
return nil
}
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
let scheme: String
if type == kCFProxyTypeHTTPS as String {
scheme = "https"
} else if type == kCFProxyTypeFTP as String {
scheme = "ftp"
} else {
scheme = "http"
}
guard let port else {
return "\(scheme)://\(host)"
}
return "\(scheme)://\(host):\(port)"
}
private func inputFileContent(for item: DownloadItem) -> String {
+121 -90
View File
@@ -5,111 +5,141 @@ struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarFilter: DownloadSidebarFilter = .all
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@State private var showDeleteConfirmation = false
var body: some View {
NavigationSplitView {
SidebarView(selection: $sidebarFilter)
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
} detail: {
VStack(spacing: 0) {
DownloadTable(
items: filteredDownloads,
selection: $selection,
title: sidebarFilter.title
)
Divider()
StatusBar()
detailView
}
}
@ViewBuilder
private var detailView: some View {
switch sidebarSelection {
case .downloads(let filter):
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .settings:
SettingsView()
}
}
private func queueView(queueID: UUID) -> some View {
downloadsView(
filter: .all,
title: controller.queueName(for: queueID),
items: controller.queueItems(for: queueID),
queueID: queueID
)
}
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
downloadsView(
filter: filter,
title: filter.title,
items: filteredDownloads(for: filter),
queueID: nil
)
}
private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View {
VStack(spacing: 0) {
DownloadTable(
items: items,
selection: $selection,
title: title,
queueID: queueID
)
Divider()
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
ToolbarItem {
Button {
controller.startQueue()
} label: {
Label("Start Queue", systemImage: "play.fill")
}
ToolbarItem {
Button {
controller.startQueue(queueID: queueID)
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
Label("Stop", systemImage: "stop.fill")
}
}
}
ToolbarItem {
SettingsLink {
Label("Settings", systemImage: "gearshape")
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
}
}
.background {
Button("") {
if !selection.isEmpty {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
handlePaste()
}
}
.background {
Button("") {
if !selection.isEmpty {
showDeleteConfirmation = true
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
selectAll()
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
Button("") {
handlePaste(queueID: queueID)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
Button("") {
selectAll(items: items)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
}
}
@@ -124,18 +154,19 @@ struct ContentView: View {
selection.removeAll()
}
private func handlePaste() {
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
private func selectAll() {
selection = Set(filteredDownloads.map { $0.id })
private func selectAll(items: [DownloadItem]) {
selection = Set(items.map { $0.id })
}
private var filteredDownloads: [DownloadItem] {
switch sidebarFilter {
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
controller.downloads
case .queued:
@@ -144,8 +175,8 @@ struct ContentView: View {
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .failed:
controller.downloads.filter { $0.status == .failed }
case .unfinished:
controller.downloads.filter { $0.status != .completed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
+276 -28
View File
@@ -5,14 +5,19 @@ import Foundation
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
var pendingAddQueueID: UUID?
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
@@ -20,9 +25,9 @@ final class DownloadController: ObservableObject {
init(settings: AppSettings) {
self.settings = settings
loadDownloads()
let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in
Task { @MainActor in
@@ -44,6 +49,14 @@ final class DownloadController: ObservableObject {
self?.saveDownloads()
}
.store(in: &cancellables)
if shouldResumeRecoveredDownloads {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.pumpQueue()
}
}
}
deinit {
@@ -62,15 +75,15 @@ final class DownloadController: ObservableObject {
downloads.filter { $0.status == .completed }.count
}
var failedCount: Int {
downloads.filter { $0.status == .failed }.count
var unfinishedCount: Int {
downloads.filter { $0.status != .completed }.count
}
var hasAria2: Bool {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, connectionsPerServer: Int? = nil) {
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
@@ -86,20 +99,24 @@ final class DownloadController: ObservableObject {
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
credentials: settings.credentials(for: url)
credentials: settings.credentials(for: url),
queueID: normalizedQueueID(queueID)
)
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID
) {
let clampedConnections = min(max(connectionsPerServer, 1), 16)
let targetQueueID = normalizedQueueID(queueID)
let items = pendingDownloads.map { pending in
DownloadItem(
@@ -111,20 +128,24 @@ final class DownloadController: ObservableObject {
credentials: settings.credentials(for: pending.url),
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue"
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
)
}
downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
saveDownloads()
if startImmediately {
startQueue()
startQueue(queueID: targetQueueID)
}
}
func startQueue() {
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
markQueuedDownloadsForAutoResume(queueID: queueID)
pumpQueue()
}
@@ -134,7 +155,10 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -151,15 +175,46 @@ final class DownloadController: ObservableObject {
$0.connectionCount = 0
}
$0.message = "Added to queue"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
let queueID = normalizedQueueID(queueID)
var changed = false
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
guard downloads[index].status != .completed,
downloads[index].status != .downloading else {
continue
}
downloads[index].status = .queued
downloads[index].queueID = queueID
downloads[index].message = "Added to \(queueName(for: queueID))"
downloads[index].autoResumeOnLaunch = false
automaticRetryCounts[downloads[index].id] = nil
changed = true
}
if changed {
saveDownloads()
updateSleepActivity()
}
}
func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.message = ""
$0.autoResumeOnLaunch = true
}
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
@@ -169,7 +224,10 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -190,11 +248,82 @@ final class DownloadController: ObservableObject {
removeCacheFiles(for: item)
}
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
saveDownloads()
}
func queueName(for id: UUID) -> String {
queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name
}
func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id)
return downloads.filter { validQueueID($0.queueID) == id }
}
func queueCount(for id: UUID) -> Int {
queueItems(for: id).count
}
@discardableResult
func addQueue() -> DownloadQueue {
var index = 2
var name = "Queue \(index)"
let existingNames = Set(queues.map(\.name))
while existingNames.contains(name) {
index += 1
name = "Queue \(index)"
}
let queue = DownloadQueue(name: name)
queues.append(queue)
saveDownloads()
return queue
}
func renameQueue(id: UUID, name: String) {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty,
id != DownloadQueue.mainQueueID,
let index = queues.firstIndex(where: { $0.id == id }) else {
return
}
queues[index].name = cleanName
saveDownloads()
}
func removeQueue(id: UUID) {
guard id != DownloadQueue.mainQueueID,
queues.contains(where: { $0.id == id }) else {
return
}
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
downloads[index].queueID = nil
}
queues.removeAll { $0.id == id }
engineMessage = "Removed queue. Downloads remain in Unfinished."
saveDownloads()
}
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID)
guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return
}
let item = downloads.remove(at: source)
downloads.insert(item, at: target)
saveDownloads()
}
private func pumpQueue() {
@@ -204,9 +333,17 @@ final class DownloadController: ObservableObject {
}
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { $0.status == .queued }) {
let next = downloads.first(where: { item in
item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true)
}) {
start(next)
}
if restrictQueueToAutoResume &&
activeCount == 0 &&
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
}
private func start(_ item: DownloadItem) {
@@ -216,11 +353,14 @@ final class DownloadController: ObservableObject {
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
$0.autoResumeOnLaunch = true
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
@@ -240,22 +380,22 @@ final class DownloadController: ObservableObject {
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
}
self.saveDownloads()
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.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
@@ -267,12 +407,10 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
updateSleepActivity()
} catch {
update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
handleDownloadFailure(itemID: item.id, error: error)
updateSleepActivity()
pumpQueue()
}
@@ -300,6 +438,58 @@ final class DownloadController: ObservableObject {
$0.credentials = credentials
$0.message = "Properties updated"
}
saveDownloads()
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
let normalizedID = queueID.map(normalizedQueueID)
for index in downloads.indices where downloads[index].status == .queued &&
(normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) {
downloads[index].autoResumeOnLaunch = true
}
saveDownloads()
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = error.localizedDescription
$0.autoResumeOnLaunch = false
}
saveDownloads()
return
}
automaticRetryCounts[itemID] = retryCount + 1
update(itemID) {
$0.status = .queued
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
private func updateSleepActivity() {
@@ -341,34 +531,92 @@ final class DownloadController: ObservableObject {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(downloads)
let state = StoredDownloadState(queues: queues, downloads: downloads)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
}
}
private func loadDownloads() {
private func loadDownloads() -> Bool {
do {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return }
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL)
let loaded = try JSONDecoder().decode([DownloadItem].self, from: data)
self.downloads = loaded.map { item in
let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState
isLegacyDownloadList = false
} else {
state = StoredDownloadState(
queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
)
isLegacyDownloadList = true
}
var shouldResumeRecoveredDownloads = false
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.status == .downloading {
adjusted.status = .paused
adjusted.message = "Paused on startup"
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = true
shouldResumeRecoveredDownloads = true
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
adjusted.message = "Recovered queued download."
shouldResumeRecoveredDownloads = true
}
return adjusted
}
if shouldResumeRecoveredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
} catch {
print("Failed to load downloads: \(error)")
return false
}
}
private func normalizedQueueID(_ id: UUID?) -> UUID {
validQueueID(id) ?? DownloadQueue.mainQueueID
}
private func validQueueID(_ id: UUID?) -> UUID? {
guard let id, queues.contains(where: { $0.id == id }) else {
return nil
}
return id
}
private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] {
var normalized = queues
if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) {
normalized.insert(.main, at: 0)
}
if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 {
let main = normalized.remove(at: mainIndex)
normalized.insert(main, at: 0)
}
return normalized
}
}
private struct StoredDownloadState: Codable {
var queues: [DownloadQueue]
var downloads: [DownloadItem]
}
private final class SleepActivityHandle: @unchecked Sendable {
+134 -38
View File
@@ -1,7 +1,9 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
case priority = "#"
case fileName = "File name"
case size = "Size"
case progress = "Progress"
@@ -21,6 +23,7 @@ enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
var width: CGFloat {
switch self {
case .priority: return 58
case .fileName: return 340
case .size: return 100
case .status: return 105
@@ -104,11 +107,13 @@ struct DownloadTable: View {
let items: [DownloadItem]
@Binding var selection: Set<DownloadItem.ID>
let title: String
var queueID: UUID?
@StateObject private var tableSettings = TableSettings()
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
@State private var lastSelectedIndex: Int?
@State private var draggedItemID: DownloadItem.ID?
var body: some View {
VStack(spacing: 0) {
@@ -201,6 +206,7 @@ struct DownloadTable: View {
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
DownloadRow(
item: item,
priorityNumber: priorityNumber(for: item),
visibleColumns: orderedVisibleColumns,
columnWidth: { width(for: $0) },
trailingWidth: trailingWidth
@@ -231,39 +237,26 @@ struct DownloadTable: View {
.contextMenu {
rowContextMenu(for: item)
}
.onDrag {
draggedItemID = item.id
return NSItemProvider(object: dragPayload(for: item) as NSString)
}
.onDrop(
of: [.text],
delegate: QueueDropDelegate(
item: item,
queueID: queueID,
draggedItemID: $draggedItemID,
controller: controller
)
)
}
private func tableHeader(trailingWidth: CGFloat) -> some View {
HStack(spacing: 0) {
ForEach(orderedVisibleColumns) { column in
ZStack(alignment: .trailing) {
Button {
if tableSettings.sortColumn == column {
tableSettings.sortDirection.toggle()
} else {
tableSettings.sortColumn = column
tableSettings.sortDirection = .ascending
}
} label: {
HStack(spacing: 4) {
Spacer(minLength: 0)
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.center)
.layoutPriority(1)
if tableSettings.sortColumn == column {
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(width: width(for: column), height: 34, alignment: .center)
.clipped()
}
.buttonStyle(.plain)
headerContent(for: column)
Rectangle()
.fill(.secondary.opacity(0.18))
@@ -301,7 +294,7 @@ struct DownloadTable: View {
.background(.bar)
.contextMenu {
Section("Columns") {
ForEach(DownloadColumn.allCases) { column in
ForEach(availableColumns) { column in
Toggle(column.rawValue, isOn: Binding(
get: { tableSettings.visibleColumns.contains(column) },
set: { isVisible in
@@ -314,10 +307,12 @@ struct DownloadTable: View {
))
}
}
Section("Sort By") {
ForEach(DownloadColumn.allCases) { column in
Button(column.rawValue) {
tableSettings.sortColumn = column
if queueID == nil {
Section("Sort By") {
ForEach(availableColumns) { column in
Button(column.rawValue) {
tableSettings.sortColumn = column
}
}
}
}
@@ -366,10 +361,15 @@ struct DownloadTable: View {
}
}
if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) {
Button {
for target in targetItems where target.status != .downloading && target.status != .queued {
controller.queue(target)
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
@@ -386,7 +386,17 @@ struct DownloadTable: View {
}
private var orderedVisibleColumns: [DownloadColumn] {
DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
if queueID != nil, !columns.contains(.priority) {
columns.insert(.priority, at: 0)
} else if queueID == nil {
columns.removeAll { $0 == .priority }
}
return columns
}
private var availableColumns: [DownloadColumn] {
DownloadColumn.allCases.filter { $0 != .priority }
}
private var totalWidth: CGFloat {
@@ -398,7 +408,11 @@ struct DownloadTable: View {
}
private var sortedItems: [DownloadItem] {
items.sorted { lhs, rhs in
if queueID != nil {
return items
}
return items.sorted { lhs, rhs in
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
if result == .orderedSame {
return lhs.id.uuidString < rhs.id.uuidString
@@ -409,6 +423,8 @@ struct DownloadTable: View {
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
switch column {
case .priority:
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
@@ -426,6 +442,58 @@ struct DownloadTable: View {
}
}
@ViewBuilder
private func headerContent(for column: DownloadColumn) -> some View {
let content = HStack(spacing: 4) {
Spacer(minLength: 0)
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.center)
.layoutPriority(1)
if queueID == nil && tableSettings.sortColumn == column {
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(width: width(for: column), height: 34, alignment: .center)
.clipped()
if queueID == nil {
Button {
if tableSettings.sortColumn == column {
tableSettings.sortDirection.toggle()
} else {
tableSettings.sortColumn = column
tableSettings.sortDirection = .ascending
}
} label: {
content
}
.buttonStyle(.plain)
} else {
content
}
}
private func priorityNumber(for item: DownloadItem) -> Int? {
guard queueID != nil,
let index = items.firstIndex(where: { $0.id == item.id }) else {
return nil
}
return index + 1
}
private func dragPayload(for item: DownloadItem) -> String {
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
return draggedIDs
.map(\.uuidString)
.joined(separator: "\n")
}
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
@@ -456,6 +524,7 @@ struct DownloadTable: View {
private struct DownloadRow: View {
let item: DownloadItem
let priorityNumber: Int?
let visibleColumns: [DownloadColumn]
let columnWidth: (DownloadColumn) -> CGFloat
let trailingWidth: CGFloat
@@ -480,6 +549,11 @@ private struct DownloadRow: View {
@ViewBuilder
private func cell(for column: DownloadColumn) -> some View {
switch column {
case .priority:
Text(priorityNumber.map(String.init) ?? "-")
.monospacedDigit()
.foregroundStyle(.secondary)
.lineLimit(1)
case .fileName:
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
@@ -610,3 +684,25 @@ private struct DownloadRow: View {
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private struct QueueDropDelegate: DropDelegate {
let item: DownloadItem
let queueID: UUID?
@Binding var draggedItemID: DownloadItem.ID?
let controller: DownloadController
func dropEntered(info: DropInfo) {
guard let queueID,
let draggedItemID,
draggedItemID != item.id else {
return
}
controller.moveDownload(draggedItemID, before: item.id, in: queueID)
}
func performDrop(info: DropInfo) -> Bool {
draggedItemID = nil
return queueID != nil
}
}
-5
View File
@@ -46,10 +46,5 @@ struct FirelinkApp: App {
.keyboardShortcut("r", modifiers: [.command])
}
}
Settings {
SettingsView()
.environmentObject(settings)
}
}
}
+17
View File
@@ -29,6 +29,21 @@ enum DownloadCategory: String, Codable, CaseIterable, Sendable {
}
}
struct DownloadQueue: Identifiable, Codable, Equatable, Sendable {
static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
var id = UUID()
var name: String
var isMain: Bool {
id == Self.mainQueueID
}
static var main: DownloadQueue {
DownloadQueue(id: mainQueueID, name: "Main queue")
}
}
struct DownloadCredentials: Codable, Equatable, Sendable {
var username: String
var password: String
@@ -57,6 +72,8 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var message: String = ""
var createdAt = Date()
var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var queueID: UUID?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
+389 -20
View File
@@ -1,32 +1,401 @@
import AppKit
import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .about: "info.circle"
}
}
var groupTitle: String {
switch self {
case .about:
"App"
default:
"Preferences"
}
}
}
struct SettingsView: View {
@State private var selection: SettingsSection = .downloads
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Settings")
.font(.largeTitle.weight(.semibold))
.padding(.horizontal, 28)
.padding(.top, 26)
.padding(.bottom, 18)
Divider()
HStack(spacing: 0) {
settingsSidebar
Divider()
ScrollView {
selectedPane
.frame(maxWidth: 720, alignment: .leading)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var settingsSidebar: some View {
List(selection: $selection) {
Section("Preferences") {
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
Section("App") {
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
}
.listStyle(.sidebar)
.frame(width: 210)
}
@ViewBuilder
private var selectedPane: some View {
switch selection {
case .downloads:
DownloadSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("GitHub") {
Link("@nimbold", destination: developerProfileURL)
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
private var version: String {
Aria2DownloadEngine.versionString() ?? "Unavailable"
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
TabView {
DownloadSettingsPane()
.tabItem {
Label("Downloads", systemImage: "arrow.down.circle")
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
LocationsSettingsPane()
.tabItem {
Label("Locations", systemImage: "folder")
}
SiteLoginsSettingsPane()
.tabItem {
Label("Site Logins", systemImage: "key.fill")
}
PowerSettingsPane()
.tabItem {
Label("Power", systemImage: "moon.zzz")
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
.padding(20)
.frame(width: 680, height: 470)
}
}
+165 -20
View File
@@ -1,11 +1,12 @@
import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case failed
case unfinished
case category(DownloadCategory)
var title: String {
@@ -14,52 +15,196 @@ enum DownloadSidebarFilter: Hashable {
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .failed: "Failed"
case .unfinished: "Unfinished"
case .category(let category): category.rawValue
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case settings
}
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: DownloadSidebarFilter
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@State private var queueName = ""
var body: some View {
List(selection: $selection) {
Section("Library") {
Label("All", systemImage: "tray.full")
.badge(controller.downloads.count)
.tag(DownloadSidebarFilter.all)
Label("Queue", systemImage: "list.bullet")
.badge(controller.queuedCount)
.tag(DownloadSidebarFilter.queued)
.tag(SidebarSelection.downloads(.all))
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(DownloadSidebarFilter.active)
.tag(SidebarSelection.downloads(.active))
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(DownloadSidebarFilter.completed)
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
.tag(DownloadSidebarFilter.failed)
.tag(SidebarSelection.downloads(.completed))
Label("Unfinished", systemImage: "circle.dashed")
.badge(controller.unfinishedCount)
.tag(SidebarSelection.downloads(.unfinished))
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(DownloadSidebarFilter.category(category))
folderRow(for: category)
}
}
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")
Section("Queues") {
ForEach(controller.queues) { queue in
queueRow(for: queue)
}
Button {
let queue = controller.addQueue()
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
}
.buttonStyle(.plain)
}
}
.listStyle(.sidebar)
.alert("Rename Queue", isPresented: Binding(
get: { queueBeingRenamed != nil },
set: { isPresented in
if !isPresented {
queueBeingRenamed = nil
}
}
)) {
TextField("Queue name", text: $queueName)
Button("Rename") {
if let queue = queueBeingRenamed {
controller.renameQueue(id: queue.id, name: queueName)
}
queueBeingRenamed = nil
}
Button("Cancel", role: .cancel) {
queueBeingRenamed = nil
}
}
.confirmationDialog(
"Delete Queue",
isPresented: Binding(
get: { queueBeingRemoved != nil },
set: { isPresented in
if !isPresented {
queueBeingRemoved = nil
}
}
),
presenting: queueBeingRemoved
) { queue in
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
}
queueBeingRemoved = nil
}
Button("Cancel", role: .cancel) {
queueBeingRemoved = nil
}
} message: { queue in
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.contentShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.background {
if selection == .settings {
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.14))
}
}
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
.background(.bar)
}
}
private func folderRow(for category: DownloadCategory) -> some View {
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
}
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
of: [.text],
delegate: QueueSidebarDropDelegate(
queueID: queue.id,
selection: $selection,
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
private struct QueueSidebarDropDelegate: DropDelegate {
let queueID: UUID
@Binding var selection: SidebarSelection
let controller: DownloadController
func performDrop(info: DropInfo) -> Bool {
guard let provider = info.itemProviders(for: [.text]).first else {
return false
}
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
Task { @MainActor in
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
selection = .queue(queueID)
}
}
return true
}
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
let text: String?
if let data = item as? Data {
text = String(data: data, encoding: .utf8)
} else {
text = item as? String
}
guard let text else { return nil }
return Set(text
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
.compactMap { UUID(uuidString: String($0)) })
}
}