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 }