apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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