fix: complete aria2 downloads via rpc

This commit is contained in:
nimbold
2026-06-05 12:38:14 +03:30
parent 4a9d4cab2f
commit c18aa6fdaf
7 changed files with 500 additions and 105 deletions
+16
View File
@@ -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/),
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
### New features
+1
View File
@@ -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`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **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.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
+83 -29
View File
@@ -43,10 +43,14 @@ struct AddDownloadsView: View {
.padding(16)
.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
scheduleMetadataRefresh(for: newValue)
}
.onChange(of: metadataRequestSignature) { _, _ in
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
scheduleMetadataRefresh(for: linkText)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
@@ -72,7 +76,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 82)
.frame(minHeight: 72)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -108,12 +112,24 @@ struct AddDownloadsView: View {
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
Label("Select...", systemImage: "folder.badge.plus")
}
.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) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
@@ -129,11 +145,11 @@ struct AddDownloadsView: View {
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
Label("Speed Limit per File", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit this batch", isOn: $speedLimitEnabled)
Toggle("Limit each file", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
@@ -173,12 +189,14 @@ struct AddDownloadsView: View {
}
private var summarySection: some View {
HStack(spacing: 8) {
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")
}
CompactSummaryStrip(
metrics: [
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
]
)
}
private var previewSection: some View {
@@ -216,7 +234,7 @@ struct AddDownloadsView: View {
}
.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) {
metadataTask?.cancel()
metadataTask = Task {
@@ -434,7 +472,12 @@ struct AddDownloadsView: View {
var loaded: [PendingDownload] = []
for url in urls {
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)
await MainActor.run {
for loadedItem in loaded {
@@ -554,30 +597,41 @@ struct AddDownloadsView: View {
}
}
private struct SummaryTile: View {
private struct SummaryMetric: Identifiable {
let title: String
let value: String
let symbolName: String
var id: String { title }
}
private struct CompactSummaryStrip: View {
let metrics: [SummaryMetric]
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold).monospacedDigit())
.lineLimit(1)
HStack(spacing: 12) {
ForEach(metrics) { metric in
HStack(spacing: 6) {
Image(systemName: metric.symbolName)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.title)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.value)
.font(.caption.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
if metric.id != metrics.last?.id {
Divider()
.frame(height: 14)
}
}
Spacer(minLength: 0)
}
.padding(9)
.frame(maxWidth: .infinity)
.frame(minHeight: 52)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+137 -2
View File
@@ -147,6 +147,8 @@ final class Aria2DownloadEngine {
let parser = Aria2ProgressParser()
let outputBuffer = LockedDataBuffer()
let errorBuffer = LockedDataBuffer()
let completionGate = CompletionGate(completion)
let completionMonitor = CompletionMonitor()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
@@ -166,11 +168,12 @@ final class Aria2DownloadEngine {
}
process.terminationHandler = { finishedProcess in
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completion(.success(()))
completionGate.complete(.success(()))
return
}
@@ -182,7 +185,7 @@ final class Aria2DownloadEngine {
.compactMap { $0 }
.filter { !$0.isEmpty }
.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 {
@@ -195,13 +198,104 @@ final class Aria2DownloadEngine {
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) {
completionMonitor.cancel()
if process.isRunning {
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 {
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 {
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
+14 -1
View File
@@ -34,6 +34,7 @@ enum DownloadMetadataFetcher {
static func fetch(
for url: URL,
settings: AppSettings,
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
@@ -56,7 +57,19 @@ 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 {
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)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
+248 -73
View File
@@ -47,6 +47,7 @@ struct DownloadPropertiesView: View {
@State private var mirrorText: String
@State private var errorMessage = ""
@State private var showsAdvancedTransfer = false
@State private var showsChunkMap = false
init(item: DownloadItem) {
self.item = item
@@ -81,39 +82,87 @@ struct DownloadPropertiesView: View {
var body: some View {
VStack(spacing: 0) {
DownloadSummaryHeader(item: item)
.padding(.horizontal, 18)
.padding(.vertical, 12)
Divider()
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.callout, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
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
)
if let noticeText {
Section {
Label(noticeText, systemImage: noticeSystemImage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
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) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(transferSettingsLocked)
if loginMode == .matching {
Text(matchingLoginText)
@@ -121,48 +170,64 @@ struct DownloadPropertiesView: View {
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
.disabled(transferSettingsLocked)
SecureField("Password", text: $password)
.disabled(transferSettingsLocked)
}
}
DisclosureGroup("Advanced Transfer", isExpanded: $showsAdvancedTransfer) {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
Section {
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Checksum")
.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 {
Section("Chunk Map") {
ChunkMapView(item: item)
Section {
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
ChunkMapView(item: item)
}
}
}
}
@@ -185,10 +250,82 @@ struct DownloadPropertiesView: View {
}
.buttonStyle(.borderedProminent)
}
.padding(14)
.padding(12)
.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 {
@@ -285,31 +422,69 @@ struct DownloadPropertiesView: View {
}
}
private struct InfoGrid: View {
private struct DownloadSummaryHeader: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
info("Status", item.status.rawValue)
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
info("Size", ByteFormatter.string(item.sizeBytes))
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)
info("Destination", item.destinationPath)
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Label(item.status.rawValue, systemImage: item.category.symbolName)
.foregroundStyle(statusColor)
}
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 {
GridRow {
private var statusColor: Color {
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)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(2)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
+1
View File
@@ -162,6 +162,7 @@ struct DownloadTable: View {
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
performPrimaryAction(for: item)