mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d0c2dec1 | |||
| c4307fbaf1 | |||
| 0c5e031f49 | |||
| 8c2dee2c13 | |||
| 8f17fa1e47 | |||
| 343fe17111 | |||
| e920eb544d | |||
| 2dbb169c39 | |||
| 108ea1297c |
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
@@ -40,6 +40,20 @@ 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
|
||||
@@ -54,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:
|
||||
@@ -62,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
|
||||
|
||||
@@ -5,6 +5,24 @@ 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
|
||||
|
||||
@@ -100,11 +100,11 @@ git push origin v0.1.0
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
|
||||
@@ -65,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"
|
||||
|
||||
@@ -12,6 +12,15 @@ struct AddDownloadsView: View {
|
||||
@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) {
|
||||
@@ -19,16 +28,17 @@ 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)
|
||||
}
|
||||
@@ -56,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")
|
||||
@@ -98,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +186,7 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.width(130)
|
||||
}
|
||||
.frame(minHeight: 230)
|
||||
.frame(minHeight: 170)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +205,7 @@ struct AddDownloadsView: View {
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
.disabled(pendingDownloads.isEmpty)
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button {
|
||||
addDownloads(start: true)
|
||||
@@ -194,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" }
|
||||
@@ -220,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."
|
||||
}
|
||||
@@ -227,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 {
|
||||
@@ -258,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 {
|
||||
@@ -294,7 +405,9 @@ struct AddDownloadsView: View {
|
||||
connectionsPerServer: Int(connectionsPerServer),
|
||||
overrideDirectory: overrideDirectory,
|
||||
startImmediately: start,
|
||||
queueID: targetQueueID
|
||||
queueID: targetQueueID,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
@@ -377,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))
|
||||
|
||||
@@ -85,6 +85,17 @@ 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() }
|
||||
}
|
||||
@@ -120,6 +131,7 @@ 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
|
||||
@@ -127,6 +139,7 @@ final class AppSettings: ObservableObject {
|
||||
} else {
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
@@ -207,6 +220,7 @@ 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) }),
|
||||
@@ -265,6 +279,7 @@ 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]
|
||||
|
||||
@@ -31,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",
|
||||
@@ -49,11 +54,6 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ final class Aria2DownloadEngine {
|
||||
func start(
|
||||
item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||
) throws -> Handle {
|
||||
@@ -100,7 +101,11 @@ final class Aria2DownloadEngine {
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration)
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
@@ -167,7 +172,11 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] {
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
@@ -181,9 +190,14 @@ final class Aria2DownloadEngine {
|
||||
"--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
|
||||
}
|
||||
@@ -281,14 +295,29 @@ final class Aria2DownloadEngine {
|
||||
|
||||
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" {
|
||||
|
||||
@@ -24,6 +24,8 @@ struct ContentView: View {
|
||||
downloadsView(filter: filter)
|
||||
case .queue(let queueID):
|
||||
queueView(queueID: queueID)
|
||||
case .scheduler:
|
||||
SchedulerView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ final class DownloadController: ObservableObject {
|
||||
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
|
||||
@@ -54,6 +55,7 @@ final class DownloadController: ObservableObject {
|
||||
Task { @MainActor in
|
||||
self.engineMessage = "Recovered downloads from the previous session."
|
||||
self.restrictQueueToAutoResume = true
|
||||
self.queuePumpScope = .all
|
||||
self.pumpQueue()
|
||||
}
|
||||
}
|
||||
@@ -113,10 +115,13 @@ final class DownloadController: ObservableObject {
|
||||
connectionsPerServer: Int,
|
||||
overrideDirectory: URL?,
|
||||
startImmediately: Bool,
|
||||
queueID: UUID = DownloadQueue.mainQueueID
|
||||
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(
|
||||
@@ -126,6 +131,11 @@ 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",
|
||||
@@ -145,6 +155,20 @@ final class DownloadController: ObservableObject {
|
||||
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()
|
||||
}
|
||||
@@ -213,6 +237,7 @@ final class DownloadController: ObservableObject {
|
||||
$0.message = ""
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
@@ -322,7 +347,8 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
let item = downloads.remove(at: source)
|
||||
downloads.insert(item, at: target)
|
||||
let insertionIndex = source < target ? target - 1 : target
|
||||
downloads.insert(item, at: insertionIndex)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
@@ -332,9 +358,13 @@ final class DownloadController: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
|
||||
while activeCount < settings.maxConcurrentDownloads,
|
||||
let next = downloads.first(where: { item in
|
||||
item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true)
|
||||
item.status == .queued &&
|
||||
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
|
||||
isAllowedToStart(item)
|
||||
}) {
|
||||
start(next)
|
||||
}
|
||||
@@ -344,6 +374,8 @@ final class DownloadController: ObservableObject {
|
||||
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
|
||||
restrictQueueToAutoResume = false
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
@@ -361,6 +393,7 @@ final class DownloadController: ObservableObject {
|
||||
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) {
|
||||
@@ -427,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
|
||||
@@ -436,16 +471,49 @@ 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?) {
|
||||
let normalizedID = queueID.map(normalizedQueueID)
|
||||
for index in downloads.indices where downloads[index].status == .queued &&
|
||||
(normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) {
|
||||
downloads[index].autoResumeOnLaunch = true
|
||||
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()
|
||||
}
|
||||
@@ -492,6 +560,65 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -66,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
|
||||
@@ -158,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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -565,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,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
|
||||
@@ -62,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 = "-"
|
||||
@@ -78,6 +190,22 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,23 @@ import SwiftUI
|
||||
|
||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case network = "Network"
|
||||
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"
|
||||
@@ -24,7 +34,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
|
||||
var groupTitle: String {
|
||||
switch self {
|
||||
case .about:
|
||||
case .engine, .about:
|
||||
"App"
|
||||
default:
|
||||
"Preferences"
|
||||
@@ -64,14 +74,14 @@ struct SettingsView: View {
|
||||
private var settingsSidebar: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Preferences") {
|
||||
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
|
||||
Section("App") {
|
||||
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
@@ -423,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 {
|
||||
|
||||
@@ -24,6 +24,7 @@ enum DownloadSidebarFilter: Hashable {
|
||||
enum SidebarSelection: Hashable {
|
||||
case downloads(DownloadSidebarFilter)
|
||||
case queue(UUID)
|
||||
case scheduler
|
||||
case settings
|
||||
}
|
||||
|
||||
@@ -70,6 +71,11 @@ struct SidebarView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Section("Tools") {
|
||||
Label("Scheduler", systemImage: "calendar.badge.clock")
|
||||
.tag(SidebarSelection.scheduler)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.alert("Rename Queue", isPresented: Binding(
|
||||
|
||||
Reference in New Issue
Block a user