mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 16:36:50 +00:00
fix: complete aria2 downloads via rpc
This commit is contained in:
@@ -5,6 +5,22 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.5.5] - 2026-06-05
|
||||||
|
|
||||||
|
### New features
|
||||||
|
- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
|
||||||
|
- Added authenticated metadata probing so batch previews can use custom or saved credentials.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Updated Download Properties disclosure sections so their full title row opens and closes them.
|
||||||
|
- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
|
||||||
|
- Expanded download table hit areas so double-clicks register across empty cell space.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
|
||||||
|
- Fixed Chunk Map layout overlap in Download Properties.
|
||||||
|
- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
|
||||||
|
|
||||||
## [0.5.4] - 2026-06-04
|
## [0.5.4] - 2026-06-04
|
||||||
|
|
||||||
### New features
|
### New features
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Dark mode is shown by default with privacy-safe example downloads. Light mode is
|
|||||||
- ⚡ **High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
|
- ⚡ **High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
|
||||||
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
|
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
|
||||||
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
|
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
|
||||||
|
- 🧾 **Download Properties:** Inspect progress and tune per-download transfer settings.
|
||||||
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
||||||
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
|
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
|
||||||
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ struct AddDownloadsView: View {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
|
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||||
.onChange(of: linkText) { _, newValue in
|
.onChange(of: linkText) { _, newValue in
|
||||||
scheduleMetadataRefresh(for: newValue)
|
scheduleMetadataRefresh(for: newValue)
|
||||||
}
|
}
|
||||||
|
.onChange(of: metadataRequestSignature) { _, _ in
|
||||||
|
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
|
||||||
|
scheduleMetadataRefresh(for: linkText)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
connectionsPerServer = Double(settings.perServerConnections)
|
connectionsPerServer = Double(settings.perServerConnections)
|
||||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||||
@@ -72,7 +76,7 @@ struct AddDownloadsView: View {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(.quaternary.opacity(0.35))
|
.background(.quaternary.opacity(0.35))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.frame(minHeight: 82)
|
.frame(minHeight: 72)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||||
@@ -108,12 +112,24 @@ struct AddDownloadsView: View {
|
|||||||
Button {
|
Button {
|
||||||
selectDestination()
|
selectDestination()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Select", systemImage: "folder.badge.plus")
|
Label("Select...", systemImage: "folder.badge.plus")
|
||||||
}
|
}
|
||||||
.disabled(!overrideDestination)
|
.disabled(!overrideDestination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
|
Label("Queue", systemImage: "tray.full")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Picker("Queue", selection: $targetQueueID) {
|
||||||
|
ForEach(controller.queues) { queue in
|
||||||
|
Text(queue.name).tag(queue.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(maxWidth: 220, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
GridRow(alignment: .firstTextBaseline) {
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -129,11 +145,11 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GridRow(alignment: .firstTextBaseline) {
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
Label("Speed Limit", systemImage: "speedometer")
|
Label("Speed Limit per File", systemImage: "speedometer")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Toggle("Limit this batch", isOn: $speedLimitEnabled)
|
Toggle("Limit each file", isOn: $speedLimitEnabled)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
Stepper(
|
Stepper(
|
||||||
"\(speedLimitKiBPerSecond) KiB/s",
|
"\(speedLimitKiBPerSecond) KiB/s",
|
||||||
@@ -173,12 +189,14 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var summarySection: some View {
|
||||||
HStack(spacing: 8) {
|
CompactSummaryStrip(
|
||||||
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
|
metrics: [
|
||||||
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
|
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
|
||||||
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
|
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
|
||||||
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
|
||||||
}
|
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var previewSection: some View {
|
private var previewSection: some View {
|
||||||
@@ -216,7 +234,7 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
.width(110)
|
.width(110)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 135)
|
.frame(minHeight: 160)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +396,26 @@ struct AddDownloadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var metadataRequestSignature: String {
|
||||||
|
[
|
||||||
|
headerText,
|
||||||
|
cookieText,
|
||||||
|
useAuthorization ? "auth" : "no-auth",
|
||||||
|
authUsername,
|
||||||
|
authPassword
|
||||||
|
].joined(separator: "\u{1f}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
|
||||||
|
if useAuthorization {
|
||||||
|
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !cleanUsername.isEmpty else { return nil }
|
||||||
|
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.credentials(for: url)
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleMetadataRefresh(for text: String) {
|
private func scheduleMetadataRefresh(for text: String) {
|
||||||
metadataTask?.cancel()
|
metadataTask?.cancel()
|
||||||
metadataTask = Task {
|
metadataTask = Task {
|
||||||
@@ -434,7 +472,12 @@ struct AddDownloadsView: View {
|
|||||||
var loaded: [PendingDownload] = []
|
var loaded: [PendingDownload] = []
|
||||||
for url in urls {
|
for url in urls {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
|
let item = await DownloadMetadataFetcher.fetch(
|
||||||
|
for: url,
|
||||||
|
settings: settings,
|
||||||
|
credentials: metadataCredentials(for: url),
|
||||||
|
transferOptions: transferOptions
|
||||||
|
)
|
||||||
loaded.append(item)
|
loaded.append(item)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
for loadedItem in loaded {
|
for loadedItem in loaded {
|
||||||
@@ -554,30 +597,41 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SummaryTile: View {
|
private struct SummaryMetric: Identifiable {
|
||||||
let title: String
|
let title: String
|
||||||
let value: String
|
let value: String
|
||||||
let symbolName: String
|
let symbolName: String
|
||||||
|
|
||||||
|
var id: String { title }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CompactSummaryStrip: View {
|
||||||
|
let metrics: [SummaryMetric]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: symbolName)
|
ForEach(metrics) { metric in
|
||||||
.font(.body)
|
HStack(spacing: 6) {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: metric.symbolName)
|
||||||
.frame(width: 20)
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundStyle(.secondary)
|
||||||
Text(title)
|
Text(metric.title)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(value)
|
Text(metric.value)
|
||||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
.font(.caption.weight(.semibold).monospacedDigit())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metric.id != metrics.last?.id {
|
||||||
|
Divider()
|
||||||
|
.frame(height: 14)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(9)
|
.padding(.horizontal, 10)
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.vertical, 7)
|
||||||
.frame(minHeight: 52)
|
|
||||||
.background(.quaternary.opacity(0.35))
|
.background(.quaternary.opacity(0.35))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ final class Aria2DownloadEngine {
|
|||||||
let parser = Aria2ProgressParser()
|
let parser = Aria2ProgressParser()
|
||||||
let outputBuffer = LockedDataBuffer()
|
let outputBuffer = LockedDataBuffer()
|
||||||
let errorBuffer = LockedDataBuffer()
|
let errorBuffer = LockedDataBuffer()
|
||||||
|
let completionGate = CompletionGate(completion)
|
||||||
|
let completionMonitor = CompletionMonitor()
|
||||||
|
|
||||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
@@ -166,11 +168,12 @@ final class Aria2DownloadEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.terminationHandler = { finishedProcess in
|
process.terminationHandler = { finishedProcess in
|
||||||
|
completionMonitor.cancel()
|
||||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
|
||||||
if finishedProcess.terminationStatus == 0 {
|
if finishedProcess.terminationStatus == 0 {
|
||||||
completion(.success(()))
|
completionGate.complete(.success(()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +185,7 @@ final class Aria2DownloadEngine {
|
|||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -195,13 +198,104 @@ final class Aria2DownloadEngine {
|
|||||||
throw EngineError.launchFailed(error.localizedDescription)
|
throw EngineError.launchFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionMonitor.set(
|
||||||
|
Self.monitorCompletion(
|
||||||
|
rpcPort: rpcPort,
|
||||||
|
rpcSecret: rpcSecret,
|
||||||
|
process: process,
|
||||||
|
completionGate: completionGate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||||
|
completionMonitor.cancel()
|
||||||
if process.isRunning {
|
if process.isRunning {
|
||||||
process.terminate()
|
process.terminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func monitorCompletion(
|
||||||
|
rpcPort: Int,
|
||||||
|
rpcSecret: String,
|
||||||
|
process: Process,
|
||||||
|
completionGate: CompletionGate
|
||||||
|
) -> Task<Void, Never> {
|
||||||
|
Task.detached {
|
||||||
|
while !Task.isCancelled && process.isRunning {
|
||||||
|
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||||
|
completionGate.complete(.success(()))
|
||||||
|
if process.isRunning {
|
||||||
|
process.terminate()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func completedDownloadStatus(rpcPort: Int, rpcSecret: String) async -> Bool {
|
||||||
|
guard let stopped = await rpcCall(
|
||||||
|
rpcPort: rpcPort,
|
||||||
|
rpcSecret: rpcSecret,
|
||||||
|
method: "aria2.tellStopped",
|
||||||
|
arguments: [0, 10, ["status", "errorCode", "completedLength", "totalLength"]]
|
||||||
|
) as? [[String: Any]] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if stopped.contains(where: { item in
|
||||||
|
(item["status"] as? String) == "complete"
|
||||||
|
}) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return stopped.contains { item in
|
||||||
|
guard (item["status"] as? String) == "error",
|
||||||
|
(item["errorCode"] as? String) == "0",
|
||||||
|
let completedLength = Int64(item["completedLength"] as? String ?? ""),
|
||||||
|
let totalLength = Int64(item["totalLength"] as? String ?? ""),
|
||||||
|
totalLength > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return completedLength >= totalLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rpcCall(
|
||||||
|
rpcPort: Int,
|
||||||
|
rpcSecret: String,
|
||||||
|
method: String,
|
||||||
|
arguments: [Any]
|
||||||
|
) async -> Any? {
|
||||||
|
guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/jsonrpc") else { return nil }
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": method,
|
||||||
|
"id": UUID().uuidString,
|
||||||
|
"params": ["token:\(rpcSecret)"] + arguments
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
request.timeoutInterval = 3
|
||||||
|
|
||||||
|
guard let (responseData, _) = try? await URLSession.shared.data(for: request),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json["result"]
|
||||||
|
}
|
||||||
|
|
||||||
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
||||||
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
||||||
|
|
||||||
@@ -422,6 +516,47 @@ final class LockedDataBuffer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class CompletionGate: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var didComplete = false
|
||||||
|
private let completion: @Sendable (Result<Void, Error>) -> Void
|
||||||
|
|
||||||
|
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
|
||||||
|
self.completion = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func complete(_ result: Result<Void, Error>) {
|
||||||
|
lock.lock()
|
||||||
|
let shouldComplete = !didComplete
|
||||||
|
if shouldComplete {
|
||||||
|
didComplete = true
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
guard shouldComplete else { return }
|
||||||
|
completion(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CompletionMonitor: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
func set(_ task: Task<Void, Never>) {
|
||||||
|
lock.lock()
|
||||||
|
self.task = task
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
lock.lock()
|
||||||
|
let task = self.task
|
||||||
|
self.task = nil
|
||||||
|
lock.unlock()
|
||||||
|
task?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class Aria2ProgressParser: @unchecked Sendable {
|
final class Aria2ProgressParser: @unchecked Sendable {
|
||||||
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
|
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
|
||||||
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
|
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ enum DownloadMetadataFetcher {
|
|||||||
static func fetch(
|
static func fetch(
|
||||||
for url: URL,
|
for url: URL,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
|
credentials: DownloadCredentials? = nil,
|
||||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
|
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
|
||||||
) async -> PendingDownload {
|
) async -> PendingDownload {
|
||||||
let initialName = FileClassifier.fileName(from: url)
|
let initialName = FileClassifier.fileName(from: url)
|
||||||
@@ -56,7 +57,19 @@ enum DownloadMetadataFetcher {
|
|||||||
request.httpMethod = "HEAD"
|
request.httpMethod = "HEAD"
|
||||||
request.timeoutInterval = 12
|
request.timeoutInterval = 12
|
||||||
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
||||||
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
|
||||||
|
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
|
||||||
|
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
|
||||||
|
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
|
||||||
|
let token = "\(credentials.username):\(credentials.password)"
|
||||||
|
.data(using: .utf8)?
|
||||||
|
.base64EncodedString()
|
||||||
|
if let token {
|
||||||
|
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for header in normalizedHeaders {
|
||||||
request.setValue(header.value, forHTTPHeaderField: header.name)
|
request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||||
}
|
}
|
||||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ struct DownloadPropertiesView: View {
|
|||||||
@State private var mirrorText: String
|
@State private var mirrorText: String
|
||||||
@State private var errorMessage = ""
|
@State private var errorMessage = ""
|
||||||
@State private var showsAdvancedTransfer = false
|
@State private var showsAdvancedTransfer = false
|
||||||
|
@State private var showsChunkMap = false
|
||||||
|
|
||||||
init(item: DownloadItem) {
|
init(item: DownloadItem) {
|
||||||
self.item = item
|
self.item = item
|
||||||
@@ -81,39 +82,87 @@ struct DownloadPropertiesView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
DownloadSummaryHeader(item: item)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
Section("Download") {
|
if let noticeText {
|
||||||
TextField("URL", text: $urlText)
|
Section {
|
||||||
.font(.system(.callout, design: .monospaced))
|
Label(noticeText, systemImage: noticeSystemImage)
|
||||||
TextField("File name", text: $fileName)
|
.font(.caption)
|
||||||
HStack {
|
.foregroundStyle(.secondary)
|
||||||
TextField("Save location", text: $destinationPath)
|
|
||||||
.font(.system(.callout, design: .monospaced))
|
|
||||||
Button {
|
|
||||||
selectDestination()
|
|
||||||
} label: {
|
|
||||||
Label("Select", systemImage: "folder.badge.plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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") {
|
Section("Download") {
|
||||||
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
Text("URL")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("URL", text: $urlText)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.disabled(fileIdentityLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("File name")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("File name", text: $fileName)
|
||||||
|
.disabled(fileIdentityLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Save location")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Save location", text: $destinationPath)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.disabled(fileIdentityLocked)
|
||||||
|
Button {
|
||||||
|
selectDestination()
|
||||||
|
} label: {
|
||||||
|
Label("Select", systemImage: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
.disabled(fileIdentityLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Connections")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Stepper("\(connections) per file", value: $connections, in: 1...16)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Speed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Toggle("Limit", isOn: $speedLimitEnabled)
|
||||||
|
if speedLimitEnabled {
|
||||||
|
Stepper(
|
||||||
|
"\(speedLimitKiBPerSecond) KiB/s",
|
||||||
|
value: $speedLimitKiBPerSecond,
|
||||||
|
in: 1...10_485_760,
|
||||||
|
step: 128
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(item.status == .completed ? "Site Login for Redownload" : "Site Login") {
|
||||||
Picker("Login", selection: $loginMode) {
|
Picker("Login", selection: $loginMode) {
|
||||||
ForEach(LoginMode.allCases) { mode in
|
ForEach(LoginMode.allCases) { mode in
|
||||||
Text(mode.rawValue).tag(mode)
|
Text(mode.rawValue).tag(mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
|
||||||
if loginMode == .matching {
|
if loginMode == .matching {
|
||||||
Text(matchingLoginText)
|
Text(matchingLoginText)
|
||||||
@@ -121,48 +170,64 @@ struct DownloadPropertiesView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if loginMode == .custom {
|
} else if loginMode == .custom {
|
||||||
TextField("Username", text: $username)
|
TextField("Username", text: $username)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
SecureField("Password", text: $password)
|
SecureField("Password", text: $password)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisclosureGroup("Advanced Transfer", isExpanded: $showsAdvancedTransfer) {
|
Section {
|
||||||
Toggle("Checksum", isOn: $checksumEnabled)
|
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
|
||||||
if checksumEnabled {
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
GridRow {
|
||||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
Text("Checksum")
|
||||||
Text(algorithm.title).tag(algorithm)
|
.foregroundStyle(.secondary)
|
||||||
|
Toggle("Verify", isOn: $checksumEnabled)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checksumEnabled {
|
||||||
|
GridRow {
|
||||||
|
Text("Algorithm")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||||
|
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||||
|
Text(algorithm.title).tag(algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Digest")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Expected digest", text: $checksumValue)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Cookies")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Cookies", text: $cookieText)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextField("Expected digest", text: $checksumValue)
|
|
||||||
.font(.system(.callout, design: .monospaced))
|
CompactEditor(title: "Headers", text: $headerText)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
|
CompactEditor(title: "Mirrors", text: $mirrorText)
|
||||||
|
.disabled(transferSettingsLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Headers")
|
|
||||||
TextEditor(text: $headerText)
|
|
||||||
.font(.system(.callout, design: .monospaced))
|
|
||||||
.frame(minHeight: 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("Cookies", text: $cookieText)
|
|
||||||
.font(.system(.callout, design: .monospaced))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Mirrors")
|
|
||||||
TextEditor(text: $mirrorText)
|
|
||||||
.font(.system(.callout, design: .monospaced))
|
|
||||||
.frame(minHeight: 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Progress") {
|
|
||||||
ProgressView(value: item.progress)
|
|
||||||
InfoGrid(item: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.status == .downloading && item.rpcPort != nil {
|
if item.status == .downloading && item.rpcPort != nil {
|
||||||
Section("Chunk Map") {
|
Section {
|
||||||
ChunkMapView(item: item)
|
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
|
||||||
|
ChunkMapView(item: item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,10 +250,82 @@ struct DownloadPropertiesView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(12)
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
}
|
}
|
||||||
.frame(width: 720, height: 720)
|
.frame(width: 720, height: 580)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fileIdentityLocked: Bool {
|
||||||
|
item.status == .completed || item.status == .downloading
|
||||||
|
}
|
||||||
|
|
||||||
|
private var transferSettingsLocked: Bool {
|
||||||
|
item.status == .downloading
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noticeText: String? {
|
||||||
|
switch item.status {
|
||||||
|
case .completed:
|
||||||
|
return "File identity is read-only. Transfer settings are saved for redownload."
|
||||||
|
case .downloading:
|
||||||
|
return "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noticeSystemImage: String {
|
||||||
|
item.status == .completed ? "checkmark.circle" : "bolt.horizontal.circle"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var advancedTransferTitle: String {
|
||||||
|
item.status == .completed ? "Advanced Transfer for Redownload" : "Advanced Transfer"
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CompactEditor: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(title)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextEditor(text: $text)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.frame(minHeight: 44, maxHeight: 54)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CollapsibleGroup<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
@ViewBuilder var content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
|
||||||
|
Button {
|
||||||
|
isExpanded.toggle()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 12)
|
||||||
|
Text(title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
content()
|
||||||
|
.padding(.leading, 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var matchingLoginText: String {
|
private var matchingLoginText: String {
|
||||||
@@ -285,31 +422,69 @@ struct DownloadPropertiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct InfoGrid: View {
|
private struct DownloadSummaryHeader: View {
|
||||||
let item: DownloadItem
|
let item: DownloadItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
info("Status", item.status.rawValue)
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||||
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
|
Text(item.fileName)
|
||||||
info("Size", ByteFormatter.string(item.sizeBytes))
|
.font(.headline)
|
||||||
info("Speed", item.speedText)
|
.lineLimit(1)
|
||||||
info("ETA", item.etaText)
|
.truncationMode(.middle)
|
||||||
info("Live connections", "\(item.connectionCount)")
|
Spacer()
|
||||||
info("Speed cap", item.speedLimitText)
|
Label(item.status.rawValue, systemImage: item.category.symbolName)
|
||||||
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
.foregroundStyle(statusColor)
|
||||||
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
}
|
||||||
info("Category", item.category.rawValue)
|
|
||||||
info("Destination", item.destinationPath)
|
ProgressView(value: item.progress)
|
||||||
|
|
||||||
|
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
|
||||||
|
GridRow {
|
||||||
|
summary("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||||
|
summary("Size", ByteFormatter.string(item.sizeBytes))
|
||||||
|
summary("Speed", item.speedText)
|
||||||
|
summary("ETA", item.etaText)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
summary("Live connections", "\(item.connectionCount)")
|
||||||
|
summary("Speed cap", item.speedLimitText)
|
||||||
|
summary("Category", item.category.rawValue)
|
||||||
|
summary("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
summary("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.gridCellColumns(2)
|
||||||
|
summary("Destination", item.destinationPath)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func info(_ label: String, _ value: String) -> some View {
|
private var statusColor: Color {
|
||||||
GridRow {
|
switch item.status {
|
||||||
|
case .queued:
|
||||||
|
.secondary
|
||||||
|
case .downloading:
|
||||||
|
.accentColor
|
||||||
|
case .paused:
|
||||||
|
.orange
|
||||||
|
case .completed:
|
||||||
|
.green
|
||||||
|
case .failed, .canceled:
|
||||||
|
.red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summary(_ label: String, _ value: String) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(value)
|
Text(value)
|
||||||
.lineLimit(2)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ struct DownloadTable: View {
|
|||||||
@ViewBuilder content: () -> Content
|
@ViewBuilder content: () -> Content
|
||||||
) -> some View {
|
) -> some View {
|
||||||
content()
|
content()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
performPrimaryAction(for: item)
|
performPrimaryAction(for: item)
|
||||||
|
|||||||
Reference in New Issue
Block a user