apple_kit

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

RadrootsBackgroundTransfer.swift (16576B)


      1 import Foundation
      2 
      3 public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable {
      4     case invalidRequest(String)
      5     case unavailable(String)
      6     case transferFailure(String)
      7     case persistenceFailure(String)
      8 }
      9 
     10 extension RadrootsBackgroundTransferError: LocalizedError {
     11     public var errorDescription: String? {
     12         switch self {
     13         case .invalidRequest(let message):
     14             message
     15         case .unavailable(let message):
     16             message
     17         case .transferFailure(let message):
     18             message
     19         case .persistenceFailure(let message):
     20             message
     21         }
     22     }
     23 }
     24 
     25 public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable {
     26     public let rawValue: String
     27 
     28     public init(_ value: String) throws {
     29         self.rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value)
     30     }
     31 
     32     public static func generated() -> Self {
     33         Self(validatedRawValue: UUID().uuidString.lowercased())
     34     }
     35 
     36     public static func < (lhs: Self, rhs: Self) -> Bool {
     37         lhs.rawValue < rhs.rawValue
     38     }
     39 
     40     private init(validatedRawValue: String) {
     41         self.rawValue = validatedRawValue
     42     }
     43 }
     44 
     45 public enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable, CaseIterable {
     46     case get = "GET"
     47     case post = "POST"
     48     case put = "PUT"
     49 }
     50 
     51 public enum RadrootsBackgroundTransferLocalFile: Sendable, Equatable, Hashable, Codable {
     52     case file(RadrootsFileReference)
     53     case stagedBlob(RadrootsStagedBlobReference)
     54 }
     55 
     56 public enum RadrootsBackgroundTransferOperation: Sendable, Equatable, Hashable, Codable {
     57     case download(destination: RadrootsBackgroundTransferLocalFile)
     58     case upload(source: RadrootsBackgroundTransferLocalFile)
     59 }
     60 
     61 public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashable, Codable, CaseIterable {
     62     case queued
     63     case running
     64     case completed
     65     case failed
     66     case cancelled
     67 }
     68 
     69 public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable {
     70     public let identifier: RadrootsBackgroundTransferIdentifier
     71     public let remoteURL: URL
     72     public let method: RadrootsBackgroundTransferMethod
     73     public let operation: RadrootsBackgroundTransferOperation
     74     public let headers: [String: String]
     75     public let metadata: [String: String]
     76 
     77     public init(
     78         identifier: RadrootsBackgroundTransferIdentifier = .generated(),
     79         remoteURL: URL,
     80         method: RadrootsBackgroundTransferMethod,
     81         operation: RadrootsBackgroundTransferOperation,
     82         headers: [String: String] = [:],
     83         metadata: [String: String] = [:]
     84     ) throws {
     85         try RadrootsBackgroundTransferValidation.validate(
     86             remoteURL: remoteURL,
     87             method: method,
     88             operation: operation,
     89             headers: headers,
     90             metadata: metadata
     91         )
     92         self.identifier = identifier
     93         self.remoteURL = remoteURL
     94         self.method = method
     95         self.operation = operation
     96         self.headers = headers
     97         self.metadata = metadata
     98     }
     99 }
    100 
    101 public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable {
    102     public let identifier: RadrootsBackgroundTransferIdentifier
    103     public let request: RadrootsBackgroundTransferRequest
    104 
    105     public init(request: RadrootsBackgroundTransferRequest) {
    106         self.identifier = request.identifier
    107         self.request = request
    108     }
    109 }
    110 
    111 public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable {
    112     public let bytesTransferred: Int64
    113     public let totalBytesExpected: Int64?
    114 
    115     public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws {
    116         guard bytesTransferred >= 0 else {
    117             throw RadrootsBackgroundTransferError.invalidRequest("background transfer bytes transferred cannot be negative")
    118         }
    119         if let totalBytesExpected {
    120             guard totalBytesExpected >= 0 else {
    121                 throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be negative")
    122             }
    123             guard totalBytesExpected >= bytesTransferred else {
    124                 throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be less than transferred bytes")
    125             }
    126         }
    127         self.bytesTransferred = bytesTransferred
    128         self.totalBytesExpected = totalBytesExpected
    129     }
    130 
    131     public static let zero = RadrootsBackgroundTransferProgress(validatedBytesTransferred: 0, totalBytesExpected: nil)
    132 
    133     private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) {
    134         self.bytesTransferred = validatedBytesTransferred
    135         self.totalBytesExpected = totalBytesExpected
    136     }
    137 }
    138 
    139 public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable {
    140     public let identifier: RadrootsBackgroundTransferIdentifier
    141     public let request: RadrootsBackgroundTransferRequest
    142     public let state: RadrootsBackgroundTransferState
    143     public let progress: RadrootsBackgroundTransferProgress
    144     public let errorMessage: String?
    145     public let updatedAt: Date
    146 
    147     public init(
    148         request: RadrootsBackgroundTransferRequest,
    149         state: RadrootsBackgroundTransferState = .queued,
    150         progress: RadrootsBackgroundTransferProgress = .zero,
    151         errorMessage: String? = nil,
    152         updatedAt: Date = Date()
    153     ) throws {
    154         guard updatedAt.timeIntervalSinceReferenceDate.isFinite else {
    155             throw RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")
    156         }
    157         self.identifier = request.identifier
    158         self.request = request
    159         self.state = state
    160         self.progress = progress
    161         self.errorMessage = try RadrootsBackgroundTransferValidation.normalizedOptionalMessage(errorMessage)
    162         self.updatedAt = updatedAt
    163     }
    164 }
    165 
    166 public protocol RadrootsBackgroundTransferStore: Sendable {
    167     func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
    168     func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws
    169     func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
    170     func removeAllSnapshots() async throws
    171 }
    172 
    173 public protocol RadrootsBackgroundTransfer: Sendable {
    174     func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle
    175     func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
    176     func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
    177     func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
    178     func handleEventsForBackgroundURLSession(
    179         identifier: String,
    180         completionHandler: @escaping @Sendable () -> Void
    181     ) async
    182 }
    183 
    184 public protocol RadrootsBackgroundTransferFileResolver: Sendable {
    185     func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
    186 }
    187 
    188 public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, Sendable {
    189     private let roots: RadrootsAppleFileRoots
    190 
    191     public init(roots: RadrootsAppleFileRoots) {
    192         self.roots = roots
    193     }
    194 
    195     public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL {
    196         switch file {
    197         case .file(let reference):
    198             try roots.resolvedURL(for: reference)
    199         case .stagedBlob(let blob):
    200             try roots.stagedBlobURL(for: blob)
    201         }
    202     }
    203 }
    204 
    205 public final class RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore, @unchecked Sendable {
    206     private let roots: RadrootsAppleFileRoots
    207     private let fileManager: FileManager
    208     private let encoder: JSONEncoder
    209     private let decoder: JSONDecoder
    210 
    211     public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
    212         self.roots = roots
    213         self.fileManager = fileManager
    214         self.encoder = JSONEncoder()
    215         self.decoder = JSONDecoder()
    216         self.encoder.outputFormatting = [.sortedKeys]
    217     }
    218 
    219     public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
    220         let url = try storeURL()
    221         guard fileManager.fileExists(atPath: url.path) else {
    222             return []
    223         }
    224         let data = try Data(contentsOf: url)
    225         return try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data)
    226             .sorted { left, right in
    227                 left.identifier < right.identifier
    228             }
    229     }
    230 
    231     public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
    232         var snapshots = try await loadSnapshots()
    233         snapshots.removeAll { $0.identifier == snapshot.identifier }
    234         snapshots.append(snapshot)
    235         try write(snapshots.sorted { left, right in
    236             left.identifier < right.identifier
    237         })
    238     }
    239 
    240     public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
    241         var snapshots = try await loadSnapshots()
    242         snapshots.removeAll { $0.identifier == identifier }
    243         try write(snapshots)
    244     }
    245 
    246     public func removeAllSnapshots() async throws {
    247         let url = try storeURL()
    248         guard fileManager.fileExists(atPath: url.path) else {
    249             return
    250         }
    251         try fileManager.removeItem(at: url)
    252     }
    253 
    254     private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws {
    255         let url = try storeURL()
    256         try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
    257         let data = try encoder.encode(snapshots)
    258         try data.write(to: url, options: [.atomic])
    259     }
    260 
    261     private func storeURL() throws -> URL {
    262         try roots.resolvedURL(
    263             for: RadrootsFileReference(
    264                 scope: .cache,
    265                 relativePath: "background_transfers/transfers.json"
    266             )
    267         )
    268     }
    269 }
    270 
    271 public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
    272     private let reason: String
    273 
    274     public init(reason: String = "background transfer is unavailable on this platform") {
    275         let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
    276         self.reason = trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason
    277     }
    278 
    279     public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
    280         throw RadrootsBackgroundTransferError.unavailable(reason)
    281     }
    282 
    283     public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
    284         throw RadrootsBackgroundTransferError.unavailable(reason)
    285     }
    286 
    287     public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
    288         throw RadrootsBackgroundTransferError.unavailable(reason)
    289     }
    290 
    291     public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
    292         throw RadrootsBackgroundTransferError.unavailable(reason)
    293     }
    294 
    295     public func handleEventsForBackgroundURLSession(
    296         identifier: String,
    297         completionHandler: @escaping @Sendable () -> Void
    298     ) async {
    299         completionHandler()
    300     }
    301 }
    302 
    303 public enum RadrootsBackgroundTransferValidation {
    304     public static func normalizedIdentifier(_ value: String) throws -> String {
    305         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    306         guard !trimmed.isEmpty else {
    307             throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")
    308         }
    309         guard trimmed.count <= 128 else {
    310             throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier is too long")
    311         }
    312         guard trimmed.range(
    313             of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$",
    314             options: .regularExpression
    315         ) != nil else {
    316             throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters")
    317         }
    318         guard !trimmed.contains("..") else {
    319             throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")
    320         }
    321         return trimmed
    322     }
    323 
    324     public static func validate(
    325         remoteURL: URL,
    326         method: RadrootsBackgroundTransferMethod,
    327         operation: RadrootsBackgroundTransferOperation,
    328         headers: [String: String],
    329         metadata: [String: String]
    330     ) throws {
    331         try validate(remoteURL: remoteURL)
    332         try validate(method: method, operation: operation)
    333         try validate(headers: headers)
    334         try validate(metadata: metadata)
    335     }
    336 
    337     public static func normalizedOptionalMessage(_ value: String?) throws -> String? {
    338         guard let value else {
    339             return nil
    340         }
    341         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    342         guard !trimmed.isEmpty else {
    343             return nil
    344         }
    345         guard trimmed.count <= 240 else {
    346             throw RadrootsBackgroundTransferError.invalidRequest("background transfer message is too long")
    347         }
    348         guard doesNotContainControlCharacters(trimmed) else {
    349             throw RadrootsBackgroundTransferError.invalidRequest("background transfer message cannot contain control characters")
    350         }
    351         return trimmed
    352     }
    353 
    354     private static func validate(remoteURL: URL) throws {
    355         guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false),
    356               components.scheme?.lowercased() == "https",
    357               components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
    358               components.user == nil,
    359               components.password == nil else {
    360             throw RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials")
    361         }
    362     }
    363 
    364     private static func validate(
    365         method: RadrootsBackgroundTransferMethod,
    366         operation: RadrootsBackgroundTransferOperation
    367     ) throws {
    368         switch operation {
    369         case .download:
    370             guard method == .get else {
    371                 throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")
    372             }
    373         case .upload:
    374             guard method == .post || method == .put else {
    375                 throw RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")
    376             }
    377         }
    378     }
    379 
    380     private static func validate(headers: [String: String]) throws {
    381         guard headers.count <= 32 else {
    382             throw RadrootsBackgroundTransferError.invalidRequest("background transfer header count is too large")
    383         }
    384         for (key, value) in headers {
    385             try validateSafeText(key, field: "background transfer header name", maximumLength: 80)
    386             try validateSafeText(value, field: "background transfer header value", maximumLength: 500)
    387         }
    388     }
    389 
    390     private static func validate(metadata: [String: String]) throws {
    391         guard metadata.count <= 32 else {
    392             throw RadrootsBackgroundTransferError.invalidRequest("background transfer metadata count is too large")
    393         }
    394         for (key, value) in metadata {
    395             try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80)
    396             try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500)
    397         }
    398     }
    399 
    400     private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws {
    401         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    402         guard !trimmed.isEmpty else {
    403             throw RadrootsBackgroundTransferError.invalidRequest("\(field) must not be empty")
    404         }
    405         guard trimmed.count <= maximumLength else {
    406             throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long")
    407         }
    408         guard doesNotContainControlCharacters(trimmed) else {
    409             throw RadrootsBackgroundTransferError.invalidRequest("\(field) cannot contain control characters")
    410         }
    411     }
    412 
    413     private static func doesNotContainControlCharacters(_ value: String) -> Bool {
    414         value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
    415     }
    416 }