RadrootsAppleBackgroundURLSession.swift (8737B)
1 import Foundation 2 3 #if os(iOS) 4 5 actor RadrootsAppleBackgroundURLSession { 6 private let identifier: String 7 private let fileResolver: any RadrootsBackgroundTransferFileResolver 8 private let downloadStagingRoot: URL 9 private let coordinator: RadrootsTransferCoordinator 10 private let fileManager: FileManager 11 private let usesForegroundSession: Bool 12 private var session: URLSession? 13 private var sessionDelegate: RadrootsTransferSessionDelegate? 14 private var sessionDelegateQueue: OperationQueue? 15 private let store: any RadrootsBackgroundTransferStore 16 private var admissions: Set<RadrootsBackgroundTransferIdentifier> = [] 17 private var cancelledAdmissions: Set<RadrootsBackgroundTransferIdentifier> = [] 18 19 init( 20 identifier: String, store: any RadrootsBackgroundTransferStore, 21 fileResolver: any RadrootsBackgroundTransferFileResolver, 22 downloadStagingRoot: URL, now: @escaping @Sendable () -> Date, 23 usesForegroundSession: Bool = false 24 ) { 25 self.identifier = identifier 26 self.store = store 27 self.fileResolver = fileResolver 28 self.downloadStagingRoot = downloadStagingRoot 29 fileManager = .default 30 self.usesForegroundSession = usesForegroundSession 31 coordinator = RadrootsTransferCoordinator( 32 sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now 33 ) 34 } 35 36 func enqueue(_ request: RadrootsBackgroundTransferRequest, executionID: UUID) async throws { 37 guard usesForegroundSession, 38 RadrootsAppleBackgroundTransferAdapters.supportsNewEnqueue(for: request.networkPolicy) 39 else { throw RadrootsBackgroundTransferError.unavailable } 40 try RadrootsNativeDestinationPolicy.validate(request.remoteURL, policy: request.networkPolicy) 41 guard admissions.count < 256, admissions.insert(request.identifier).inserted else { 42 throw RadrootsBackgroundTransferError.invalidRequest 43 } 44 defer { 45 admissions.remove(request.identifier) 46 cancelledAdmissions.remove(request.identifier) 47 } 48 if try await alreadyAdmitted(request.identifier, executionID: executionID) { 49 return 50 } 51 guard let snapshot = try await store.loadSnapshots().first(where: { $0.identifier == request.identifier }), 52 snapshot.executionID == executionID, snapshot.state == .queued, 53 try snapshot.request == request.redactedForPersistence(), 54 !cancelledAdmissions.contains(request.identifier) 55 else { throw RadrootsBackgroundTransferError.invalidRequest } 56 let session = backgroundSession() 57 var urlRequest = URLRequest(url: request.remoteURL) 58 urlRequest.httpMethod = request.method.rawValue 59 for (key, value) in request.headers { 60 urlRequest.setValue(value, forHTTPHeaderField: key) 61 } 62 let task: URLSessionTask 63 switch request.operation { 64 case .download: task = session.downloadTask(with: urlRequest) 65 case .upload: 66 let lease = try fileResolver.prepareUploadLease( 67 for: request, 68 executionID: executionID, 69 existing: snapshot.uploadLease 70 ) 71 let leased = try RadrootsBackgroundTransferSnapshot( 72 request: snapshot.request, state: .queued, updatedAt: snapshot.updatedAt, 73 executionID: executionID, uploadLease: lease.blob 74 ) 75 guard try await store.compareExchangeSnapshot(expected: snapshot, desired: leased), 76 !cancelledAdmissions.contains(request.identifier), !Task.isCancelled 77 else { throw RadrootsBackgroundTransferError.transferFailure } 78 task = session.uploadTask(with: urlRequest, fromFile: lease.fileURL) 79 } 80 task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: executionID) 81 .taskDescription 82 sessionDelegate?.registerResponseBodyLimit( 83 request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier 84 ) 85 task.resume() 86 } 87 88 private func alreadyAdmitted(_ identifier: RadrootsBackgroundTransferIdentifier, 89 executionID: UUID) async throws -> Bool 90 { 91 let tasks = try await allTasks() 92 let existingTasks = tasks.filter { 93 RadrootsBackgroundURLTaskDescriptor(taskDescription: $0.taskDescription)?.identifier == identifier 94 } 95 if !existingTasks.isEmpty { 96 guard existingTasks.count == 1, 97 RadrootsBackgroundURLTaskDescriptor(taskDescription: existingTasks[0].taskDescription)? 98 .executionID == executionID 99 else { throw RadrootsBackgroundTransferError.invalidRequest } 100 return true 101 } 102 return false 103 } 104 105 func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { 106 if admissions.contains(identifier) { 107 cancelledAdmissions.insert(identifier) 108 } 109 let tasks = try await allTasks() 110 for task in tasks 111 where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier 112 == identifier 113 { 114 task.cancel() 115 } 116 } 117 118 func activeTransferIdentifiers() async throws -> Set<RadrootsBackgroundTransferIdentifier> { 119 let tasks = try await allTasks() 120 let identifiers = try RadrootsBackgroundTaskQuery.descriptors(tasks.map(\.taskDescription)) 121 .map(\.identifier) 122 return Set(identifiers).union(sessionDelegate?.callbacks.pendingIdentifiers ?? []) 123 } 124 125 func handleBackgroundEvents( 126 identifier: String, completionHandler: @escaping @Sendable () -> Void 127 ) async { 128 _ = backgroundSession() 129 await coordinator.handleBackgroundEvents( 130 identifier: identifier, completionHandler: completionHandler 131 ) 132 } 133 134 private func allTasks() async throws -> [URLSessionTask] { 135 let session = backgroundSession() 136 let tasks = try await RadrootsBackgroundTaskQuery.read { session.getAllTasks(completionHandler: $0) } 137 guard tasks.count <= RadrootsBackgroundTaskQuery.maximumTasks else { 138 throw RadrootsBackgroundTransferError.transferFailure 139 } 140 _ = try RadrootsBackgroundTaskQuery.descriptors(tasks.map(\.taskDescription)) 141 try Task.checkCancellation() 142 return tasks 143 } 144 145 private func backgroundSession() -> URLSession { 146 if let session { 147 return session 148 } 149 removeOrphanedDownloadStagingFiles() 150 let configuration: URLSessionConfiguration 151 if usesForegroundSession { 152 configuration = .ephemeral 153 } else { 154 configuration = URLSessionConfiguration.background(withIdentifier: identifier) 155 configuration.sessionSendsLaunchEvents = true 156 configuration.isDiscretionary = false 157 } 158 let delegateQueue = OperationQueue() 159 delegateQueue.name = "org.radroots.background-transfer.\(identifier)" 160 delegateQueue.maxConcurrentOperationCount = 1 161 let delegate = RadrootsTransferSessionDelegate( 162 coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager 163 ) 164 let session = URLSession( 165 configuration: configuration, delegate: delegate, delegateQueue: delegateQueue 166 ) 167 self.session = session 168 sessionDelegate = delegate 169 sessionDelegateQueue = delegateQueue 170 return session 171 } 172 173 private func removeOrphanedDownloadStagingFiles() { 174 guard 175 let urls = try? fileManager.contentsOfDirectory( 176 at: downloadStagingRoot, includingPropertiesForKeys: nil 177 ) 178 else { return } 179 for url in urls where url.pathExtension == "download" { 180 try? fileManager.removeItem(at: url) 181 } 182 } 183 } 184 185 #endif