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

RadrootsAppleBackgroundTransferCoordinator.swift (17463B)


      1 import Foundation
      2 
      3 actor RadrootsTransferCoordinator {
      4     private let sessionIdentifier: String
      5     private let store: any RadrootsBackgroundTransferStore
      6     private let fileResolver: any RadrootsBackgroundTransferFileResolver
      7     private let now: @Sendable () -> Date
      8     private let fileManager: FileManager
      9     private var completionHandlers: [@Sendable () -> Void]
     10     private var unclaimedFinishedEventCount: Int
     11     private var pendingReceiptCount = 0
     12     private var deferredFinishedEvents = 0
     13 
     14     var hasPendingReceipts: Bool {
     15         pendingReceiptCount > 0
     16     }
     17 
     18     init(
     19         sessionIdentifier: String, store: any RadrootsBackgroundTransferStore,
     20         fileResolver: any RadrootsBackgroundTransferFileResolver,
     21         now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default
     22     ) {
     23         self.sessionIdentifier = sessionIdentifier
     24         self.store = store
     25         self.fileResolver = fileResolver
     26         self.now = now
     27         self.fileManager = fileManager
     28         completionHandlers = []
     29         unclaimedFinishedEventCount = 0
     30     }
     31 
     32     func updateProgress(
     33         identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64,
     34         totalBytesExpected: Int64?, executionID: UUID? = nil
     35     ) async {
     36         guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID,
     37               existing.state == .running || existing.state == .queued
     38         else { return }
     39         guard
     40             let progress = Self.progress(
     41                 bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
     42                 fallback: existing.progress
     43             )
     44         else { return }
     45         _ = try? await store.compareExchangeSnapshot(expected: existing, desired:
     46             try RadrootsBackgroundTransferSnapshot(
     47                 request: existing.request, state: .running, progress: progress,
     48                 failure: existing.failure,
     49                 response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan,
     50                 updatedAt: now(), executionID: existing.executionID, uploadLease: existing.uploadLease
     51             ))
     52     }
     53 
     54     func complete(
     55         identifier: RadrootsBackgroundTransferIdentifier, completion: RadrootsTransferCompletion,
     56         executionID: UUID? = nil
     57     ) async {
     58         pendingReceiptCount += 1
     59         defer { receiptFinished() }
     60         guard let existing = await recoverableSnapshot(for: identifier), existing.executionID == executionID,
     61               [.queued, .running, .interrupted].contains(existing.state)
     62         else { return }
     63         if let failure = Self.completionFailure(request: existing.request, completion: completion) {
     64             Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager)
     65             await fail(existing: existing, code: failure, possibleRemoteOrphan: existing.request.isUpload)
     66             return
     67         }
     68         let response: RadrootsBackgroundTransferResponse
     69         do {
     70             response = try Self.validatedResponse(for: existing.request, httpResult: completion.httpResult)
     71         } catch {
     72             Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager)
     73             await fail(existing: existing,
     74                        code: (error as? RadrootsTransferResponseError)?.failure ?? .responseInvalid,
     75                        possibleRemoteOrphan: existing.request.isUpload)
     76             return
     77         }
     78         switch existing.request.operation {
     79         case let .download(destination):
     80             await completeDownload(
     81                 existing: existing,
     82                 destination: destination,
     83                 response: response,
     84                 completion: completion
     85             )
     86         case .upload:
     87             await completeUpload(existing: existing, response: response, bytesTransferred: completion.bytesTransferred,
     88                                  totalBytesExpected: completion.totalBytesExpected)
     89         }
     90     }
     91 
     92     private static func completionFailure(
     93         request: RadrootsBackgroundTransferRequest, completion: RadrootsTransferCompletion
     94     ) -> RadrootsBackgroundTransferFailure? {
     95         if completion.httpResult.destinationMismatch {
     96             return .responseInvalid
     97         }
     98         if let failure = completion.httpResult.headerFailure {
     99             return failure
    100         }
    101         if let encoding = completion.httpResult.contentEncoding, encoding != "identity" {
    102             return .responseContentEncoding
    103         }
    104         if UInt64(max(completion.bytesTransferred, 0)) > request.maximumTransferBytes
    105             || completion.totalBytesExpected.map({ UInt64(max($0, 0)) > request.maximumTransferBytes }) == true {
    106             return .transferTooLarge
    107         }
    108         if completion.httpResult.bodyExceeded {
    109             return .responseTooLarge
    110         }
    111         if completion.httpResult.mediaTypeWasMalformed {
    112             return .responseMediaType
    113         }
    114         if completion.platformError != nil {
    115             return .platformFailure
    116         }
    117         guard let status = completion.httpResult.statusCode else { return .responseMissing }
    118         return (200 ... 299).contains(status) ? nil : .httpStatus
    119     }
    120 
    121     func releaseUploadLease(executionID: UUID?) {
    122         guard let executionID else { return }
    123         // Only the native terminal callback calls this. Cancellation intent alone
    124         // cannot release bytes still owned by URLSession. Failed cleanup remains
    125         // an owned orphan for later reconciliation.
    126         try? fileResolver.releaseUploadLease(executionID: executionID)
    127     }
    128 
    129     func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) {
    130         guard identifier == sessionIdentifier else {
    131             completionHandler()
    132             return
    133         }
    134         if unclaimedFinishedEventCount > 0 {
    135             unclaimedFinishedEventCount -= 1
    136             completionHandler()
    137             return
    138         }
    139         completionHandlers.append(completionHandler)
    140     }
    141 
    142     func finishBackgroundEvents(identifier: String?) {
    143         guard identifier == nil || identifier == sessionIdentifier else { return }
    144         guard pendingReceiptCount == 0 else {
    145             deferredFinishedEvents = min(deferredFinishedEvents + 1, 8)
    146             return
    147         }
    148         guard !completionHandlers.isEmpty else {
    149             unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8)
    150             return
    151         }
    152         let handlers = completionHandlers
    153         completionHandlers.removeAll()
    154         for handler in handlers {
    155             handler()
    156         }
    157     }
    158 }
    159 
    160 extension RadrootsTransferCoordinator {
    161     private func completeUpload(
    162         existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse,
    163         bytesTransferred: Int64,
    164         totalBytesExpected: Int64?
    165     ) async {
    166         let progress =
    167             Self.progress(
    168                 bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
    169                 fallback: existing.progress
    170             )
    171             ?? existing.progress
    172         do {
    173             let desired = try RadrootsBackgroundTransferSnapshot(
    174                 request: existing.request, state: .awaitingVerification, progress: progress,
    175                 response: response, updatedAt: now(), executionID: existing.executionID,
    176                 uploadLease: existing.uploadLease
    177             )
    178             await persistTerminal(desired)
    179         } catch {
    180             await fail(existing: existing, code: .responseInvalid, possibleRemoteOrphan: true)
    181         }
    182     }
    183 
    184     private func completeDownload(
    185         existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile,
    186         response: RadrootsBackgroundTransferResponse, completion: RadrootsTransferCompletion
    187     ) async {
    188         let stagedDownloadResult = completion.stagedDownloadResult
    189         let mediaType = completion.httpResult.mediaType
    190         let bytesTransferred = completion.bytesTransferred
    191         let totalBytesExpected = completion.totalBytesExpected
    192         guard case let .file(stagedFileURL) = stagedDownloadResult else {
    193             await fail(existing: existing, code: .downloadStagingFailure)
    194             return
    195         }
    196         do {
    197             guard case let .file(destinationReference) = destination else {
    198                 throw RadrootsBackgroundTransferError.invalidRequest
    199             }
    200             let destinationURL = try fileResolver.resolve(destination)
    201             let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager)
    202             guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else {
    203                 throw RadrootsBackgroundTransferError.transferFailure
    204             }
    205             let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact(
    206                 file: destinationReference,
    207                 sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL),
    208                 byteSize: UInt64(fileSize),
    209                 mediaType: mediaType
    210             )
    211             try installDownload(stagedFileURL, at: destinationURL)
    212             let progress =
    213                 Self.progress(
    214                     bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected,
    215                     fallback: existing.progress
    216                 )
    217                 ?? existing.progress
    218             let desired = try RadrootsBackgroundTransferSnapshot(
    219                 request: existing.request, state: .awaitingVerification, progress: progress,
    220                 response: response,
    221                 downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID,
    222                 uploadLease: existing.uploadLease
    223             )
    224             await persistTerminal(desired)
    225         } catch {
    226             Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager)
    227             await fail(existing: existing, code: .destinationFailure)
    228         }
    229     }
    230 
    231     private func installDownload(_ source: URL, at destination: URL) throws {
    232         try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
    233         try Self.moveReplacingItem(from: source, to: destination, fileManager: fileManager)
    234         #if os(iOS)
    235             try fileManager.setAttributes(
    236                 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
    237                 ofItemAtPath: destination.path
    238             )
    239         #endif
    240         try RadrootsAtomicFile.synchronizeExisting(at: destination)
    241     }
    242 
    243     private func fail(
    244         existing: RadrootsBackgroundTransferSnapshot,
    245         code: RadrootsBackgroundTransferFailure,
    246         possibleRemoteOrphan: Bool = false
    247     ) async {
    248         while true {
    249             let observed = now()
    250             let timestamp = observed.timeIntervalSinceReferenceDate.isFinite ? observed : existing.updatedAt
    251             let desired = try? RadrootsBackgroundTransferSnapshot(
    252                 request: existing.request, state: .failed, progress: existing.progress, failure: code,
    253                 possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: timestamp, executionID: existing.executionID,
    254                 uploadLease: existing.uploadLease
    255             )
    256             if let desired {
    257                 await persistTerminal(desired); return
    258             }
    259             await Self.persistenceRetryDelay()
    260         }
    261     }
    262 
    263     private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
    264         -> RadrootsBackgroundTransferSnapshot? {
    265         try await store.loadSnapshots().first { $0.identifier == identifier }
    266     }
    267 
    268     private func recoverableSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async
    269         -> RadrootsBackgroundTransferSnapshot? {
    270         while true {
    271             do {
    272                 return try await snapshot(for: identifier)
    273             } catch {
    274                 await Self.persistenceRetryDelay()
    275             }
    276         }
    277     }
    278 
    279     /// Progress and reconciliation may replace a snapshot during an await. Retry
    280     /// against the current exact value without changing the receipt's attempt.
    281     /// Store failure retains the receipt and blocks finished-event acknowledgement.
    282     private func persistTerminal(_ desired: RadrootsBackgroundTransferSnapshot) async {
    283         while true {
    284             do {
    285                 guard let current = try await snapshot(for: desired.identifier),
    286                       current.executionID == desired.executionID, current.request == desired.request,
    287                       [.queued, .running, .interrupted].contains(current.state)
    288                 else { return }
    289                 if try await store.compareExchangeSnapshot(expected: current, desired: desired) {
    290                     return
    291                 }
    292             } catch { /* Retain receipt ownership until storage becomes available. */ }
    293             await Self.persistenceRetryDelay()
    294         }
    295     }
    296 
    297     private static func persistenceRetryDelay() async {
    298         // The OS acknowledgement barrier must survive caller cancellation. A
    299         // separately owned delay avoids a cancelled task spinning on sleep.
    300         await Task.detached { try? await Task.sleep(for: .milliseconds(100)) }.value
    301     }
    302 
    303     private func receiptFinished() {
    304         pendingReceiptCount -= 1
    305         guard pendingReceiptCount == 0, deferredFinishedEvents > 0 else { return }
    306         let events = deferredFinishedEvents
    307         deferredFinishedEvents = 0
    308         for _ in 0 ..< events {
    309             finishBackgroundEvents(identifier: sessionIdentifier)
    310         }
    311     }
    312 
    313     private static func progress(
    314         bytesTransferred: Int64, totalBytesExpected: Int64?,
    315         fallback: RadrootsBackgroundTransferProgress
    316     )
    317         -> RadrootsBackgroundTransferProgress? {
    318         let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred)
    319         let safeTotalBytesExpected =
    320             totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil }
    321                 ?? fallback.totalBytesExpected.flatMap { value -> Int64? in
    322                     value >= safeBytesTransferred ? value : nil
    323                 }
    324         return try? RadrootsBackgroundTransferProgress(
    325             bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected
    326         )
    327     }
    328 
    329     private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 {
    330         let values = try url.resourceValues(forKeys: [.fileSizeKey])
    331         return Int64(values.fileSize ?? 0)
    332     }
    333 
    334     private static func moveReplacingItem(
    335         from source: URL, to destination: URL, fileManager: FileManager
    336     ) throws {
    337         guard fileManager.fileExists(atPath: destination.path) else {
    338             try fileManager.moveItem(at: source, to: destination)
    339             return
    340         }
    341         let backup = destination.deletingLastPathComponent().appendingPathComponent(
    342             ".radroots-transfer-backup-\(UUID().uuidString.lowercased())"
    343         )
    344         try fileManager.moveItem(at: destination, to: backup)
    345         do {
    346             try fileManager.moveItem(at: source, to: destination)
    347             try fileManager.removeItem(at: backup)
    348         } catch {
    349             if fileManager.fileExists(atPath: destination.path) {
    350                 try? fileManager.removeItem(at: destination)
    351             }
    352             if fileManager.fileExists(atPath: backup.path) {
    353                 try? fileManager.moveItem(at: backup, to: destination)
    354             }
    355             throw error
    356         }
    357     }
    358 
    359     private static func validatedResponse(
    360         for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult
    361     ) throws
    362         -> RadrootsBackgroundTransferResponse {
    363         guard let statusCode = httpResult.statusCode else {
    364             throw RadrootsTransferResponseError(failure: .responseMissing)
    365         }
    366         if request.responsePolicy == .discard {
    367             return try RadrootsBackgroundTransferResponse(
    368                 statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil
    369             )
    370         }
    371         guard let body = httpResult.body else {
    372             throw RadrootsTransferResponseError(failure: .responseMissing)
    373         }
    374         guard let mediaType = httpResult.mediaType,
    375               request.responsePolicy.acceptedMediaTypes.contains(mediaType)
    376         else {
    377             throw RadrootsTransferResponseError(failure: .responseMediaType)
    378         }
    379         guard body.count <= request.responsePolicy.maximumBodyBytes else {
    380             throw RadrootsTransferResponseError(failure: .responseTooLarge)
    381         }
    382         guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else {
    383             throw RadrootsTransferResponseError(failure: .responseContentEncoding)
    384         }
    385         return try RadrootsBackgroundTransferResponse(
    386             statusCode: statusCode, mediaType: mediaType,
    387             contentEncoding: httpResult.contentEncoding, body: body
    388         )
    389     }
    390 
    391     private static func removeStagedDownload(
    392         _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager
    393     ) {
    394         guard case let .file(url) = result, fileManager.fileExists(atPath: url.path) else { return }
    395         try? fileManager.removeItem(at: url)
    396     }
    397 }