diff --git a/README.md b/README.md index f31973e..6ba4936 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ git push origin v0.1.0 - [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. diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 300a125..5764ce1 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -12,6 +12,13 @@ struct AddDownloadsView: View { @State private var destinationPath = "" @State private var metadataTask: Task? @State private var targetQueueID = DownloadQueue.mainQueueID + @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,6 +26,7 @@ struct AddDownloadsView: View { VStack(alignment: .leading, spacing: 18) { linkSection optionsSection + advancedTransferSection summarySection previewSection } @@ -186,7 +194,7 @@ struct AddDownloadsView: View { } label: { Label("Add to Queue", systemImage: "list.bullet") } - .disabled(pendingDownloads.isEmpty) + .disabled(!canAddDownloads) Button { addDownloads(start: true) @@ -194,12 +202,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: 74) + } + + 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: 74) + } + } + .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 +287,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 +298,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 +358,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 +394,8 @@ struct AddDownloadsView: View { connectionsPerServer: Int(connectionsPerServer), overrideDirectory: overrideDirectory, startImmediately: start, - queueID: targetQueueID + queueID: targetQueueID, + transferOptions: transferOptions ) dismiss() } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 1a74605..d55d9b2 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -181,6 +181,7 @@ final class Aria2DownloadEngine { "--retry-wait=5", "--connect-timeout=30", "--timeout=60", + "--uri-selector=adaptive", "--input-file=-" ] @@ -281,14 +282,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" { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 550fc19..6cbdb82 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -113,7 +113,8 @@ final class DownloadController: ObservableObject { connectionsPerServer: Int, overrideDirectory: URL?, startImmediately: Bool, - queueID: UUID = DownloadQueue.mainQueueID + queueID: UUID = DownloadQueue.mainQueueID, + transferOptions: DownloadTransferOptions = DownloadTransferOptions() ) { let clampedConnections = min(max(connectionsPerServer, 1), 16) let targetQueueID = normalizedQueueID(queueID) @@ -126,6 +127,10 @@ 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, sizeBytes: pending.sizeBytes, bytesText: ByteFormatter.string(pending.sizeBytes), message: startImmediately ? "Queued to start" : "Added to queue", @@ -427,7 +432,8 @@ final class DownloadController: ObservableObject { fileName: String, destinationDirectory: URL, connectionsPerServer: Int, - credentials: DownloadCredentials? + credentials: DownloadCredentials?, + transferOptions: DownloadTransferOptions ) { update(id) { $0.url = url @@ -436,6 +442,10 @@ 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.message = "Properties updated" } saveDownloads() diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift index c0f44c9..24a71b3 100644 --- a/Sources/Firelink/DownloadMetadataFetcher.swift +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -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) diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift index 038ebe1..e3d8113 100644 --- a/Sources/Firelink/DownloadPropertiesView.swift +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -37,6 +37,12 @@ struct DownloadPropertiesView: View { @State private var loginMode: LoginMode @State private var username: String @State private var password: String + @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) { @@ -54,6 +60,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 { @@ -93,6 +111,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 +168,7 @@ struct DownloadPropertiesView: View { .padding(14) .background(.bar) } - .frame(width: 620, height: 560) + .frame(width: 620, height: 760) } private var matchingLoginText: String { @@ -175,16 +223,45 @@ 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 ) 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 { diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index e15623f..de0d419 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -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[.. [String] { + headerLines(text).filter { line in + guard let colonIndex = line.firstIndex(of: ":") else { return true } + let name = String(line[.. 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,10 @@ 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 status: DownloadStatus = .queued var progress: Double = 0 var speedText: String = "-" @@ -78,6 +189,15 @@ 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 ?? [] + ) + } } struct DownloadProgress: Equatable, Sendable {