apple_kit

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

RadrootsAppleBackgroundTransfer.swift (25833B)


      1 import Foundation
      2 
      3 public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
      4     public let now: @Sendable () -> Date
      5     public let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void
      6     public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void
      7     public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
      8     public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void
      9 
     10     public init(
     11         now: @escaping @Sendable () -> Date = Date.init,
     12         enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void,
     13         cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void,
     14         activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>,
     15         handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void
     16     ) {
     17         self.now = now
     18         self.enqueue = enqueue
     19         self.cancel = cancel
     20         self.activeTransferIdentifiers = activeTransferIdentifiers
     21         self.handleBackgroundEvents = handleBackgroundEvents
     22     }
     23 
     24     public static let unavailable = Self(
     25         enqueue: { _ in
     26             throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
     27         },
     28         cancel: { _ in
     29             throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
     30         },
     31         activeTransferIdentifiers: {
     32             throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
     33         },
     34         handleBackgroundEvents: { _, completionHandler in
     35             completionHandler()
     36         }
     37     )
     38 
     39     public static func live(
     40         sessionIdentifier: String,
     41         store: any RadrootsBackgroundTransferStore,
     42         fileResolver: any RadrootsBackgroundTransferFileResolver,
     43         downloadStagingRoot: URL,
     44         now: @escaping @Sendable () -> Date = Date.init
     45     ) throws -> Self {
     46         #if os(iOS)
     47         let normalizedSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier)
     48         let session = RadrootsAppleBackgroundURLSession(
     49             identifier: normalizedSessionIdentifier,
     50             store: store,
     51             fileResolver: fileResolver,
     52             downloadStagingRoot: downloadStagingRoot,
     53             now: now
     54         )
     55         return Self(
     56             now: now,
     57             enqueue: { request in
     58                 try await session.enqueue(request)
     59             },
     60             cancel: { identifier in
     61                 await session.cancel(identifier)
     62             },
     63             activeTransferIdentifiers: {
     64                 await session.activeTransferIdentifiers()
     65             },
     66             handleBackgroundEvents: { identifier, completionHandler in
     67                 await session.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
     68             }
     69         )
     70         #else
     71         return .unavailable
     72         #endif
     73     }
     74 }
     75 
     76 public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
     77     private let store: any RadrootsBackgroundTransferStore
     78     private let adapters: RadrootsAppleBackgroundTransferAdapters
     79 
     80     public init(
     81         store: any RadrootsBackgroundTransferStore,
     82         adapters: RadrootsAppleBackgroundTransferAdapters
     83     ) {
     84         self.store = store
     85         self.adapters = adapters
     86     }
     87 
     88     public convenience init(
     89         roots: RadrootsAppleFileRoots,
     90         sessionIdentifier: String
     91     ) throws {
     92         let store = RadrootsAppleBackgroundTransferStore(roots: roots)
     93         let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
     94         let downloadStagingRoot = try roots.resolvedURL(
     95             for: RadrootsFileReference(
     96                 scope: .temporary,
     97                 relativePath: "background_transfers/\(try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"
     98             ),
     99             allowRootDirectory: true
    100         )
    101         try self.init(
    102             store: store,
    103             adapters: .live(
    104                 sessionIdentifier: sessionIdentifier,
    105                 store: store,
    106                 fileResolver: resolver,
    107                 downloadStagingRoot: downloadStagingRoot
    108             )
    109         )
    110     }
    111 
    112     public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
    113         try await store.saveSnapshot(
    114             try RadrootsBackgroundTransferSnapshot(
    115                 request: request,
    116                 state: .queued,
    117                 updatedAt: adapters.now()
    118             )
    119         )
    120         do {
    121             try await adapters.enqueue(request)
    122             let current = try await store.loadSnapshots().first { $0.identifier == request.identifier }
    123             if current?.state == .queued {
    124                 try await store.saveSnapshot(
    125                     try RadrootsBackgroundTransferSnapshot(
    126                         request: request,
    127                         state: .running,
    128                         updatedAt: adapters.now()
    129                     )
    130                 )
    131             }
    132             return RadrootsBackgroundTransferHandle(request: request)
    133         } catch {
    134             let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
    135             try await store.saveSnapshot(
    136                 try RadrootsBackgroundTransferSnapshot(
    137                     request: request,
    138                     state: .failed,
    139                     errorMessage: message,
    140                     updatedAt: adapters.now()
    141                 )
    142             )
    143             throw error
    144         }
    145     }
    146 
    147     public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
    148         try await adapters.cancel(identifier)
    149         if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
    150             try await store.saveSnapshot(
    151                 try RadrootsBackgroundTransferSnapshot(
    152                     request: existing.request,
    153                     state: .cancelled,
    154                     progress: existing.progress,
    155                     updatedAt: adapters.now()
    156                 )
    157             )
    158         }
    159     }
    160 
    161     public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
    162         try await snapshots().first { $0.identifier == identifier }
    163     }
    164 
    165     public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
    166         let activeIdentifiers = try await adapters.activeTransferIdentifiers()
    167         let storedSnapshots = try await store.loadSnapshots()
    168         var reconciled: [RadrootsBackgroundTransferSnapshot] = []
    169         for snapshot in storedSnapshots {
    170             if activeIdentifiers.contains(snapshot.identifier), snapshot.state == .queued {
    171                 let runningSnapshot = try RadrootsBackgroundTransferSnapshot(
    172                     request: snapshot.request,
    173                     state: .running,
    174                     progress: snapshot.progress,
    175                     errorMessage: snapshot.errorMessage,
    176                     updatedAt: adapters.now()
    177                 )
    178                 try await store.saveSnapshot(runningSnapshot)
    179                 reconciled.append(runningSnapshot)
    180             } else {
    181                 reconciled.append(snapshot)
    182             }
    183         }
    184         return reconciled.sorted { left, right in
    185             left.identifier < right.identifier
    186         }
    187     }
    188 
    189     public func handleEventsForBackgroundURLSession(
    190         identifier: String,
    191         completionHandler: @escaping @Sendable () -> Void
    192     ) async {
    193         await adapters.handleBackgroundEvents(identifier, completionHandler)
    194     }
    195 }
    196 
    197 enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable {
    198     case file(URL)
    199     case failure(String)
    200 }
    201 
    202 actor RadrootsAppleBackgroundTransferCoordinator {
    203     private let sessionIdentifier: String
    204     private let store: any RadrootsBackgroundTransferStore
    205     private let fileResolver: any RadrootsBackgroundTransferFileResolver
    206     private let now: @Sendable () -> Date
    207     private let fileManager: FileManager
    208     private var completionHandlersByIdentifier: [String: @Sendable () -> Void]
    209 
    210     init(
    211         sessionIdentifier: String,
    212         store: any RadrootsBackgroundTransferStore,
    213         fileResolver: any RadrootsBackgroundTransferFileResolver,
    214         now: @escaping @Sendable () -> Date = Date.init,
    215         fileManager: FileManager = .default
    216     ) {
    217         self.sessionIdentifier = sessionIdentifier
    218         self.store = store
    219         self.fileResolver = fileResolver
    220         self.now = now
    221         self.fileManager = fileManager
    222         self.completionHandlersByIdentifier = [:]
    223     }
    224 
    225     func updateProgress(
    226         identifier: RadrootsBackgroundTransferIdentifier,
    227         bytesTransferred: Int64,
    228         totalBytesExpected: Int64?
    229     ) async {
    230         guard let existing = try? await snapshot(for: identifier), existing.state == .running || existing.state == .queued else {
    231             return
    232         }
    233         guard let progress = Self.progress(
    234             bytesTransferred: bytesTransferred,
    235             totalBytesExpected: totalBytesExpected,
    236             fallback: existing.progress
    237         ) else {
    238             return
    239         }
    240         try? await store.saveSnapshot(
    241             try RadrootsBackgroundTransferSnapshot(
    242                 request: existing.request,
    243                 state: .running,
    244                 progress: progress,
    245                 errorMessage: existing.errorMessage,
    246                 updatedAt: now()
    247             )
    248         )
    249     }
    250 
    251     func complete(
    252         identifier: RadrootsBackgroundTransferIdentifier,
    253         platformError: Error?,
    254         stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
    255         bytesTransferred: Int64,
    256         totalBytesExpected: Int64?
    257     ) async {
    258         guard let existing = try? await snapshot(for: identifier), existing.state != .cancelled else {
    259             return
    260         }
    261         if let platformError {
    262             await fail(existing: existing, message: Self.failureMessage(for: platformError))
    263             return
    264         }
    265         switch existing.request.operation {
    266         case .download(let destination):
    267             await completeDownload(
    268                 existing: existing,
    269                 destination: destination,
    270                 stagedDownloadResult: stagedDownloadResult,
    271                 bytesTransferred: bytesTransferred,
    272                 totalBytesExpected: totalBytesExpected
    273             )
    274         case .upload:
    275             await completeUpload(
    276                 existing: existing,
    277                 bytesTransferred: bytesTransferred,
    278                 totalBytesExpected: totalBytesExpected
    279             )
    280         }
    281     }
    282 
    283     func handleBackgroundEvents(
    284         identifier: String,
    285         completionHandler: @escaping @Sendable () -> Void
    286     ) {
    287         guard identifier == sessionIdentifier else {
    288             completionHandler()
    289             return
    290         }
    291         completionHandlersByIdentifier[identifier] = completionHandler
    292     }
    293 
    294     func finishBackgroundEvents(identifier: String?) {
    295         guard identifier == nil || identifier == sessionIdentifier else {
    296             return
    297         }
    298         completionHandlersByIdentifier.removeValue(forKey: sessionIdentifier)?()
    299     }
    300 
    301     private func completeUpload(
    302         existing: RadrootsBackgroundTransferSnapshot,
    303         bytesTransferred: Int64,
    304         totalBytesExpected: Int64?
    305     ) async {
    306         let progress = Self.progress(
    307             bytesTransferred: bytesTransferred,
    308             totalBytesExpected: totalBytesExpected,
    309             fallback: existing.progress
    310         ) ?? existing.progress
    311         try? await store.saveSnapshot(
    312             try RadrootsBackgroundTransferSnapshot(
    313                 request: existing.request,
    314                 state: .completed,
    315                 progress: progress,
    316                 updatedAt: now()
    317             )
    318         )
    319     }
    320 
    321     private func completeDownload(
    322         existing: RadrootsBackgroundTransferSnapshot,
    323         destination: RadrootsBackgroundTransferLocalFile,
    324         stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
    325         bytesTransferred: Int64,
    326         totalBytesExpected: Int64?
    327     ) async {
    328         guard case .file(let stagedFileURL) = stagedDownloadResult else {
    329             let message: String
    330             if case .failure(let failureMessage) = stagedDownloadResult {
    331                 message = failureMessage
    332             } else {
    333                 message = "background download finished without a staged file"
    334             }
    335             await fail(existing: existing, message: message)
    336             return
    337         }
    338         do {
    339             let destinationURL = try fileResolver.resolve(destination)
    340             try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
    341             if fileManager.fileExists(atPath: destinationURL.path) {
    342                 try fileManager.removeItem(at: destinationURL)
    343             }
    344             try fileManager.moveItem(at: stagedFileURL, to: destinationURL)
    345             let fileSize = try Self.fileSize(at: destinationURL, fileManager: fileManager)
    346             let progress = Self.progress(
    347                 bytesTransferred: max(bytesTransferred, fileSize),
    348                 totalBytesExpected: totalBytesExpected,
    349                 fallback: existing.progress
    350             ) ?? existing.progress
    351             try await store.saveSnapshot(
    352                 try RadrootsBackgroundTransferSnapshot(
    353                     request: existing.request,
    354                     state: .completed,
    355                     progress: progress,
    356                     updatedAt: now()
    357                 )
    358             )
    359         } catch {
    360             await fail(existing: existing, message: Self.failureMessage(for: error))
    361         }
    362     }
    363 
    364     private func fail(existing: RadrootsBackgroundTransferSnapshot, message: String) async {
    365         try? await store.saveSnapshot(
    366             try RadrootsBackgroundTransferSnapshot(
    367                 request: existing.request,
    368                 state: .failed,
    369                 progress: existing.progress,
    370                 errorMessage: Self.sanitizedMessage(message),
    371                 updatedAt: now()
    372             )
    373         )
    374     }
    375 
    376     private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
    377         try await store.loadSnapshots().first { $0.identifier == identifier }
    378     }
    379 
    380     private static func progress(
    381         bytesTransferred: Int64,
    382         totalBytesExpected: Int64?,
    383         fallback: RadrootsBackgroundTransferProgress
    384     ) -> RadrootsBackgroundTransferProgress? {
    385         let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred)
    386         let safeTotalBytesExpected = totalBytesExpected.flatMap { value -> Int64? in
    387             value >= safeBytesTransferred ? value : nil
    388         } ?? fallback.totalBytesExpected.flatMap { value -> Int64? in
    389             value >= safeBytesTransferred ? value : nil
    390         }
    391         return try? RadrootsBackgroundTransferProgress(
    392             bytesTransferred: safeBytesTransferred,
    393             totalBytesExpected: safeTotalBytesExpected
    394         )
    395     }
    396 
    397     private static func fileSize(at url: URL, fileManager: FileManager) throws -> Int64 {
    398         let values = try url.resourceValues(forKeys: [.fileSizeKey])
    399         return Int64(values.fileSize ?? 0)
    400     }
    401 
    402     private static func failureMessage(for error: Error) -> String {
    403         if let localized = error as? LocalizedError,
    404            let description = localized.errorDescription {
    405             return sanitizedMessage(description)
    406         }
    407         return sanitizedMessage(String(describing: error))
    408     }
    409 
    410     private static func sanitizedMessage(_ value: String) -> String {
    411         let scalars = value.unicodeScalars.map { scalar in
    412             CharacterSet.controlCharacters.contains(scalar) ? UnicodeScalar(32)! : scalar
    413         }
    414         let withoutControls = String(String.UnicodeScalarView(scalars))
    415         let trimmed = withoutControls.trimmingCharacters(in: .whitespacesAndNewlines)
    416         guard !trimmed.isEmpty else {
    417             return "background transfer failed"
    418         }
    419         return String(trimmed.prefix(240))
    420     }
    421 }
    422 
    423 #if os(iOS)
    424 private actor RadrootsAppleBackgroundURLSession {
    425     private let identifier: String
    426     private let fileResolver: any RadrootsBackgroundTransferFileResolver
    427     private let downloadStagingRoot: URL
    428     private let coordinator: RadrootsAppleBackgroundTransferCoordinator
    429     private let fileManager: FileManager
    430     private var session: URLSession?
    431     private var sessionDelegate: RadrootsAppleBackgroundURLSessionDelegate?
    432     private var sessionDelegateQueue: OperationQueue?
    433 
    434     init(
    435         identifier: String,
    436         store: any RadrootsBackgroundTransferStore,
    437         fileResolver: any RadrootsBackgroundTransferFileResolver,
    438         downloadStagingRoot: URL,
    439         now: @escaping @Sendable () -> Date
    440     ) {
    441         self.identifier = identifier
    442         self.fileResolver = fileResolver
    443         self.downloadStagingRoot = downloadStagingRoot
    444         self.fileManager = .default
    445         self.coordinator = RadrootsAppleBackgroundTransferCoordinator(
    446             sessionIdentifier: identifier,
    447             store: store,
    448             fileResolver: fileResolver,
    449             now: now
    450         )
    451     }
    452 
    453     func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws {
    454         let session = backgroundSession()
    455         var urlRequest = URLRequest(url: request.remoteURL)
    456         urlRequest.httpMethod = request.method.rawValue
    457         for (key, value) in request.headers {
    458             urlRequest.setValue(value, forHTTPHeaderField: key)
    459         }
    460         let task: URLSessionTask
    461         switch request.operation {
    462         case .download:
    463             task = session.downloadTask(with: urlRequest)
    464         case .upload(let source):
    465             task = try session.uploadTask(with: urlRequest, fromFile: fileResolver.resolve(source))
    466         }
    467         task.taskDescription = request.identifier.rawValue
    468         task.resume()
    469     }
    470 
    471     func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
    472         let tasks = await allTasks()
    473         for task in tasks where task.taskDescription == identifier.rawValue {
    474             task.cancel()
    475         }
    476     }
    477 
    478     func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
    479         let tasks = await allTasks()
    480         let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
    481             guard let taskDescription = task.taskDescription else {
    482                 return nil
    483             }
    484             return try? RadrootsBackgroundTransferIdentifier(taskDescription)
    485         }
    486         return Set(identifiers)
    487     }
    488 
    489     func handleBackgroundEvents(
    490         identifier: String,
    491         completionHandler: @escaping @Sendable () -> Void
    492     ) async {
    493         await coordinator.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
    494     }
    495 
    496     private func allTasks() async -> [URLSessionTask] {
    497         await withCheckedContinuation { continuation in
    498             backgroundSession().getAllTasks { tasks in
    499                 continuation.resume(returning: tasks)
    500             }
    501         }
    502     }
    503 
    504     private func backgroundSession() -> URLSession {
    505         if let session {
    506             return session
    507         }
    508         let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
    509         configuration.sessionSendsLaunchEvents = true
    510         configuration.isDiscretionary = false
    511         let delegateQueue = OperationQueue()
    512         delegateQueue.name = "org.radroots.background-transfer.\(identifier)"
    513         delegateQueue.maxConcurrentOperationCount = 1
    514         let delegate = RadrootsAppleBackgroundURLSessionDelegate(
    515             coordinator: coordinator,
    516             downloadStagingRoot: downloadStagingRoot,
    517             fileManager: fileManager
    518         )
    519         let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue)
    520         self.session = session
    521         self.sessionDelegate = delegate
    522         self.sessionDelegateQueue = delegateQueue
    523         return session
    524     }
    525 }
    526 
    527 private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate, @unchecked Sendable {
    528     private let coordinator: RadrootsAppleBackgroundTransferCoordinator
    529     private let downloadStagingRoot: URL
    530     private let fileManager: FileManager
    531     private let lock = NSLock()
    532     private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult]
    533 
    534     init(
    535         coordinator: RadrootsAppleBackgroundTransferCoordinator,
    536         downloadStagingRoot: URL,
    537         fileManager: FileManager
    538     ) {
    539         self.coordinator = coordinator
    540         self.downloadStagingRoot = downloadStagingRoot
    541         self.fileManager = fileManager
    542         self.stagedDownloadResultsByTaskIdentifier = [:]
    543     }
    544 
    545     func urlSession(
    546         _ session: URLSession,
    547         downloadTask: URLSessionDownloadTask,
    548         didFinishDownloadingTo location: URL
    549     ) {
    550         guard let identifier = transferIdentifier(from: downloadTask) else {
    551             return
    552         }
    553         let result: RadrootsStagedBackgroundDownloadResult
    554         do {
    555             try fileManager.createDirectory(at: downloadStagingRoot, withIntermediateDirectories: true)
    556             let destination = downloadStagingRoot
    557                 .appendingPathComponent("\(identifier.rawValue)-\(downloadTask.taskIdentifier).download")
    558                 .standardizedFileURL
    559             if fileManager.fileExists(atPath: destination.path) {
    560                 try fileManager.removeItem(at: destination)
    561             }
    562             try fileManager.moveItem(at: location, to: destination)
    563             result = .file(destination)
    564         } catch {
    565             result = .failure(Self.failureMessage(for: error))
    566         }
    567         recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier)
    568     }
    569 
    570     func urlSession(
    571         _ session: URLSession,
    572         downloadTask: URLSessionDownloadTask,
    573         didWriteData bytesWritten: Int64,
    574         totalBytesWritten: Int64,
    575         totalBytesExpectedToWrite: Int64
    576     ) {
    577         guard let identifier = transferIdentifier(from: downloadTask) else {
    578             return
    579         }
    580         Task {
    581             await coordinator.updateProgress(
    582                 identifier: identifier,
    583                 bytesTransferred: totalBytesWritten,
    584                 totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite)
    585             )
    586         }
    587     }
    588 
    589     func urlSession(
    590         _ session: URLSession,
    591         task: URLSessionTask,
    592         didSendBodyData bytesSent: Int64,
    593         totalBytesSent: Int64,
    594         totalBytesExpectedToSend: Int64
    595     ) {
    596         guard let identifier = transferIdentifier(from: task) else {
    597             return
    598         }
    599         Task {
    600             await coordinator.updateProgress(
    601                 identifier: identifier,
    602                 bytesTransferred: totalBytesSent,
    603                 totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)
    604             )
    605         }
    606     }
    607 
    608     func urlSession(
    609         _ session: URLSession,
    610         task: URLSessionTask,
    611         didCompleteWithError error: Error?
    612     ) {
    613         guard let identifier = transferIdentifier(from: task) else {
    614             return
    615         }
    616         let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0)
    617         let expected = Self.expectedByteCount(max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend))
    618         let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier)
    619         Task {
    620             await coordinator.complete(
    621                 identifier: identifier,
    622                 platformError: error,
    623                 stagedDownloadResult: stagedDownloadResult,
    624                 bytesTransferred: bytesTransferred,
    625                 totalBytesExpected: expected
    626             )
    627         }
    628     }
    629 
    630     func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    631         Task {
    632             await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier)
    633         }
    634     }
    635 
    636     private func recordDownloadResult(
    637         _ result: RadrootsStagedBackgroundDownloadResult,
    638         taskIdentifier: Int
    639     ) {
    640         lock.lock()
    641         defer { lock.unlock() }
    642         stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result
    643     }
    644 
    645     private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? {
    646         lock.lock()
    647         defer { lock.unlock() }
    648         return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier)
    649     }
    650 
    651     private func transferIdentifier(from task: URLSessionTask) -> RadrootsBackgroundTransferIdentifier? {
    652         guard let taskDescription = task.taskDescription else {
    653             return nil
    654         }
    655         return try? RadrootsBackgroundTransferIdentifier(taskDescription)
    656     }
    657 
    658     private static func expectedByteCount(_ value: Int64) -> Int64? {
    659         value >= 0 ? value : nil
    660     }
    661 
    662     private static func failureMessage(for error: Error) -> String {
    663         if let localized = error as? LocalizedError,
    664            let description = localized.errorDescription {
    665             return description
    666         }
    667         return String(describing: error)
    668     }
    669 }
    670 #endif