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

RadrootsAppleBackgroundTransferStore.swift (13966B)


      1 import Darwin
      2 import Foundation
      3 
      4 public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore {
      5     private static let maximumPersistenceBytes = 1024 * 1024
      6 
      7     private struct Envelope: Codable {
      8         let schemaVersion: Int
      9         let snapshots: [RadrootsBackgroundTransferSnapshot]
     10 
     11         init(snapshots: [RadrootsBackgroundTransferSnapshot]) {
     12             schemaVersion = 1
     13             self.snapshots = snapshots
     14         }
     15     }
     16 
     17     private let roots: RadrootsAppleFileRoots
     18     private let fileManager: FileManager
     19     private let encoder: JSONEncoder
     20     private let decoder: JSONDecoder
     21     private let protectedData: RadrootsProtectedDataProvider
     22     private let persistence: RadrootsFilePersistence
     23     private var admissionScan: RadrootsAdmissionFileScan?
     24 
     25     private struct Admission {
     26         let descriptor: Int32
     27         let url: URL
     28     }
     29 
     30     public init(
     31         roots: RadrootsAppleFileRoots, fileManager: FileManager = .default,
     32         protectedData: RadrootsProtectedDataProvider = .available
     33     ) {
     34         self.roots = roots
     35         self.fileManager = fileManager
     36         encoder = JSONEncoder()
     37         decoder = JSONDecoder()
     38         self.protectedData = protectedData
     39         persistence = .live
     40         encoder.outputFormatting = [.sortedKeys]
     41     }
     42 
     43     init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) {
     44         self.roots = roots
     45         fileManager = .default
     46         encoder = JSONEncoder()
     47         decoder = JSONDecoder()
     48         protectedData = .available
     49         self.persistence = persistence
     50         encoder.outputFormatting = [.sortedKeys]
     51     }
     52 
     53     public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
     54         try withStoreLock { try loadSnapshotsSynchronously() }
     55     }
     56 
     57     public func withAdmission<Result: Sendable>(
     58         for identifier: RadrootsBackgroundTransferIdentifier,
     59         operation: @escaping @Sendable () async throws -> Result
     60     ) async throws -> Result {
     61         guard let admission = try await acquireAdmission(identifier) else {
     62             throw RadrootsBackgroundTransferError.invalidRequest
     63         }
     64         // Only this attempt owns the descriptor across awaits. Persistence uses a
     65         // separate short lock; neither reservation waits for another owner.
     66         defer { retireAdmission(admission) }
     67         return try await operation()
     68     }
     69 
     70     public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool {
     71         guard let admission = try await acquireAdmission(identifier) else { return true }
     72         retireAdmission(admission)
     73         return false
     74     }
     75 
     76     private func acquireAdmission(_ identifier: RadrootsBackgroundTransferIdentifier) async throws -> Admission? {
     77         try requireProtectedData()
     78         let coordination = try await acquireAdmissionCoordination()
     79         defer { Darwin.close(coordination) }
     80         do {
     81             let name = RadrootsAppleFileDigest.sha256(Data(identifier.rawValue.utf8))
     82             let url = try roots.resolvedURL(for: RadrootsFileReference(
     83                 scope: .data, relativePath: "background_transfers/admissions/\(name).lock"
     84             ))
     85             guard let descriptor = try RadrootsAtomicFile.acquireExclusiveLock(at: url) else { return nil }
     86             return Admission(descriptor: descriptor, url: url)
     87         } catch RadrootsAppleFileError.transientFailure {
     88             throw RadrootsBackgroundTransferError.unavailable
     89         } catch {
     90             throw RadrootsBackgroundTransferError.persistence(error)
     91         }
     92     }
     93 
     94     private func admissionCoordinationURL() throws -> URL {
     95         try roots.resolvedURL(for: RadrootsFileReference(
     96             scope: .data, relativePath: "background_transfers/admissions/.coordination.lock"
     97         ))
     98     }
     99 
    100     private func acquireAdmissionCoordination() async throws -> Int32 {
    101         for _ in 0 ..< 16 {
    102             try Task.checkCancellation()
    103             try requireProtectedData()
    104             do {
    105                 if let descriptor = try RadrootsAtomicFile.acquireExclusiveLock(at: admissionCoordinationURL()) {
    106                     return descriptor
    107                 }
    108             } catch RadrootsAppleFileError.transientFailure {
    109                 // The leaf was not created within its bounded retry budget.
    110             } catch { throw RadrootsBackgroundTransferError.persistence(error) }
    111             await Task.yield()
    112         }
    113         throw RadrootsBackgroundTransferError.unavailable
    114     }
    115 
    116     private func retireAdmission(_ admission: Admission) {
    117         do {
    118             try requireProtectedData()
    119             guard let coordination = try RadrootsAtomicFile.acquireExclusiveLock(at: admissionCoordinationURL()) else {
    120                 Darwin.close(admission.descriptor)
    121                 return
    122             }
    123             // Close the retired inode before releasing the acquisition gate.
    124             defer { Darwin.close(admission.descriptor); Darwin.close(coordination) }
    125             _ = try? RadrootsAtomicFile.removeEmptyLockedFile(at: admission.url, descriptor: admission.descriptor)
    126         } catch {
    127             // Cleanup failure never rewrites the operation result. A later
    128             // explicit pass may reconcile the retained inactive inode.
    129             Darwin.close(admission.descriptor)
    130         }
    131     }
    132 
    133     /// Explicit metadata housekeeping. Each call visits at most 64 directory
    134     /// entries and continues this store's pass. A new pass starts after its end;
    135     /// restarting the owner starts over. Snapshots and upload leases are untouched.
    136     public func collectInactiveAdmissions(limit: Int = 64) async throws -> RadrootsAdmissionCleanupResult {
    137         guard (1 ... 64).contains(limit) else { throw RadrootsBackgroundTransferError.invalidRequest }
    138         let coordination = try await acquireAdmissionCoordination()
    139         defer { Darwin.close(coordination) }
    140         do {
    141             let directory = try admissionCoordinationURL().deletingLastPathComponent()
    142             if admissionScan == nil {
    143                 admissionScan = try RadrootsAdmissionFileScan(url: directory)
    144             }
    145             guard let admissionScan else { throw RadrootsBackgroundTransferError.persistenceFailure }
    146             let batch = try admissionScan.next(limit: limit)
    147             var removed = 0
    148             for name in batch.names where Self.isAdmissionFilename(name) {
    149                 if try admissionScan.removeInactive(name: name, coordination: coordination) {
    150                     removed += 1
    151                 }
    152             }
    153             if batch.reachedEnd {
    154                 self.admissionScan = nil
    155             }
    156             return RadrootsAdmissionCleanupResult(scannedEntries: batch.scanned, removedFiles: removed, reachedEnd: batch.reachedEnd)
    157         } catch {
    158             admissionScan = nil
    159             throw RadrootsBackgroundTransferError.persistence(error)
    160         }
    161     }
    162 
    163     private static func isAdmissionFilename(_ name: String) -> Bool {
    164         name.utf8.count == 69 && name.hasSuffix(".lock")
    165             && name.utf8.prefix(64).allSatisfy { (48 ... 57).contains($0) || (97 ... 102).contains($0) }
    166     }
    167 
    168     public func compareExchangeSnapshot(
    169         expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot
    170     ) async throws -> Bool {
    171         try withStoreLock {
    172             guard expected == nil || expected?.identifier == desired.identifier else {
    173                 throw RadrootsBackgroundTransferError.invalidRequest
    174             }
    175             var snapshots = try loadSnapshotsSynchronously()
    176             let current = snapshots.first { $0.identifier == desired.identifier }
    177             guard try current == (expected?.redactedForPersistence()) else { return false }
    178             snapshots.removeAll { $0.identifier == desired.identifier }
    179             try snapshots.append(desired.redactedForPersistence())
    180             try write(snapshots.sorted { $0.identifier < $1.identifier })
    181             return true
    182         }
    183     }
    184 
    185     private func withStoreLock<T>(_ body: () throws -> T) throws -> T {
    186         try requireProtectedData()
    187         do {
    188             let lockURL = try roots.resolvedURL(
    189                 for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.lock")
    190             )
    191             return try RadrootsAtomicFile.withExclusiveLock(at: lockURL, body)
    192         } catch let error as RadrootsBackgroundTransferError {
    193             throw error
    194         } catch {
    195             throw RadrootsBackgroundTransferError.persistence(error)
    196         }
    197     }
    198 
    199     private func loadSnapshotsSynchronously() throws -> [RadrootsBackgroundTransferSnapshot] {
    200         try requireProtectedData()
    201         do {
    202             let url = try storeURL()
    203             let legacyURL = try legacyStoreURL()
    204             let isLegacy: Bool
    205             if fileManager.fileExists(atPath: url.path) {
    206                 isLegacy = false
    207             } else if fileManager.fileExists(atPath: legacyURL.path) {
    208                 isLegacy = true
    209             } else {
    210                 return []
    211             }
    212             let data = try RadrootsGovernedFileReader.read(
    213                 root: roots.root(for: isLegacy ? .cache : .data),
    214                 relativePath: "background_transfers/transfers.json",
    215                 maximumBytes: Self.maximumPersistenceBytes
    216             )
    217             let decoded: [RadrootsBackgroundTransferSnapshot]
    218             let usedLegacyEncoding: Bool
    219             if let envelope = try? decoder.decode(Envelope.self, from: data) {
    220                 guard envelope.schemaVersion == 1 else {
    221                     throw RadrootsBackgroundTransferError.persistenceFailure
    222                 }
    223                 decoded = envelope.snapshots
    224                 usedLegacyEncoding = false
    225             } else {
    226                 decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data)
    227                 usedLegacyEncoding = true
    228             }
    229             let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in
    230                 left.identifier < right.identifier
    231             }
    232             if isLegacy || usedLegacyEncoding {
    233                 try write(snapshots)
    234             }
    235             if fileManager.fileExists(atPath: legacyURL.path) {
    236                 try fileManager.removeItem(at: legacyURL)
    237             }
    238             return snapshots
    239         } catch {
    240             throw RadrootsBackgroundTransferError.persistence(error)
    241         }
    242     }
    243 
    244     public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
    245         try withStoreLock { try saveSnapshotSynchronously(snapshot) }
    246     }
    247 
    248     private func saveSnapshotSynchronously(_ snapshot: RadrootsBackgroundTransferSnapshot) throws {
    249         try requireProtectedData()
    250         do {
    251             var snapshots = try loadSnapshotsSynchronously()
    252             snapshots.removeAll { $0.identifier == snapshot.identifier }
    253             try snapshots.append(snapshot.redactedForPersistence())
    254             try write(snapshots.sorted { left, right in left.identifier < right.identifier })
    255         } catch let error as RadrootsBackgroundTransferError {
    256             throw error
    257         } catch {
    258             throw RadrootsBackgroundTransferError.persistence(error)
    259         }
    260     }
    261 
    262     public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
    263         try withStoreLock { try removeSnapshotSynchronously(for: identifier) }
    264     }
    265 
    266     private func removeSnapshotSynchronously(for identifier: RadrootsBackgroundTransferIdentifier) throws {
    267         try requireProtectedData()
    268         do {
    269             var snapshots = try loadSnapshotsSynchronously()
    270             snapshots.removeAll { $0.identifier == identifier }
    271             try write(snapshots)
    272         } catch let error as RadrootsBackgroundTransferError {
    273             throw error
    274         } catch {
    275             throw RadrootsBackgroundTransferError.persistence(error)
    276         }
    277     }
    278 
    279     public func removeAllSnapshots() async throws {
    280         try withStoreLock { try removeAllSnapshotsSynchronously() }
    281     }
    282 
    283     private func removeAllSnapshotsSynchronously() throws {
    284         try requireProtectedData()
    285         do {
    286             for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) {
    287                 try fileManager.removeItem(at: url)
    288             }
    289         } catch let error as RadrootsBackgroundTransferError {
    290             throw error
    291         } catch {
    292             throw RadrootsBackgroundTransferError.persistence(error)
    293         }
    294     }
    295 
    296     private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws {
    297         let url = try storeURL()
    298         try fileManager.createDirectory(
    299             at: url.deletingLastPathComponent(), withIntermediateDirectories: true
    300         )
    301         let data = try encoder.encode(Envelope(snapshots: snapshots))
    302         guard data.count <= Self.maximumPersistenceBytes else {
    303             throw RadrootsBackgroundTransferError.receiptCapacityExceeded
    304         }
    305         try persistence.install(data, url, .replace, false)
    306         #if os(iOS)
    307             try fileManager.setAttributes(
    308                 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
    309                 ofItemAtPath: url.path
    310             )
    311         #endif
    312     }
    313 
    314     private func storeURL() throws -> URL {
    315         try roots.resolvedURL(
    316             for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json")
    317         )
    318     }
    319 
    320     private func legacyStoreURL() throws -> URL {
    321         try roots.resolvedURL(
    322             for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json")
    323         )
    324     }
    325 
    326     private func requireProtectedData() throws {
    327         guard protectedData.currentState() == .available else {
    328             throw RadrootsBackgroundTransferError.persistenceFailure
    329         }
    330     }
    331 }