mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d0c2dec1 | |||
| c4307fbaf1 | |||
| 0c5e031f49 | |||
| 8c2dee2c13 | |||
| 8f17fa1e47 | |||
| 343fe17111 | |||
| e920eb544d | |||
| 2dbb169c39 | |||
| 108ea1297c | |||
| f02a156b57 | |||
| b54b5142f0 | |||
| a6f2dc8324 | |||
| 5442174e70 | |||
| b91f6abdb0 | |||
| cba94a7415 | |||
| 7338ba0a9f | |||
| 53d667c70e | |||
| 31e0acd05c | |||
| 4c7de3ab82 | |||
| 2a7d359be8 | |||
| fd6457ddc5 | |||
| ac039a50c8 | |||
| dd348c46da | |||
| a4740190fd | |||
| 22aeff4740 | |||
| fa018dff05 | |||
| 340ae6b0fe |
@@ -17,11 +17,11 @@ permissions:
|
||||
jobs:
|
||||
macos-arm64-dmg:
|
||||
name: Build macOS ARM64 DMG
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
@@ -40,6 +40,23 @@ jobs:
|
||||
run: |
|
||||
uname -a
|
||||
swift --version
|
||||
xcodebuild -version
|
||||
xcode-select -p
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
|
||||
- name: Verify macOS 26 SDK
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
|
||||
SDK_MAJOR="${SDK_VERSION%%.*}"
|
||||
|
||||
if [[ "$SDK_MAJOR" != "26" ]]; then
|
||||
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install aria2 dylibbundler
|
||||
|
||||
- name: Build app bundle
|
||||
env:
|
||||
@@ -51,6 +68,7 @@ jobs:
|
||||
run: |
|
||||
file build/Firelink.app/Contents/MacOS/Firelink
|
||||
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
|
||||
codesign --verify --deep --strict build/Firelink.app
|
||||
|
||||
- name: Create DMG
|
||||
env:
|
||||
@@ -59,7 +77,7 @@ jobs:
|
||||
run: Scripts/create_dmg.sh
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
|
||||
path: dist/*.dmg
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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.4.0] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
|
||||
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
|
||||
- Prefer the bundled `aria2c` binary inside release builds.
|
||||
|
||||
### Fixes
|
||||
- Fixed queue-specific starts so one queue no longer starts unrelated queued downloads.
|
||||
- Fixed scheduler completion handling so empty queues do not trigger post-download system actions.
|
||||
- Fixed queue drag reordering when moving items downward.
|
||||
- Fixed scheduler Automation permission prompting.
|
||||
|
||||
### Features added
|
||||
- Added scheduler controls with explicit Automation permission UI.
|
||||
- Added global and per-download speed limits.
|
||||
- Added advanced transfer options for checksums, headers, cookies, and mirror URLs.
|
||||
|
||||
## [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.
|
||||
@@ -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>
|
||||
|
||||
[](https://swift.org)
|
||||
[](https://apple.com)
|
||||
[](https://aria2.github.io/)
|
||||
[](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:
|
||||
@@ -98,12 +100,13 @@ 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] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
|
||||
- [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] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
|
||||
- [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 |
@@ -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>
|
||||
@@ -45,8 +65,14 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if command -v codesign &> /dev/null; then
|
||||
codesign --force --deep --sign - "$APP_DIR"
|
||||
fi
|
||||
|
||||
echo "Created $APP_DIR"
|
||||
|
||||
@@ -11,6 +11,16 @@ 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
|
||||
@State private var speedLimitEnabled = false
|
||||
@State private var speedLimitKiBPerSecond = 1024
|
||||
@State private var showsAdvancedTransfer = false
|
||||
@State private var checksumEnabled = false
|
||||
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
|
||||
@State private var checksumValue = ""
|
||||
@State private var headerText = ""
|
||||
@State private var cookieText = ""
|
||||
@State private var mirrorText = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -18,21 +28,24 @@ struct AddDownloadsView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
linkSection
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(20)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
actionBar
|
||||
}
|
||||
.frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740)
|
||||
.frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
|
||||
.onChange(of: linkText) { _, newValue in
|
||||
scheduleMetadataRefresh(for: newValue)
|
||||
}
|
||||
.onAppear {
|
||||
connectionsPerServer = Double(settings.perServerConnections)
|
||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||
controller.pendingAddQueueID = nil
|
||||
if let text = controller.pendingPasteboardText {
|
||||
linkText = text
|
||||
controller.pendingPasteboardText = nil
|
||||
@@ -53,7 +66,7 @@ struct AddDownloadsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 140)
|
||||
.frame(minHeight: 96)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
@@ -95,33 +108,46 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
|
||||
.frame(width: 220)
|
||||
.frame(width: 170)
|
||||
Text("\(Int(connectionsPerServer)) segments")
|
||||
.monospacedDigit()
|
||||
.frame(width: 130, alignment: .leading)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Speed Limit", systemImage: "speedometer")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 10) {
|
||||
Toggle("Limit this batch", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(!speedLimitEnabled)
|
||||
}
|
||||
Text("Firelink splits each file into this many parallel segments.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
|
||||
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
|
||||
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
|
||||
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
|
||||
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
|
||||
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
|
||||
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +164,6 @@ struct AddDownloadsView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.fileName)
|
||||
.lineLimit(1)
|
||||
Text(item.url.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +186,7 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.width(130)
|
||||
}
|
||||
.frame(minHeight: 230)
|
||||
.frame(minHeight: 170)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +205,7 @@ struct AddDownloadsView: View {
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
.disabled(pendingDownloads.isEmpty)
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button {
|
||||
addDownloads(start: true)
|
||||
@@ -191,12 +213,71 @@ struct AddDownloadsView: View {
|
||||
Label("Start Downloads", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(pendingDownloads.isEmpty)
|
||||
.disabled(!canAddDownloads)
|
||||
}
|
||||
.padding(14)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 130)
|
||||
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.headline)
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.headline)
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.headline)
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
private var requiredSpaceText: String {
|
||||
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
|
||||
guard knownBytes > 0 else { return "Unknown" }
|
||||
@@ -217,6 +298,10 @@ struct AddDownloadsView: View {
|
||||
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
return validationMessage
|
||||
}
|
||||
|
||||
if unknownSizeCount > 0 {
|
||||
return "Some servers did not report file size before download."
|
||||
}
|
||||
@@ -224,6 +309,35 @@ struct AddDownloadsView: View {
|
||||
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
|
||||
}
|
||||
|
||||
private var canAddDownloads: Bool {
|
||||
!pendingDownloads.isEmpty && validationMessage == nil
|
||||
}
|
||||
|
||||
private var validationMessage: String? {
|
||||
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Add the expected checksum digest, or turn checksum off."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
|
||||
return "Headers must use Name: Value lines."
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
|
||||
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
|
||||
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
|
||||
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
|
||||
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleMetadataRefresh(for text: String) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = Task {
|
||||
@@ -255,7 +369,7 @@ struct AddDownloadsView: View {
|
||||
var loaded: [PendingDownload] = []
|
||||
for url in urls {
|
||||
guard !Task.isCancelled else { return }
|
||||
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings)
|
||||
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
|
||||
loaded.append(item)
|
||||
await MainActor.run {
|
||||
for loadedItem in loaded {
|
||||
@@ -290,7 +404,10 @@ struct AddDownloadsView: View {
|
||||
pendingDownloads,
|
||||
connectionsPerServer: Int(connectionsPerServer),
|
||||
overrideDirectory: overrideDirectory,
|
||||
startImmediately: start
|
||||
startImmediately: start,
|
||||
queueID: targetQueueID,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
@@ -373,7 +490,7 @@ private struct SummaryTile: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 190)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 64)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
@@ -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 {
|
||||
@@ -28,10 +85,32 @@ final class AppSettings: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var globalSpeedLimitKiBPerSecond: Int {
|
||||
didSet {
|
||||
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
|
||||
if globalSpeedLimitKiBPerSecond != clamped {
|
||||
globalSpeedLimitKiBPerSecond = clamped
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var preventsSleepWhileDownloading: Bool {
|
||||
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() }
|
||||
}
|
||||
@@ -52,13 +131,17 @@ final class AppSettings: ObservableObject {
|
||||
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
||||
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
||||
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
|
||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||
siteLogins = stored.siteLogins
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
} else {
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
}
|
||||
@@ -83,6 +166,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)
|
||||
@@ -130,7 +220,9 @@ final class AppSettings: ObservableObject {
|
||||
let stored = StoredSettings(
|
||||
perServerConnections: perServerConnections,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins
|
||||
)
|
||||
@@ -187,7 +279,9 @@ final class AppSettings: ObservableObject {
|
||||
private struct StoredSettings: Codable {
|
||||
var perServerConnections: Int
|
||||
var maxConcurrentDownloads: Int?
|
||||
var globalSpeedLimitKiBPerSecond: Int?
|
||||
var preventsSleepWhileDownloading: Bool
|
||||
var proxySettings: ProxySettings?
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +31,11 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
|
||||
static func findExecutable() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/aria2c",
|
||||
"/usr/local/bin/aria2c",
|
||||
@@ -45,16 +54,39 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
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,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||
) throws -> Handle {
|
||||
@@ -69,7 +101,11 @@ final class Aria2DownloadEngine {
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = arguments(for: item)
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
@@ -136,8 +172,12 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private func arguments(for item: DownloadItem) -> [String] {
|
||||
[
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
"--auto-file-renaming=true",
|
||||
@@ -146,20 +186,138 @@ final class Aria2DownloadEngine {
|
||||
"--download-result=hide",
|
||||
"--file-allocation=none",
|
||||
"--min-split-size=1M",
|
||||
"--max-tries=10",
|
||||
"--retry-wait=5",
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-"
|
||||
]
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
|
||||
}
|
||||
|
||||
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 {
|
||||
let connections = min(max(item.connectionsPerServer, 1), 16)
|
||||
let urls = ([item.url] + (item.mirrorURLs ?? []))
|
||||
.map { sanitizedOptionValue($0.absoluteString) }
|
||||
.joined(separator: "\t")
|
||||
var lines = [
|
||||
sanitizedOptionValue(item.url.absoluteString),
|
||||
urls,
|
||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||
" out=\(sanitizedOptionValue(item.fileName))",
|
||||
" split=\(connections)",
|
||||
" max-connection-per-server=\(connections)"
|
||||
]
|
||||
|
||||
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
|
||||
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
|
||||
}
|
||||
|
||||
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
|
||||
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
|
||||
}
|
||||
|
||||
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
|
||||
}
|
||||
|
||||
if let credentials = item.credentials, !credentials.isEmpty {
|
||||
let scheme = item.url.scheme?.lowercased()
|
||||
if scheme == "ftp" || scheme == "sftp" {
|
||||
|
||||
@@ -5,111 +5,143 @@ 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 .scheduler:
|
||||
SchedulerView()
|
||||
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 +156,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 +177,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 }
|
||||
}
|
||||
|
||||
@@ -5,14 +5,20 @@ 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 queuePumpScope: QueuePumpScope = .idle
|
||||
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 +26,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 +50,15 @@ 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.queuePumpScope = .all
|
||||
self.pumpQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -62,15 +77,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 +101,27 @@ 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,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
||||
speedLimitKiBPerSecond: Int? = nil
|
||||
) {
|
||||
let clampedConnections = min(max(connectionsPerServer, 1), 16)
|
||||
let targetQueueID = normalizedQueueID(queueID)
|
||||
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
|
||||
let items = pendingDownloads.map { pending in
|
||||
DownloadItem(
|
||||
@@ -109,22 +131,45 @@ final class DownloadController: ObservableObject {
|
||||
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
|
||||
connectionsPerServer: clampedConnections,
|
||||
credentials: settings.credentials(for: pending.url),
|
||||
checksum: transferOptions.checksum,
|
||||
requestHeaders: transferOptions.requestHeaders,
|
||||
cookieHeader: transferOptions.cookieHeader,
|
||||
mirrorURLs: transferOptions.mirrorURLs,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
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
|
||||
if let queueID {
|
||||
let queueID = normalizedQueueID(queueID)
|
||||
switch queuePumpScope {
|
||||
case .all:
|
||||
break
|
||||
case .idle:
|
||||
queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: [])
|
||||
case .scoped(var queueIDs, let itemIDs):
|
||||
queueIDs.insert(queueID)
|
||||
queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
|
||||
}
|
||||
} else {
|
||||
queuePumpScope = .all
|
||||
}
|
||||
markQueuedDownloadsForAutoResume(queueID: queueID)
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
@@ -134,7 +179,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 +199,47 @@ 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
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
@@ -169,7 +249,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 +273,83 @@ 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)
|
||||
let insertionIndex = source < target ? target - 1 : target
|
||||
downloads.insert(item, at: insertionIndex)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func pumpQueue() {
|
||||
@@ -203,10 +358,24 @@ final class DownloadController: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
|
||||
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) &&
|
||||
isAllowedToStart(item)
|
||||
}) {
|
||||
start(next)
|
||||
}
|
||||
|
||||
if restrictQueueToAutoResume &&
|
||||
activeCount == 0 &&
|
||||
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
|
||||
restrictQueueToAutoResume = false
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
@@ -216,11 +385,15 @@ 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,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.update(item.id) {
|
||||
@@ -240,22 +413,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 +440,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()
|
||||
}
|
||||
@@ -289,7 +460,9 @@ final class DownloadController: ObservableObject {
|
||||
fileName: String,
|
||||
destinationDirectory: URL,
|
||||
connectionsPerServer: Int,
|
||||
credentials: DownloadCredentials?
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) {
|
||||
update(id) {
|
||||
$0.url = url
|
||||
@@ -298,8 +471,152 @@ final class DownloadController: ObservableObject {
|
||||
$0.destinationDirectory = destinationDirectory
|
||||
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
||||
$0.credentials = credentials
|
||||
$0.checksum = transferOptions.checksum
|
||||
$0.requestHeaders = transferOptions.requestHeaders
|
||||
$0.cookieHeader = transferOptions.cookieHeader
|
||||
$0.mirrorURLs = transferOptions.mirrorURLs
|
||||
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
$0.message = "Properties updated"
|
||||
}
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
||||
guard let value, value > 0 else { return nil }
|
||||
return min(value, 10_485_760)
|
||||
}
|
||||
|
||||
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
|
||||
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
||||
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
|
||||
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
|
||||
|
||||
switch (itemLimit, globalLimit) {
|
||||
case let (.some(itemLimit), .some(globalLimit)):
|
||||
return min(itemLimit, globalLimit)
|
||||
case let (.some(itemLimit), .none):
|
||||
return itemLimit
|
||||
case let (.none, .some(globalLimit)):
|
||||
return globalLimit
|
||||
case (.none, .none):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
||||
if let queueID {
|
||||
let normalizedID = normalizedQueueID(queueID)
|
||||
for index in downloads.indices where downloads[index].status == .queued &&
|
||||
validQueueID(downloads[index].queueID) == normalizedID {
|
||||
downloads[index].autoResumeOnLaunch = true
|
||||
}
|
||||
} else {
|
||||
for index in downloads.indices where downloads[index].status == .queued {
|
||||
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 isAllowedToStart(_ item: DownloadItem) -> Bool {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return false
|
||||
case .all:
|
||||
return true
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
if itemIDs.contains(item.id) {
|
||||
return true
|
||||
}
|
||||
guard let queueID = validQueueID(item.queueID) else { return false }
|
||||
return queueIDs.contains(queueID)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneActiveQueueScopes() {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return
|
||||
case .all:
|
||||
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
|
||||
queuePumpScope = .idle
|
||||
}
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
let activeQueueIDs = queueIDs.filter { queueID in
|
||||
downloads.contains { item in
|
||||
validQueueID(item.queueID) == queueID &&
|
||||
(item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
let activeItemIDs = itemIDs.filter { itemID in
|
||||
downloads.contains { item in
|
||||
item.id == itemID && (item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
|
||||
? .idle
|
||||
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueuePumpScope {
|
||||
case idle
|
||||
case all
|
||||
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
|
||||
|
||||
func includingItem(_ itemID: UUID) -> QueuePumpScope {
|
||||
switch self {
|
||||
case .idle:
|
||||
return .scoped(queueIDs: [], itemIDs: [itemID])
|
||||
case .all:
|
||||
return .all
|
||||
case .scoped(let queueIDs, var itemIDs):
|
||||
itemIDs.insert(itemID)
|
||||
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSleepActivity() {
|
||||
@@ -341,34 +658,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 {
|
||||
|
||||
@@ -30,7 +30,11 @@ enum DownloadURLParser {
|
||||
}
|
||||
|
||||
enum DownloadMetadataFetcher {
|
||||
static func fetch(for url: URL, settings: AppSettings) async -> PendingDownload {
|
||||
static func fetch(
|
||||
for url: URL,
|
||||
settings: AppSettings,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
|
||||
) async -> PendingDownload {
|
||||
let initialName = FileClassifier.fileName(from: url)
|
||||
let initialCategory = FileClassifier.category(forFileName: initialName)
|
||||
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
|
||||
@@ -51,6 +55,12 @@ enum DownloadMetadataFetcher {
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 12
|
||||
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
||||
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||
request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
@@ -37,6 +37,14 @@ struct DownloadPropertiesView: View {
|
||||
@State private var loginMode: LoginMode
|
||||
@State private var username: String
|
||||
@State private var password: String
|
||||
@State private var speedLimitEnabled: Bool
|
||||
@State private var speedLimitKiBPerSecond: Int
|
||||
@State private var checksumEnabled: Bool
|
||||
@State private var checksumAlgorithm: ChecksumAlgorithm
|
||||
@State private var checksumValue: String
|
||||
@State private var headerText: String
|
||||
@State private var cookieText: String
|
||||
@State private var mirrorText: String
|
||||
@State private var errorMessage = ""
|
||||
|
||||
init(item: DownloadItem) {
|
||||
@@ -45,6 +53,8 @@ struct DownloadPropertiesView: View {
|
||||
_fileName = State(initialValue: item.fileName)
|
||||
_destinationPath = State(initialValue: item.destinationDirectory.path)
|
||||
_connections = State(initialValue: item.connectionsPerServer)
|
||||
_speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0)
|
||||
_speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1))
|
||||
if let credentials = item.credentials {
|
||||
_loginMode = State(initialValue: .custom)
|
||||
_username = State(initialValue: credentials.username)
|
||||
@@ -54,6 +64,18 @@ struct DownloadPropertiesView: View {
|
||||
_username = State(initialValue: "")
|
||||
_password = State(initialValue: "")
|
||||
}
|
||||
if let checksum = item.checksum {
|
||||
_checksumEnabled = State(initialValue: true)
|
||||
_checksumAlgorithm = State(initialValue: checksum.algorithm)
|
||||
_checksumValue = State(initialValue: checksum.value)
|
||||
} else {
|
||||
_checksumEnabled = State(initialValue: false)
|
||||
_checksumAlgorithm = State(initialValue: .sha256)
|
||||
_checksumValue = State(initialValue: "")
|
||||
}
|
||||
_headerText = State(initialValue: (item.requestHeaders ?? []).map(\.headerLine).joined(separator: "\n"))
|
||||
_cookieText = State(initialValue: item.cookieHeader ?? "")
|
||||
_mirrorText = State(initialValue: (item.mirrorURLs ?? []).map(\.absoluteString).joined(separator: "\n"))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -73,6 +95,15 @@ struct DownloadPropertiesView: View {
|
||||
}
|
||||
}
|
||||
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
|
||||
Toggle("Limit speed", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Site Login") {
|
||||
@@ -93,6 +124,36 @@ struct DownloadPropertiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Advanced Transfer") {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
if checksumEnabled {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 70)
|
||||
}
|
||||
|
||||
TextField("Cookies", text: $cookieText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 70)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Progress") {
|
||||
ProgressView(value: item.progress)
|
||||
InfoGrid(item: item)
|
||||
@@ -120,7 +181,7 @@ struct DownloadPropertiesView: View {
|
||||
.padding(14)
|
||||
.background(.bar)
|
||||
}
|
||||
.frame(width: 620, height: 560)
|
||||
.frame(width: 620, height: 760)
|
||||
}
|
||||
|
||||
private var matchingLoginText: String {
|
||||
@@ -175,16 +236,46 @@ struct DownloadPropertiesView: View {
|
||||
credentials = nil
|
||||
}
|
||||
|
||||
guard let transferOptions = validatedTransferOptions else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.updateDownload(
|
||||
id: item.id,
|
||||
url: url,
|
||||
fileName: cleanFileName,
|
||||
destinationDirectory: destination,
|
||||
connectionsPerServer: connections,
|
||||
credentials: credentials
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private var validatedTransferOptions: DownloadTransferOptions? {
|
||||
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
errorMessage = "Add the expected checksum digest, or turn checksum off."
|
||||
return nil
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
|
||||
errorMessage = "Headers must use Name: Value lines."
|
||||
return nil
|
||||
}
|
||||
|
||||
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
|
||||
errorMessage = "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadTransferOptions(
|
||||
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
|
||||
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
|
||||
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
|
||||
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoGrid: View {
|
||||
@@ -198,6 +289,7 @@ private struct InfoGrid: View {
|
||||
info("Speed", item.speedText)
|
||||
info("ETA", item.etaText)
|
||||
info("Live connections", "\(item.connectionCount)")
|
||||
info("Speed cap", item.speedLimitText)
|
||||
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
||||
info("Category", item.category.rawValue)
|
||||
|
||||
@@ -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
|
||||
@@ -63,14 +66,17 @@ final class TableSettings: ObservableObject {
|
||||
private let storageKey = "Firelink.TableSettings.v1"
|
||||
|
||||
init() {
|
||||
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
|
||||
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
|
||||
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
|
||||
visibleColumns = stored.visibleColumns
|
||||
visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
|
||||
columnWidths = stored.columnWidths
|
||||
sortColumn = stored.sortColumn
|
||||
sortDirection = stored.sortDirection
|
||||
} else {
|
||||
visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
|
||||
visibleColumns = defaultVisibleColumns
|
||||
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
|
||||
sortColumn = .dateAdded
|
||||
sortDirection = .descending
|
||||
@@ -104,11 +110,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) {
|
||||
@@ -153,7 +161,7 @@ struct DownloadTable: View {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add to paste one or more links.")
|
||||
description: Text("Use Add or press Command-V to paste one or more links.")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +209,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 +240,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 +297,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 +310,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 +364,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 +389,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 +411,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 +426,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 +445,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 +527,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 +552,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)
|
||||
@@ -491,11 +568,6 @@ private struct DownloadRow: View {
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(item.url.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -610,3 +682,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import SwiftUI
|
||||
struct FirelinkApp: App {
|
||||
@StateObject private var settings: AppSettings
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
|
||||
init() {
|
||||
let settings = AppSettings()
|
||||
let controller = DownloadController(settings: settings)
|
||||
_settings = StateObject(wrappedValue: settings)
|
||||
_controller = StateObject(wrappedValue: DownloadController(settings: settings))
|
||||
_controller = StateObject(wrappedValue: controller)
|
||||
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -16,6 +19,7 @@ struct FirelinkApp: App {
|
||||
ContentView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
@@ -46,10 +50,5 @@ struct FirelinkApp: App {
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
}
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +54,113 @@ struct DownloadCredentials: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case md5
|
||||
case sha1 = "sha-1"
|
||||
case sha256 = "sha-256"
|
||||
case sha512 = "sha-512"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .md5: "MD5"
|
||||
case .sha1: "SHA-1"
|
||||
case .sha256: "SHA-256"
|
||||
case .sha512: "SHA-512"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadChecksum: Codable, Equatable, Sendable {
|
||||
var algorithm: ChecksumAlgorithm
|
||||
var value: String
|
||||
|
||||
var normalized: DownloadChecksum {
|
||||
DownloadChecksum(
|
||||
algorithm: algorithm,
|
||||
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadRequestHeader: Codable, Equatable, Sendable {
|
||||
var name: String
|
||||
var value: String
|
||||
|
||||
var normalized: DownloadRequestHeader {
|
||||
DownloadRequestHeader(
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
let clean = normalized
|
||||
return clean.name.isEmpty && clean.value.isEmpty
|
||||
}
|
||||
|
||||
var headerLine: String {
|
||||
let clean = normalized
|
||||
return "\(clean.name): \(clean.value)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadTransferOptions: Equatable, Sendable {
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader] = []
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL] = []
|
||||
}
|
||||
|
||||
enum DownloadTransferOptionParser {
|
||||
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
|
||||
headerLines(text).compactMap { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let header = DownloadRequestHeader(name: name, value: value).normalized
|
||||
return header.isEmpty || header.name.isEmpty ? nil : header
|
||||
}
|
||||
}
|
||||
|
||||
static func invalidHeaderLines(_ text: String) -> [String] {
|
||||
headerLines(text).filter { line in
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { return true }
|
||||
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanCookieHeader(_ text: String) -> String? {
|
||||
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.lowercased().hasPrefix("cookie:") {
|
||||
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
static func parseMirrorURLs(_ text: String) -> [URL] {
|
||||
DownloadURLParser.parse(text)
|
||||
}
|
||||
|
||||
static func invalidMirrorLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
|
||||
}
|
||||
|
||||
private static func headerLines(_ text: String) -> [String] {
|
||||
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var id = UUID()
|
||||
var url: URL
|
||||
@@ -47,6 +169,11 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var destinationDirectory: URL
|
||||
var connectionsPerServer: Int
|
||||
var credentials: DownloadCredentials?
|
||||
var checksum: DownloadChecksum?
|
||||
var requestHeaders: [DownloadRequestHeader]?
|
||||
var cookieHeader: String?
|
||||
var mirrorURLs: [URL]?
|
||||
var speedLimitKiBPerSecond: Int?
|
||||
var status: DownloadStatus = .queued
|
||||
var progress: Double = 0
|
||||
var speedText: String = "-"
|
||||
@@ -57,10 +184,28 @@ 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
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
requestHeaders: requestHeaders ?? [],
|
||||
cookieHeader: cookieHeader,
|
||||
mirrorURLs: mirrorURLs ?? []
|
||||
)
|
||||
}
|
||||
|
||||
var speedLimitText: String {
|
||||
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
|
||||
return "No limit"
|
||||
}
|
||||
return "\(speedLimitKiBPerSecond) KiB/s"
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgress: Equatable, Sendable {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
|
||||
case doNothing = "Do nothing"
|
||||
case sleep = "Sleep"
|
||||
case restart = "Restart"
|
||||
case shutdown = "Shut down"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .sunday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday: "T"
|
||||
case .wednesday: "W"
|
||||
case .thursday: "T"
|
||||
case .friday: "F"
|
||||
case .saturday: "S"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
var targetQueueIDs: Set<UUID> = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SchedulerController: ObservableObject {
|
||||
@Published var settings: SchedulerSettings
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var hasAutomationPermission: Bool = false
|
||||
private let downloadController: DownloadController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timer: Timer?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.SchedulerSettings.v1"
|
||||
|
||||
// We only trigger once per minute to prevent multiple triggers in the same minute
|
||||
private var lastTriggeredMinute: Date?
|
||||
|
||||
init(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
|
||||
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
|
||||
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
|
||||
self.settings = stored
|
||||
} else {
|
||||
self.settings = SchedulerSettings()
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
startTimer()
|
||||
|
||||
$settings
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe downloads to check if we should trigger post-action
|
||||
downloadController.$downloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.checkIfRunningFinished()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkSchedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSchedule() {
|
||||
guard settings.isEnabled else { return }
|
||||
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Check if we already triggered in this exact minute
|
||||
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
|
||||
return
|
||||
}
|
||||
|
||||
let startHour = calendar.component(.hour, from: settings.startTime)
|
||||
let startMinute = calendar.component(.minute, from: settings.startTime)
|
||||
|
||||
let currentHour = calendar.component(.hour, from: now)
|
||||
let currentMinute = calendar.component(.minute, from: now)
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
let shouldRun: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRun = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRun {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerQueues() {
|
||||
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
|
||||
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
||||
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
||||
}
|
||||
|
||||
guard !runnableQueueIDs.isEmpty else { return }
|
||||
|
||||
isRunning = true
|
||||
|
||||
for queueID in runnableQueueIDs {
|
||||
downloadController.startQueue(queueID: queueID)
|
||||
}
|
||||
|
||||
checkIfRunningFinished()
|
||||
}
|
||||
|
||||
private func checkIfRunningFinished() {
|
||||
guard isRunning else { return }
|
||||
|
||||
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
|
||||
downloadController.queueItems(for: queueID).contains {
|
||||
$0.status == .queued || $0.status == .downloading
|
||||
}
|
||||
}
|
||||
|
||||
if !hasActiveItems {
|
||||
isRunning = false
|
||||
performPostAction()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPostAction() {
|
||||
guard settings.postQueueAction != .doNothing else { return }
|
||||
|
||||
var scriptCode = ""
|
||||
switch settings.postQueueAction {
|
||||
case .sleep:
|
||||
scriptCode = "tell application \"Finder\" to sleep"
|
||||
case .restart:
|
||||
scriptCode = "tell application \"Finder\" to restart"
|
||||
case .shutdown:
|
||||
scriptCode = "tell application \"Finder\" to shut down"
|
||||
case .doNothing:
|
||||
break
|
||||
}
|
||||
|
||||
guard !scriptCode.isEmpty else { return }
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to perform scheduler post action: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
|
||||
hasAutomationPermission = (status == noErr)
|
||||
}
|
||||
|
||||
func requestAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
|
||||
|
||||
if status != noErr {
|
||||
triggerAutomationConsentPrompt()
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
|
||||
if !hasAutomationPermission {
|
||||
openAutomationPermissionSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func openAutomationPermissionSettings() {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
|
||||
return
|
||||
}
|
||||
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func triggerAutomationConsentPrompt() {
|
||||
let scriptCode = "tell application \"Finder\" to get name"
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to trigger Automation permission prompt: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SchedulerView: View {
|
||||
@EnvironmentObject private var downloadController: DownloadController
|
||||
@EnvironmentObject private var schedulerController: SchedulerController
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var startTime: Date = Date()
|
||||
@State private var isEveryday: Bool = true
|
||||
@State private var selectedDays: Set<SchedulerDay> = []
|
||||
@State private var postQueueAction: PostQueueAction = .doNothing
|
||||
@State private var targetQueueIDs: Set<UUID> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
timeSelectionSection
|
||||
queueSelectionSection
|
||||
postActionSection
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
Divider()
|
||||
permissionsSection
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Scheduler")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save Settings") {
|
||||
saveState()
|
||||
withAnimation(.spring()) {
|
||||
showSaveToast = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation {
|
||||
showSaveToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var timeSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Start Time")
|
||||
.font(.headline)
|
||||
|
||||
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
|
||||
Toggle("Everyday", isOn: $isEveryday)
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Toggle(day.shortName, isOn: Binding(
|
||||
get: { selectedDays.contains(day) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
selectedDays.insert(day)
|
||||
} else {
|
||||
selectedDays.remove(day)
|
||||
}
|
||||
}
|
||||
))
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var queueSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Queues to Start")
|
||||
.font(.headline)
|
||||
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(downloadController.queues) { queue in
|
||||
Toggle(queue.name, isOn: Binding(
|
||||
get: { targetQueueIDs.contains(queue.id) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
targetQueueIDs.insert(queue.id)
|
||||
} else {
|
||||
targetQueueIDs.remove(queue.id)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var postActionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("After Completion")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("System Permissions")
|
||||
.font(.headline)
|
||||
|
||||
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if schedulerController.hasAutomationPermission {
|
||||
Button("Revoke Permissions") {
|
||||
schedulerController.openAutomationPermissionSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
schedulerController.requestAutomationPermission()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Settings Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func loadState() {
|
||||
isEnabled = schedulerController.settings.isEnabled
|
||||
startTime = schedulerController.settings.startTime
|
||||
isEveryday = schedulerController.settings.isEveryday
|
||||
selectedDays = schedulerController.settings.selectedDays
|
||||
postQueueAction = schedulerController.settings.postQueueAction
|
||||
targetQueueIDs = schedulerController.settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
schedulerController.settings.isEnabled = isEnabled
|
||||
schedulerController.settings.startTime = startTime
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs
|
||||
schedulerController.saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,411 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case locations = "Locations"
|
||||
case network = "Network"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case about = "About"
|
||||
|
||||
static let orderedCases: [SettingsSection] = [
|
||||
.downloads,
|
||||
.locations,
|
||||
.network,
|
||||
.siteLogins,
|
||||
.power,
|
||||
.engine,
|
||||
.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 .engine, .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.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
|
||||
Section("App") {
|
||||
ForEach(SettingsSection.orderedCases.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +433,42 @@ private struct DownloadSettingsPane: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Bandwidth") {
|
||||
Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Stepper(
|
||||
"Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s",
|
||||
value: globalSpeedLimitValue,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(settings.globalSpeedLimitKiBPerSecond == 0)
|
||||
|
||||
Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var globalSpeedLimitEnabled: Binding<Bool> {
|
||||
Binding {
|
||||
settings.globalSpeedLimitKiBPerSecond > 0
|
||||
} set: { isEnabled in
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
|
||||
}
|
||||
}
|
||||
|
||||
private var globalSpeedLimitValue: Binding<Int> {
|
||||
Binding {
|
||||
max(settings.globalSpeedLimitKiBPerSecond, 1)
|
||||
} set: { newValue in
|
||||
settings.globalSpeedLimitKiBPerSecond = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationsSettingsPane: View {
|
||||
|
||||
@@ -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,202 @@ 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 scheduler
|
||||
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)
|
||||
}
|
||||
|
||||
Section("Tools") {
|
||||
Label("Scheduler", systemImage: "calendar.badge.clock")
|
||||
.tag(SidebarSelection.scheduler)
|
||||
}
|
||||
}
|
||||
.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)) })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user