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