From a94c5c41b27188a97c853a60af2caefe835b1902 Mon Sep 17 00:00:00 2001 From: Quinn Gao Date: Wed, 22 Jul 2026 13:07:15 +0800 Subject: [PATCH 1/2] feat: add per-download custom HTTP headers --- Harbor/Models/AddDownloadRequest.swift | 3 + Harbor/Models/AddDownloadSheetDraft.swift | 5 +- Harbor/Models/DownloadItem.swift | 11 + Harbor/Models/DownloadSource.swift | 4 + Harbor/Models/RequestHeader.swift | 110 ++++++++ Harbor/Services/Aria2TorrentService.swift | 26 +- Harbor/Services/DownloadCoordinator.swift | 53 +++- Harbor/Services/TorrentSourceLoader.swift | 107 ++++++++ Harbor/ViewModels/DownloadCenter.swift | 20 +- Harbor/Views/AddDownloadSheet.swift | 135 +++++++++- Harbor/Views/RequestHeadersEditor.swift | 314 ++++++++++++++++++++++ 11 files changed, 765 insertions(+), 23 deletions(-) create mode 100644 Harbor/Models/RequestHeader.swift create mode 100644 Harbor/Services/TorrentSourceLoader.swift create mode 100644 Harbor/Views/RequestHeadersEditor.swift diff --git a/Harbor/Models/AddDownloadRequest.swift b/Harbor/Models/AddDownloadRequest.swift index 061191f..a6f6e7d 100644 --- a/Harbor/Models/AddDownloadRequest.swift +++ b/Harbor/Models/AddDownloadRequest.swift @@ -6,6 +6,7 @@ struct AddDownloadRequest: Sendable { let customFilename: String? let destinationFolder: URL let shouldStartImmediately: Bool + let requestHeaders: [RequestHeader] let mediaMetadata: MediaDownloadMetadata? let mediaFormatPreference: MediaDownloadFormatPreference? @@ -15,6 +16,7 @@ struct AddDownloadRequest: Sendable { customFilename: String?, destinationFolder: URL, shouldStartImmediately: Bool, + requestHeaders: [RequestHeader] = [], mediaMetadata: MediaDownloadMetadata? = nil, mediaFormatPreference: MediaDownloadFormatPreference? = nil ) { @@ -23,6 +25,7 @@ struct AddDownloadRequest: Sendable { self.customFilename = customFilename self.destinationFolder = destinationFolder self.shouldStartImmediately = shouldStartImmediately + self.requestHeaders = requestHeaders self.mediaMetadata = mediaMetadata self.mediaFormatPreference = mediaFormatPreference } diff --git a/Harbor/Models/AddDownloadSheetDraft.swift b/Harbor/Models/AddDownloadSheetDraft.swift index ab33ff0..c9a81fb 100644 --- a/Harbor/Models/AddDownloadSheetDraft.swift +++ b/Harbor/Models/AddDownloadSheetDraft.swift @@ -24,6 +24,7 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { let torrentFileURL: URL? let destinationFolderURL: URL let shouldStartImmediately: Bool + let requestHeaders: [RequestHeader] init( id: UUID = UUID(), @@ -32,7 +33,8 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { customFilename: String = "", torrentFileURL: URL? = nil, destinationFolderURL: URL, - shouldStartImmediately: Bool + shouldStartImmediately: Bool, + requestHeaders: [RequestHeader] = [] ) { self.id = id self.entryMode = entryMode @@ -41,6 +43,7 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { self.torrentFileURL = torrentFileURL self.destinationFolderURL = destinationFolderURL self.shouldStartImmediately = shouldStartImmediately + self.requestHeaders = requestHeaders } static func blank( diff --git a/Harbor/Models/DownloadItem.swift b/Harbor/Models/DownloadItem.swift index 0203eb1..4c5965e 100644 --- a/Harbor/Models/DownloadItem.swift +++ b/Harbor/Models/DownloadItem.swift @@ -113,6 +113,7 @@ struct DownloadRecord: Codable, Sendable { let updatedAt: Date let lastError: String? let resumeData: Data? + let requestHeaders: [RequestHeader] let backendIdentifier: String? let metadataName: String? let mediaMetadata: MediaDownloadMetadata? @@ -137,6 +138,7 @@ struct DownloadRecord: Codable, Sendable { case updatedAt case lastError case resumeData + case requestHeaders case backendIdentifier case metadataName case mediaMetadata @@ -162,6 +164,7 @@ struct DownloadRecord: Codable, Sendable { updatedAt: Date, lastError: String?, resumeData: Data?, + requestHeaders: [RequestHeader] = [], backendIdentifier: String?, metadataName: String?, mediaMetadata: MediaDownloadMetadata? = nil, @@ -185,6 +188,7 @@ struct DownloadRecord: Codable, Sendable { self.updatedAt = updatedAt self.lastError = lastError self.resumeData = resumeData + self.requestHeaders = requestHeaders self.backendIdentifier = backendIdentifier self.metadataName = metadataName self.mediaMetadata = mediaMetadata @@ -211,6 +215,7 @@ struct DownloadRecord: Codable, Sendable { self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? .now self.lastError = try container.decodeIfPresent(String.self, forKey: .lastError) self.resumeData = try container.decodeIfPresent(Data.self, forKey: .resumeData) + self.requestHeaders = try container.decodeIfPresent([RequestHeader].self, forKey: .requestHeaders) ?? [] self.backendIdentifier = try container.decodeIfPresent(String.self, forKey: .backendIdentifier) self.metadataName = try container.decodeIfPresent(String.self, forKey: .metadataName) self.mediaMetadata = try container.decodeIfPresent(MediaDownloadMetadata.self, forKey: .mediaMetadata) @@ -237,6 +242,7 @@ struct DownloadRecord: Codable, Sendable { try container.encode(updatedAt, forKey: .updatedAt) try container.encode(lastError, forKey: .lastError) try container.encode(resumeData, forKey: .resumeData) + try container.encode(requestHeaders, forKey: .requestHeaders) try container.encode(backendIdentifier, forKey: .backendIdentifier) try container.encode(metadataName, forKey: .metadataName) try container.encode(mediaMetadata, forKey: .mediaMetadata) @@ -272,6 +278,7 @@ final class DownloadItem: Identifiable { var metadataName: String? var mediaMetadata: MediaDownloadMetadata? var mediaFormatPreference: MediaDownloadFormatPreference? + var requestHeaders: [RequestHeader] var activityEvents: [DownloadActivityEvent] init( @@ -299,6 +306,7 @@ final class DownloadItem: Identifiable { metadataName: String? = nil, mediaMetadata: MediaDownloadMetadata? = nil, mediaFormatPreference: MediaDownloadFormatPreference? = nil, + requestHeaders: [RequestHeader] = [], activityEvents: [DownloadActivityEvent] = [] ) { self.id = id @@ -325,6 +333,7 @@ final class DownloadItem: Identifiable { self.metadataName = metadataName self.mediaMetadata = mediaMetadata self.mediaFormatPreference = mediaFormatPreference + self.requestHeaders = requestHeaders self.activityEvents = activityEvents if self.activityEvents.contains(where: { $0.kind == .added }) == false { @@ -361,6 +370,7 @@ final class DownloadItem: Identifiable { metadataName: record.metadataName, mediaMetadata: record.mediaMetadata, mediaFormatPreference: record.mediaFormatPreference, + requestHeaders: record.requestHeaders, activityEvents: record.activityEvents ) } @@ -524,6 +534,7 @@ final class DownloadItem: Identifiable { updatedAt: updatedAt, lastError: lastError, resumeData: resumeData, + requestHeaders: requestHeaders, backendIdentifier: backendIdentifier, metadataName: metadataName, mediaMetadata: mediaMetadata, diff --git a/Harbor/Models/DownloadSource.swift b/Harbor/Models/DownloadSource.swift index 102f16e..3e66677 100644 --- a/Harbor/Models/DownloadSource.swift +++ b/Harbor/Models/DownloadSource.swift @@ -38,6 +38,10 @@ enum DownloadSourceKind: String, Codable, CaseIterable, Identifiable, Sendable { self == .directURL } + var usesAria2: Bool { + self == .magnetLink || self == .torrentFile + } + static func detect(from url: URL) -> DownloadSourceKind? { if url.isFileURL { return url.pathExtension.lowercased() == "torrent" ? .torrentFile : nil diff --git a/Harbor/Models/RequestHeader.swift b/Harbor/Models/RequestHeader.swift new file mode 100644 index 0000000..f4ea630 --- /dev/null +++ b/Harbor/Models/RequestHeader.swift @@ -0,0 +1,110 @@ +import Foundation + +nonisolated struct RequestHeader: Codable, Hashable, Identifiable, Sendable { + let id: UUID + var name: String + var value: String + + init( + id: UUID = UUID(), + name: String = "", + value: String = "" + ) { + self.id = id + self.name = name + self.value = value + } + + var validationIssue: RequestHeaderValidationIssue? { + guard name.isEmpty == false else { + return .missingName + } + + guard name.unicodeScalars.allSatisfy(Self.allowedNameCharacters.contains) else { + return .invalidName + } + + guard value.unicodeScalars.contains(where: Self.isDisallowedValueCharacter) == false else { + return .invalidValue + } + + return nil + } + + /// Identifies Cookie and Authorization headers for warnings in RequestHeadersEditor and AddDownloadSheet. + var triggersSensitiveTorrentWarning: Bool { + let fieldName = name.trimmingCharacters(in: .whitespacesAndNewlines) + return fieldName.caseInsensitiveCompare("Cookie") == .orderedSame + || fieldName.caseInsensitiveCompare("Authorization") == .orderedSame + } + + var aria2HeaderValue: String { + "\(name): \(value)" + } + + private static let allowedNameCharacters = CharacterSet( + charactersIn: "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ) + + private static func isDisallowedValueCharacter(_ character: Unicode.Scalar) -> Bool { + (character.value < 0x20 && character.value != 0x09) // 0x09: horizontal tab + || character.value == 0x7F // 0x7F: delete + } +} + +nonisolated enum RequestHeaderValidationIssue: Equatable, Sendable { + case missingName + case invalidName + case invalidValue +} + +nonisolated extension Collection where Element == RequestHeader { + var triggersSensitiveTorrentWarning: Bool { + contains(where: \.triggersSensitiveTorrentWarning) + } + + func apply(to request: inout URLRequest) { + var appliedFieldNames = Set() + + for header in self { + let normalizedName = header.name.lowercased() + if appliedFieldNames.insert(normalizedName).inserted { + request.setValue(header.value, forHTTPHeaderField: header.name) + } else { + request.addValue(header.value, forHTTPHeaderField: header.name) + } + } + } + + func apply( + toSameOriginRedirect request: inout URLRequest, + originatingAt sourceURL: URL + ) { + guard let redirectedURL = request.url, + let sourceOrigin = HTTPOrigin(sourceURL), + let redirectedOrigin = HTTPOrigin(redirectedURL), + sourceOrigin == redirectedOrigin else { + return + } + + apply(to: &request) + } +} + +private nonisolated struct HTTPOrigin: Equatable { + let scheme: String + let host: String + let port: Int + + init?(_ url: URL) { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host?.lowercased() else { + return nil + } + + self.scheme = scheme + self.host = host + self.port = url.port ?? (scheme == "http" ? 80 : 443) + } +} diff --git a/Harbor/Services/Aria2TorrentService.swift b/Harbor/Services/Aria2TorrentService.swift index c95f5a7..59134a0 100644 --- a/Harbor/Services/Aria2TorrentService.swift +++ b/Harbor/Services/Aria2TorrentService.swift @@ -147,12 +147,16 @@ actor Aria2TorrentService { func addDownload( sourceKind: DownloadSourceKind, sourceURL: URL, - destinationFolderPath: String + destinationFolderPath: String, + requestHeaders: [RequestHeader] ) async throws -> String { logger.info("Starting torrent add request for source kind \(String(describing: sourceKind), privacy: .public)") try await ensureDaemonRunning() - let options = downloadOptions(destinationFolderPath: destinationFolderPath) + let options = downloadOptions( + destinationFolderPath: destinationFolderPath, + requestHeaders: requestHeaders + ) switch sourceKind { case .magnetLink: @@ -170,7 +174,10 @@ actor Aria2TorrentService { logger.info("aria2 accepted magnet download with gid \(gid, privacy: .public)") return gid case .torrentFile: - let torrentData = try Data(contentsOf: sourceURL) + let torrentData = try await TorrentSourceLoader.load( + from: sourceURL, + requestHeaders: requestHeaders + ) let gid = try await rpcCallWithDaemonRestart( method: "aria2.addTorrent", params: { @@ -373,17 +380,24 @@ actor Aria2TorrentService { return "token:\(rpcSecret)" } - private func downloadOptions(destinationFolderPath: String) -> [String: String] { - var options = [ + private func downloadOptions( + destinationFolderPath: String, + requestHeaders: [RequestHeader] + ) -> [String: Any] { + var options: [String: Any] = [ "dir": destinationFolderPath, "continue": "true", "pause": "false" ] - perDownloadOptions(transferSettings).forEach { key, value in + for (key, value) in perDownloadOptions(transferSettings) { options[key] = value } + if requestHeaders.isEmpty == false { + options["header"] = requestHeaders.map(\.aria2HeaderValue) + } + return options } diff --git a/Harbor/Services/DownloadCoordinator.swift b/Harbor/Services/DownloadCoordinator.swift index da78f31..e796088 100644 --- a/Harbor/Services/DownloadCoordinator.swift +++ b/Harbor/Services/DownloadCoordinator.swift @@ -28,6 +28,8 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { private struct TaskContext { let downloadID: UUID + let sourceURL: URL + let requestHeaders: [RequestHeader] let session: URLSession let task: URLSessionDownloadTask var transferSample: TransferSample @@ -75,18 +77,27 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { } @discardableResult - func startDownload(id: UUID, sourceURL: URL, resumeData: Data?) -> Int { + func startDownload( + id: UUID, + sourceURL: URL, + requestHeaders: [RequestHeader], + resumeData: Data? + ) -> Int { let session = makeSession() let task: URLSessionDownloadTask if let resumeData { task = session.downloadTask(withResumeData: resumeData) } else { - task = session.downloadTask(with: sourceURL) + var request = URLRequest(url: sourceURL) + requestHeaders.apply(to: &request) + task = session.downloadTask(with: request) } let key = makeTaskKey(session: session, taskIdentifier: task.taskIdentifier) let context = TaskContext( downloadID: id, + sourceURL: sourceURL, + requestHeaders: requestHeaders, session: session, task: task, transferSample: TransferSample( @@ -176,6 +187,16 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { } } + private func redirectContext( + for taskKey: TaskKey + ) -> (sourceURL: URL, requestHeaders: [RequestHeader])? { + withLock { + contexts[taskKey].map { context in + (context.sourceURL, context.requestHeaders) + } + } + } + private func makeSession() -> URLSession { let perDownloadConnectionCount = withLock { transferSettings.perDownloadConnectionCount @@ -298,6 +319,34 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { } extension DownloadCoordinator: URLSessionDownloadDelegate, URLSessionTaskDelegate { + /// Preserves this download's request headers when URLSession follows a same-origin redirect. + /// + /// URLSession creates the proposed redirect request and may omit headers such as `Authorization`. + /// Looking up the task context keeps the headers scoped to their originating download, while + /// `apply(toSameOriginRedirect:originatingAt:)` prevents us from deliberately reapplying them + /// after the redirect crosses an HTTP origin boundary. The completion handler must always be + /// called; passing the resulting request allows URLSession to continue following the redirect. + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + let taskKey = makeTaskKey(session: session, taskIdentifier: task.taskIdentifier) + guard let context = redirectContext(for: taskKey) else { + completionHandler(request) + return + } + + var redirectedRequest = request + context.requestHeaders.apply( + toSameOriginRedirect: &redirectedRequest, + originatingAt: context.sourceURL + ) + completionHandler(redirectedRequest) + } + func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, diff --git a/Harbor/Services/TorrentSourceLoader.swift b/Harbor/Services/TorrentSourceLoader.swift new file mode 100644 index 0000000..7c594f1 --- /dev/null +++ b/Harbor/Services/TorrentSourceLoader.swift @@ -0,0 +1,107 @@ +import Foundation + +nonisolated enum TorrentSourceLoader { + static func load( + from sourceURL: URL, + requestHeaders: [RequestHeader] + ) async throws -> Data { + if sourceURL.isFileURL { + return try Data(contentsOf: sourceURL) + } + + var request = URLRequest(url: sourceURL) + requestHeaders.apply(to: &request) + + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = true + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 120 + + let redirectDelegate = TorrentSourceRedirectDelegate( + sourceURL: sourceURL, + requestHeaders: requestHeaders + ) + let session = URLSession( + configuration: configuration, + delegate: redirectDelegate, + delegateQueue: nil + ) + defer { + session.finishTasksAndInvalidate() + } + + let (data, response) = try await session.data(for: request) + if let response = response as? HTTPURLResponse, + (200 ... 299).contains(response.statusCode) == false { + throw TorrentSourceLoadingError.httpStatus(response.statusCode) + } + + guard data.isEmpty == false else { + throw TorrentSourceLoadingError.emptyResponse + } + + return data + } +} + +/// Handles redirects while Harbor fetches a remote `.torrent` file before passing its data to aria2. +/// +/// `TorrentSourceLoader` uses a separate ephemeral URLSession, so it cannot share the redirect handling +/// in `DownloadCoordinator`. This delegate is scoped to that one source-loading session; it does not +/// control the later tracker or web-seed requests made by aria2. +private nonisolated final class TorrentSourceRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let sourceURL: URL + private let requestHeaders: [RequestHeader] + + init( + sourceURL: URL, + requestHeaders: [RequestHeader] + ) { + self.sourceURL = sourceURL + self.requestHeaders = requestHeaders + } + + /// Reapplies the supplied headers when the torrent source redirects within its original HTTP origin. + /// URLSession may omit headers such as `Authorization` from its proposed redirect request. Passing + /// the adjusted request to the completion handler allows the source fetch to continue without + /// deliberately reapplying those headers after an origin change. + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + var redirectedRequest = request + requestHeaders.apply( + toSameOriginRedirect: &redirectedRequest, + originatingAt: sourceURL + ) + completionHandler(redirectedRequest) + } +} + +nonisolated enum TorrentSourceLoadingError: LocalizedError, Sendable { + case httpStatus(Int) + case emptyResponse + + var errorDescription: String? { + switch self { + case let .httpStatus(statusCode): + return String( + format: String( + localized: "error.torrent.sourceHTTPStatus", + defaultValue: "The torrent file server returned HTTP %d.", + comment: "Error shown when a remote torrent file request returns a non-success HTTP status. Parameter is the HTTP status code." + ), + statusCode + ) + case .emptyResponse: + return String( + localized: "error.torrent.sourceEmpty", + defaultValue: "The torrent file server returned an empty response.", + comment: "Error shown when a remote torrent file request succeeds without returning torrent data." + ) + } + } +} diff --git a/Harbor/ViewModels/DownloadCenter.swift b/Harbor/ViewModels/DownloadCenter.swift index 02d27e3..0b48eaf 100644 --- a/Harbor/ViewModels/DownloadCenter.swift +++ b/Harbor/ViewModels/DownloadCenter.swift @@ -445,7 +445,8 @@ final class DownloadCenter { status: request.shouldStartImmediately ? .queued : .paused, metadataName: request.mediaMetadata?.title, mediaMetadata: request.mediaMetadata, - mediaFormatPreference: request.mediaFormatPreference + mediaFormatPreference: request.mediaFormatPreference, + requestHeaders: request.requestHeaders ) if request.sourceKind == .magnetLink { @@ -900,6 +901,7 @@ final class DownloadCenter { item.taskIdentifier = coordinator.startDownload( id: item.id, sourceURL: item.sourceURL, + requestHeaders: item.requestHeaders, resumeData: item.resumeData ) item.resumeData = nil @@ -1017,7 +1019,8 @@ final class DownloadCenter { let replacementIdentifier = try await torrentService.addDownload( sourceKind: refreshedItem.sourceKind, sourceURL: refreshedItem.sourceURL, - destinationFolderPath: refreshedItem.destinationFolderPath + destinationFolderPath: refreshedItem.destinationFolderPath, + requestHeaders: refreshedItem.requestHeaders ) guard item(for: id) != nil else { @@ -1031,7 +1034,8 @@ final class DownloadCenter { let backendIdentifier = try await torrentService.addDownload( sourceKind: currentItem.sourceKind, sourceURL: currentItem.sourceURL, - destinationFolderPath: currentItem.destinationFolderPath + destinationFolderPath: currentItem.destinationFolderPath, + requestHeaders: currentItem.requestHeaders ) guard item(for: id) != nil else { await torrentService.remove(gid: backendIdentifier) @@ -2062,7 +2066,15 @@ final class DownloadCenter { persistTask?.cancel() persistTask = Task { [persistence] in - try? await Task.sleep(for: .milliseconds(250)) + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { + return + } + + guard Task.isCancelled == false else { + return + } try? await persistence.save(records) } } diff --git a/Harbor/Views/AddDownloadSheet.swift b/Harbor/Views/AddDownloadSheet.swift index 7be5b98..5e10841 100644 --- a/Harbor/Views/AddDownloadSheet.swift +++ b/Harbor/Views/AddDownloadSheet.swift @@ -20,6 +20,9 @@ struct AddDownloadSheet: View { @State private var torrentFileURL: URL? @State private var destinationPath: String @State private var shouldStartImmediately: Bool + @State private var requestHeaders: [RequestHeader] + @State private var isAdvancedSettingsExpanded = false + @State private var isRequestHeadersEditorPresented = false @State private var validationMessage: String? @State private var mediaPreview: MediaDownloadMetadata? @State private var mediaPreviewError: String? @@ -29,6 +32,8 @@ struct AddDownloadSheet: View { @State private var isSubmitting = false @State private var mediaPreviewTask: Task? @State private var mediaPreviewGeneration = 0 + @State private var approvedSensitiveTorrentHeaders: [RequestHeader]? + @State private var pendingSensitiveHeaderRequest: AddDownloadRequest? init( settings: AppSettingsStore, @@ -45,6 +50,7 @@ struct AddDownloadSheet: View { _torrentFileURL = State(initialValue: draft.torrentFileURL) _destinationPath = State(initialValue: draft.destinationFolderURL.path) _shouldStartImmediately = State(initialValue: draft.shouldStartImmediately) + _requestHeaders = State(initialValue: draft.requestHeaders) } var body: some View { @@ -96,6 +102,8 @@ struct AddDownloadSheet: View { destinationPicker Toggle("Start immediately", isOn: $shouldStartImmediately) + + advancedSettingsSection } .formStyle(.grouped) @@ -151,6 +159,42 @@ struct AddDownloadSheet: View { focusedField = nil } } + .sheet(isPresented: $isRequestHeadersEditorPresented) { + RequestHeadersEditor( + requestHeaders: requestHeaders, + sourceUsesAria2: currentSourceUsesAria2 + ) { updatedHeaders, confirmedSensitiveTorrentWarning in + requestHeaders = updatedHeaders + approvedSensitiveTorrentHeaders = confirmedSensitiveTorrentWarning + ? updatedHeaders + : nil + validationMessage = nil + } + } + // Fallback if the source becomes an aria2 job after sensitive headers were saved. + .alert( + "Sensitive headers may be shared", + isPresented: Binding( + get: { pendingSensitiveHeaderRequest != nil }, + set: { isPresented in + if isPresented == false { + pendingSensitiveHeaderRequest = nil + } + } + ) + ) { + Button("Cancel", role: .cancel) { + pendingSensitiveHeaderRequest = nil + } + + Button("Continue Download") { + continuePendingSensitiveHeaderDownload() + } + } message: { + Text( + "The supplied headers contain Cookie or Authorization information. aria2 may send these headers to every HTTP/HTTPS tracker and web seed used by this torrent. Proceed?" + ) + } } @ViewBuilder @@ -240,6 +284,29 @@ struct AddDownloadSheet: View { } } + private var advancedSettingsSection: some View { + DisclosureGroup(isExpanded: $isAdvancedSettingsExpanded) { + LabeledContent("Request Headers") { + Button("Configure…") { + isRequestHeadersEditorPresented = true + } + } + .padding(.top, 10) + .padding(.leading, 24) + } label: { + HStack { + Text("Advanced Settings") + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isAdvancedSettingsExpanded.toggle() + } + } + } + } + private var canSubmit: Bool { switch entryMode { case .linkOrMagnet: @@ -289,6 +356,19 @@ struct AddDownloadSheet: View { return URL(string: trimmedURL) } + private var currentSourceUsesAria2: Bool { + switch entryMode { + case .torrentFile: + true + case .linkOrMagnet: + if let parsedLinkURL { + DownloadSourceKind.detect(from: parsedLinkURL)?.usesAria2 == true + } else { + false + } + } + } + @MainActor private func submit() async { guard isSubmitting == false else { @@ -396,20 +476,54 @@ struct AddDownloadSheet: View { let folderURL = URL(fileURLWithPath: destinationPath, isDirectory: true) let trimmedFilename = customFilename.trimmingCharacters(in: .whitespacesAndNewlines) - onSubmit( - AddDownloadRequest( - sourceKind: sourceKind, - sourceURL: sourceURL, - customFilename: sourceKind.supportsCustomFilename && trimmedFilename.isEmpty == false ? trimmedFilename : nil, - destinationFolder: folderURL, - shouldStartImmediately: shouldStartImmediately, - mediaMetadata: requestMediaMetadata, - mediaFormatPreference: requestMediaFormatPreference + guard sourceKind != .mediaURL || requestHeaders.isEmpty else { + validationMessage = String( + localized: "add.validation.mediaHeadersUnsupported", + defaultValue: "Request headers aren’t supported for media downloads.", + comment: "Validation message shown when request headers are supplied for a yt-dlp media download." ) + return + } + + let request = AddDownloadRequest( + sourceKind: sourceKind, + sourceURL: sourceURL, + customFilename: sourceKind.supportsCustomFilename && trimmedFilename.isEmpty == false ? trimmedFilename : nil, + destinationFolder: folderURL, + shouldStartImmediately: shouldStartImmediately, + requestHeaders: requestHeaders, + mediaMetadata: requestMediaMetadata, + mediaFormatPreference: requestMediaFormatPreference ) + + if sourceKind.usesAria2, + requestHeaders.triggersSensitiveTorrentWarning, + approvedSensitiveTorrentHeaders != requestHeaders { + pendingSensitiveHeaderRequest = request + return + } + + performSubmission(request) + } + + @MainActor + private func performSubmission(_ request: AddDownloadRequest) { + onSubmit(request) dismiss() } + private func continuePendingSensitiveHeaderDownload() { + guard let request = pendingSensitiveHeaderRequest, + isSubmitting == false else { + return + } + + pendingSensitiveHeaderRequest = nil + isSubmitting = true + defer { isSubmitting = false } + performSubmission(request) + } + @ViewBuilder private func mediaThumbnail(for metadata: MediaDownloadMetadata) -> some View { if let thumbnailURL = metadata.thumbnailURL { @@ -510,7 +624,8 @@ struct AddDownloadSheet: View { guard entryMode == .linkOrMagnet, let url = parsedLinkURL, - DownloadSourceKind.detect(from: url) == .directURL else { + DownloadSourceKind.detect(from: url) == .directURL, + isKnownMediaHost(url) else { return } diff --git a/Harbor/Views/RequestHeadersEditor.swift b/Harbor/Views/RequestHeadersEditor.swift new file mode 100644 index 0000000..7e261af --- /dev/null +++ b/Harbor/Views/RequestHeadersEditor.swift @@ -0,0 +1,314 @@ +import AppKit +import SwiftUI + +struct RequestHeadersEditor: View { + private static let placeholder = """ + User-Agent: Mozilla/5.0 + Referer: https://example.com/ + """ + + let sourceUsesAria2: Bool + let onSave: ([RequestHeader], _ confirmedSensitiveTorrentWarning: Bool) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var requestHeadersText: String + @State private var isRequestHeadersEditorFocused = false + @State private var validationMessage: String? + @State private var pendingSensitiveHeaders: [RequestHeader]? + @State private var isSensitiveHeaderWarningPresented = false + + init( + requestHeaders: [RequestHeader], + sourceUsesAria2: Bool, + onSave: @escaping ([RequestHeader], _ confirmedSensitiveTorrentWarning: Bool) -> Void + ) { + self.sourceUsesAria2 = sourceUsesAria2 + self.onSave = onSave + _requestHeadersText = State( + initialValue: requestHeaders + .map { "\($0.name): \($0.value)" } + .joined(separator: "\n") + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Request Headers") + .font(.title2.weight(.semibold)) + + Text("Enter one header per line using Name: value format.") + .foregroundStyle(.secondary) + } + + ZStack(alignment: .topLeading) { + PaddedTextEditor( + text: $requestHeadersText, + isFocused: $isRequestHeadersEditorFocused + ) + + if requestHeadersText.isEmpty { + Text(Self.placeholder) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary.opacity(0.6)) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + .frame(minHeight: 140, idealHeight: 170) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(.secondary.opacity(0.25), lineWidth: 1) + } + + if let validationMessage { + Text(validationMessage) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button("Save") { + save() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: 460, idealWidth: 500, maxWidth: 560, minHeight: 260, idealHeight: 300) + .onAppear { + isRequestHeadersEditorFocused = true + } + .onChange(of: isRequestHeadersEditorFocused) { oldValue, newValue in + if oldValue, newValue == false { + _ = parseRequestHeaders() + } + } + .onChange(of: requestHeadersText) { + validationMessage = nil + } + // See RequestHeader.triggersSensitiveTorrentWarning; warn before saving headers aria2 may forward. + .alert( + "Sensitive headers may be shared", + isPresented: $isSensitiveHeaderWarningPresented + ) { + Button("Cancel", role: .cancel) { + pendingSensitiveHeaders = nil + isRequestHeadersEditorFocused = true + } + + Button("Save Headers") { + savePendingSensitiveHeaders() + } + } message: { + Text( + "The supplied headers contain Cookie or Authorization information. aria2 may send these headers to every HTTP/HTTPS tracker and web seed used by this torrent. Save these headers?" + ) + } + } + + private func save() { + guard let requestHeaders = parseRequestHeaders() else { + isRequestHeadersEditorFocused = true + return + } + + if sourceUsesAria2, + requestHeaders.triggersSensitiveTorrentWarning { + pendingSensitiveHeaders = requestHeaders + isSensitiveHeaderWarningPresented = true + return + } + + commit(requestHeaders, confirmedSensitiveTorrentWarning: false) + } + + private func savePendingSensitiveHeaders() { + guard let requestHeaders = pendingSensitiveHeaders else { + return + } + + pendingSensitiveHeaders = nil + commit(requestHeaders, confirmedSensitiveTorrentWarning: true) + } + + private func commit( + _ requestHeaders: [RequestHeader], + confirmedSensitiveTorrentWarning: Bool + ) { + onSave(requestHeaders, confirmedSensitiveTorrentWarning) + dismiss() + } + + private func parseRequestHeaders() -> [RequestHeader]? { + var requestHeaders: [RequestHeader] = [] + validationMessage = nil + + for (index, line) in requestHeadersText.components(separatedBy: "\n").enumerated() { + guard line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + continue + } + + let lineNumber = index + 1 + guard let separator = line.firstIndex(of: ":") else { + validationMessage = String( + format: String( + localized: "add.validation.headerFormat", + defaultValue: "Header line %d must use Name: value format.", + comment: "Validation message shown when a request header line has no colon separator. Parameter is the one-based line number." + ), + lineNumber + ) + return nil + } + + let valueStart = line.index(after: separator) + let header = RequestHeader( + name: line[.. Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { + return scrollView + } + + scrollView.borderType = .noBorder + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + textView.delegate = context.coordinator + textView.string = text + textView.font = .monospacedSystemFont( + ofSize: NSFont.systemFontSize, + weight: .regular + ) + textView.textContainerInset = NSSize(width: 0, height: 8) + textView.textContainer?.lineFragmentPadding = 5 + textView.isRichText = false + textView.allowsUndo = true + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isContinuousSpellCheckingEnabled = false + textView.setAccessibilityLabel("Request Headers") + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.parent = self + + if textView.string != text { + textView.string = text + } + + guard isFocused, + textView.window?.firstResponder !== textView else { + return + } + + DispatchQueue.main.async { + guard isFocused else { + return + } + + textView.window?.makeFirstResponder(textView) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: PaddedTextEditor + + init(_ parent: PaddedTextEditor) { + self.parent = parent + } + + func textDidBeginEditing(_ notification: Notification) { + if parent.isFocused == false { + parent.isFocused = true + } + } + + func textDidEndEditing(_ notification: Notification) { + if parent.isFocused { + parent.isFocused = false + } + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView, + parent.text != textView.string else { + return + } + + parent.text = textView.string + } + } +} From ea7c4b4c76dd20493bd84642ea91ad1aaa3cc918 Mon Sep 17 00:00:00 2001 From: Quinn Gao Date: Tue, 1 Sep 2026 16:42:24 +0800 Subject: [PATCH 2/2] chore: code quality improvements --- Harbor/Models/AddDownloadSheetDraft.swift | 5 +- Harbor/Models/RequestHeader.swift | 15 +----- .../Services/ManagedTorrentSourceStore.swift | 34 +----------- .../TorrentContentsPreviewService.swift | 2 +- Harbor/Services/TorrentSourceLoader.swift | 12 ++--- Harbor/Views/AddDownloadSheet.swift | 49 ++++------------- Harbor/Views/RequestHeadersEditor.swift | 52 ++----------------- 7 files changed, 21 insertions(+), 148 deletions(-) diff --git a/Harbor/Models/AddDownloadSheetDraft.swift b/Harbor/Models/AddDownloadSheetDraft.swift index ebacc82..8ef9add 100644 --- a/Harbor/Models/AddDownloadSheetDraft.swift +++ b/Harbor/Models/AddDownloadSheetDraft.swift @@ -23,7 +23,6 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { let torrentFileURL: URL? let destinationFolderURL: URL let shouldStartImmediately: Bool - let requestHeaders: [RequestHeader] init( id: UUID = UUID(), @@ -31,8 +30,7 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { sourceURLText: String = "", torrentFileURL: URL? = nil, destinationFolderURL: URL, - shouldStartImmediately: Bool, - requestHeaders: [RequestHeader] = [] + shouldStartImmediately: Bool ) { self.id = id self.entryMode = entryMode @@ -40,7 +38,6 @@ struct AddDownloadSheetDraft: Identifiable, Sendable { self.torrentFileURL = torrentFileURL self.destinationFolderURL = destinationFolderURL self.shouldStartImmediately = shouldStartImmediately - self.requestHeaders = requestHeaders } static func blank( diff --git a/Harbor/Models/RequestHeader.swift b/Harbor/Models/RequestHeader.swift index f4ea630..ebe626f 100644 --- a/Harbor/Models/RequestHeader.swift +++ b/Harbor/Models/RequestHeader.swift @@ -1,20 +1,9 @@ import Foundation -nonisolated struct RequestHeader: Codable, Hashable, Identifiable, Sendable { - let id: UUID +nonisolated struct RequestHeader: Codable, Hashable, Sendable { var name: String var value: String - init( - id: UUID = UUID(), - name: String = "", - value: String = "" - ) { - self.id = id - self.name = name - self.value = value - } - var validationIssue: RequestHeaderValidationIssue? { guard name.isEmpty == false else { return .missingName @@ -31,7 +20,7 @@ nonisolated struct RequestHeader: Codable, Hashable, Identifiable, Sendable { return nil } - /// Identifies Cookie and Authorization headers for warnings in RequestHeadersEditor and AddDownloadSheet. + /// Identifies Cookie and Authorization headers that require torrent disclosure. var triggersSensitiveTorrentWarning: Bool { let fieldName = name.trimmingCharacters(in: .whitespacesAndNewlines) return fieldName.caseInsensitiveCompare("Cookie") == .orderedSame diff --git a/Harbor/Services/ManagedTorrentSourceStore.swift b/Harbor/Services/ManagedTorrentSourceStore.swift index 0ef4a13..3406269 100644 --- a/Harbor/Services/ManagedTorrentSourceStore.swift +++ b/Harbor/Services/ManagedTorrentSourceStore.swift @@ -11,8 +11,6 @@ struct ManagedTorrentSource: Equatable, Sendable { enum ManagedTorrentSourceStoreError: LocalizedError { case emptyTorrent case torrentTooLarge - case invalidServerResponse - case unsuccessfulStatusCode(Int) var errorDescription: String? { switch self { @@ -28,21 +26,6 @@ enum ManagedTorrentSourceStoreError: LocalizedError { defaultValue: "The torrent file is too large.", comment: "Error shown when a torrent file exceeds Harbor's safe metadata size limit." ) - case .invalidServerResponse: - String( - localized: "torrent.import.invalidResponse", - defaultValue: "The torrent server returned an invalid response.", - comment: "Error shown when a remote torrent URL does not return an HTTP response." - ) - case let .unsuccessfulStatusCode(statusCode): - String( - format: String( - localized: "torrent.import.httpStatus", - defaultValue: "The torrent server returned HTTP status %lld.", - comment: "Error shown when a remote torrent URL returns a failing HTTP status." - ), - Int64(statusCode) - ) } } } @@ -74,26 +57,11 @@ actor ManagedTorrentSourceStore { return try persist(data: data, originalURL: originalURL ?? sourceURL) } - func fetchRemoteTorrent( - from remoteURL: URL, - using session: URLSession = .shared - ) async throws -> ManagedTorrentSource { - let (temporaryURL, response) = try await session.download(from: remoteURL) - guard let httpResponse = response as? HTTPURLResponse else { - throw ManagedTorrentSourceStoreError.invalidServerResponse - } - guard 200 ..< 300 ~= httpResponse.statusCode else { - throw ManagedTorrentSourceStoreError.unsuccessfulStatusCode(httpResponse.statusCode) - } - let data = try Self.loadTorrentData(at: temporaryURL, fileManager: fileManager) - return try persist(data: data, originalURL: remoteURL) - } - func fetchRemoteTorrent( from remoteURL: URL, requestHeaders: [RequestHeader] ) async throws -> ManagedTorrentSource { - let data = try await TorrentSourceLoader.load( + let data = try await TorrentSourceLoader.fetch( from: remoteURL, requestHeaders: requestHeaders ) diff --git a/Harbor/Services/TorrentContentsPreviewService.swift b/Harbor/Services/TorrentContentsPreviewService.swift index ed92484..54971cf 100644 --- a/Harbor/Services/TorrentContentsPreviewService.swift +++ b/Harbor/Services/TorrentContentsPreviewService.swift @@ -18,7 +18,7 @@ struct TorrentContentsPreviewService: Sendable { if sourceURL.isFileURL { data = try readLocalTorrent(at: sourceURL) } else { - data = try await TorrentSourceLoader.load( + data = try await TorrentSourceLoader.fetch( from: sourceURL, requestHeaders: requestHeaders ) diff --git a/Harbor/Services/TorrentSourceLoader.swift b/Harbor/Services/TorrentSourceLoader.swift index d421899..7ab21cf 100644 --- a/Harbor/Services/TorrentSourceLoader.swift +++ b/Harbor/Services/TorrentSourceLoader.swift @@ -1,15 +1,11 @@ import Foundation nonisolated enum TorrentSourceLoader { - static func load( - from sourceURL: URL, + static func fetch( + from remoteURL: URL, requestHeaders: [RequestHeader] ) async throws -> Data { - if sourceURL.isFileURL { - return try ManagedTorrentSourceStore.loadTorrentData(at: sourceURL) - } - - var request = URLRequest(url: sourceURL) + var request = URLRequest(url: remoteURL) requestHeaders.apply(to: &request) let configuration = URLSessionConfiguration.ephemeral @@ -18,7 +14,7 @@ nonisolated enum TorrentSourceLoader { configuration.timeoutIntervalForResource = 120 let redirectDelegate = TorrentSourceRedirectDelegate( - sourceURL: sourceURL, + sourceURL: remoteURL, requestHeaders: requestHeaders ) let session = URLSession( diff --git a/Harbor/Views/AddDownloadSheet.swift b/Harbor/Views/AddDownloadSheet.swift index 11fb8a3..271293f 100644 --- a/Harbor/Views/AddDownloadSheet.swift +++ b/Harbor/Views/AddDownloadSheet.swift @@ -36,8 +36,7 @@ struct AddDownloadSheet: View { @State private var destinationPath: String @State private var hasCustomizedDestination = false @State private var shouldStartImmediately: Bool - @State private var requestHeaders: [RequestHeader] - @State private var isAdvancedSettingsExpanded = false + @State private var requestHeaders: [RequestHeader] = [] @State private var isRequestHeadersEditorPresented = false @State private var validationMessage: String? @State private var mediaPreview: MediaDownloadMetadata? @@ -49,7 +48,7 @@ struct AddDownloadSheet: View { @State private var mediaPreviewTask: Task? @State private var mediaPreviewGeneration = 0 @State private var torrentPreviewSource: TorrentPreviewSource? - @State private var approvedSensitiveTorrentHeaders: [RequestHeader]? + @State private var hasApprovedSensitiveTorrentHeaders = false @State private var pendingSensitiveTorrentAction: PendingSensitiveTorrentAction? init( @@ -70,7 +69,6 @@ struct AddDownloadSheet: View { _torrentFileURL = State(initialValue: draft.torrentFileURL) _destinationPath = State(initialValue: draft.destinationFolderURL.path) _shouldStartImmediately = State(initialValue: draft.shouldStartImmediately) - _requestHeaders = State(initialValue: draft.requestHeaders) } var body: some View { @@ -224,17 +222,13 @@ struct AddDownloadSheet: View { } .sheet(isPresented: $isRequestHeadersEditorPresented) { RequestHeadersEditor( - requestHeaders: requestHeaders, - sourceUsesAria2: currentSourceUsesAria2 - ) { updatedHeaders, confirmedSensitiveTorrentWarning in + requestHeaders: requestHeaders + ) { updatedHeaders in requestHeaders = updatedHeaders - approvedSensitiveTorrentHeaders = confirmedSensitiveTorrentWarning - ? updatedHeaders - : nil + hasApprovedSensitiveTorrentHeaders = false validationMessage = nil } } - // Fallback if the source becomes an aria2 job after sensitive headers were saved. .alert( "Sensitive headers may be shared", isPresented: Binding( @@ -281,7 +275,7 @@ struct AddDownloadSheet: View { private func presentTorrentPreview(_ source: TorrentPreviewSource) { if requestHeaders.triggersSensitiveTorrentWarning, - approvedSensitiveTorrentHeaders != requestHeaders { + hasApprovedSensitiveTorrentHeaders == false { pendingSensitiveTorrentAction = .preview(source) return } @@ -516,7 +510,7 @@ struct AddDownloadSheet: View { } private var advancedSettingsSection: some View { - DisclosureGroup(isExpanded: $isAdvancedSettingsExpanded) { + DisclosureGroup("Advanced Settings") { LabeledContent("Request Headers") { Button("Configure…") { isRequestHeadersEditorPresented = true @@ -524,17 +518,6 @@ struct AddDownloadSheet: View { } .padding(.top, 10) .padding(.leading, 24) - } label: { - HStack { - Text("Advanced Settings") - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - isAdvancedSettingsExpanded.toggle() - } - } } } @@ -608,20 +591,6 @@ struct AddDownloadSheet: View { return URL(string: trimmedURL) } - private var currentSourceUsesAria2: Bool { - switch entryMode { - case .torrentFile: - return true - case .linkOrMagnet: - let sourceURLs = isBatchEntry - ? parsedBatchURLs - : [parsedLinkURL].compactMap { $0 } - return sourceURLs.contains { - DownloadSourceKind.detect(from: $0)?.usesAria2 == true - } - } - } - private var batchEntries: [DownloadSourceImportService.TextEntry] { DownloadSourceImportService.textEntries(from: sourceURLText) } @@ -910,7 +879,7 @@ struct AddDownloadSheet: View { if requests.contains(where: { $0.sourceKind.usesAria2 && $0.requestHeaders.triggersSensitiveTorrentWarning }), - approvedSensitiveTorrentHeaders != requestHeaders { + hasApprovedSensitiveTorrentHeaders == false { pendingSensitiveTorrentAction = .submit(requests) return } @@ -930,7 +899,7 @@ struct AddDownloadSheet: View { } pendingSensitiveTorrentAction = nil - approvedSensitiveTorrentHeaders = requestHeaders + hasApprovedSensitiveTorrentHeaders = true switch action { case let .preview(source): diff --git a/Harbor/Views/RequestHeadersEditor.swift b/Harbor/Views/RequestHeadersEditor.swift index 7e261af..0f49f9d 100644 --- a/Harbor/Views/RequestHeadersEditor.swift +++ b/Harbor/Views/RequestHeadersEditor.swift @@ -7,23 +7,18 @@ struct RequestHeadersEditor: View { Referer: https://example.com/ """ - let sourceUsesAria2: Bool - let onSave: ([RequestHeader], _ confirmedSensitiveTorrentWarning: Bool) -> Void + let onSave: ([RequestHeader]) -> Void @Environment(\.dismiss) private var dismiss @State private var requestHeadersText: String @State private var isRequestHeadersEditorFocused = false @State private var validationMessage: String? - @State private var pendingSensitiveHeaders: [RequestHeader]? - @State private var isSensitiveHeaderWarningPresented = false init( requestHeaders: [RequestHeader], - sourceUsesAria2: Bool, - onSave: @escaping ([RequestHeader], _ confirmedSensitiveTorrentWarning: Bool) -> Void + onSave: @escaping ([RequestHeader]) -> Void ) { - self.sourceUsesAria2 = sourceUsesAria2 self.onSave = onSave _requestHeadersText = State( initialValue: requestHeaders @@ -96,24 +91,6 @@ struct RequestHeadersEditor: View { .onChange(of: requestHeadersText) { validationMessage = nil } - // See RequestHeader.triggersSensitiveTorrentWarning; warn before saving headers aria2 may forward. - .alert( - "Sensitive headers may be shared", - isPresented: $isSensitiveHeaderWarningPresented - ) { - Button("Cancel", role: .cancel) { - pendingSensitiveHeaders = nil - isRequestHeadersEditorFocused = true - } - - Button("Save Headers") { - savePendingSensitiveHeaders() - } - } message: { - Text( - "The supplied headers contain Cookie or Authorization information. aria2 may send these headers to every HTTP/HTTPS tracker and web seed used by this torrent. Save these headers?" - ) - } } private func save() { @@ -122,30 +99,7 @@ struct RequestHeadersEditor: View { return } - if sourceUsesAria2, - requestHeaders.triggersSensitiveTorrentWarning { - pendingSensitiveHeaders = requestHeaders - isSensitiveHeaderWarningPresented = true - return - } - - commit(requestHeaders, confirmedSensitiveTorrentWarning: false) - } - - private func savePendingSensitiveHeaders() { - guard let requestHeaders = pendingSensitiveHeaders else { - return - } - - pendingSensitiveHeaders = nil - commit(requestHeaders, confirmedSensitiveTorrentWarning: true) - } - - private func commit( - _ requestHeaders: [RequestHeader], - confirmedSensitiveTorrentWarning: Bool - ) { - onSave(requestHeaders, confirmedSensitiveTorrentWarning) + onSave(requestHeaders) dismiss() }