diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f2cc3..4ad7c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 26ef0ba..5f54dff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index ff092d4..8fc2a52 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -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)) } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index e61c704..ec46098 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -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 { + 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 + + init(_ completion: @escaping @Sendable (Result) -> Void) { + self.completion = completion + } + + func complete(_ result: Result) { + 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? + + func set(_ task: Task) { + 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+)"#) diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift index 56ba4c1..bccd326 100644 --- a/Sources/Firelink/DownloadMetadataFetcher.swift +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -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 { diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift index 9744825..1fc9282 100644 --- a/Sources/Firelink/DownloadPropertiesView.swift +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -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: 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) } } } diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index fde8e6c..095114d 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -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)