RadrootsAppleMediaPicker.swift (29933B)
1 import Foundation 2 3 #if canImport(AVFoundation) 4 @preconcurrency import AVFoundation 5 #endif 6 7 #if canImport(ImageIO) 8 import ImageIO 9 #endif 10 11 #if canImport(PhotosUI) 12 @preconcurrency import PhotosUI 13 #endif 14 15 #if canImport(UIKit) 16 @preconcurrency import UIKit 17 #endif 18 19 #if canImport(UniformTypeIdentifiers) 20 import UniformTypeIdentifiers 21 #endif 22 23 #if canImport(UIKit) 24 public typealias RadrootsAppleViewControllerProvider = 25 @MainActor @Sendable () throws -> UIViewController 26 #endif 27 28 public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sendable { 29 private let fileAccess: RadrootsAppleFileAccess 30 private let fileManager: FileManager 31 private let callbackTimeout: TimeInterval 32 33 #if canImport(UIKit) 34 private let viewControllerProvider: RadrootsAppleViewControllerProvider 35 #endif 36 37 #if canImport(UIKit) 38 public init( 39 fileAccess: RadrootsAppleFileAccess, 40 fileManager: FileManager = .default, 41 callbackTimeout: TimeInterval = 120 42 ) { 43 self.fileAccess = fileAccess 44 self.fileManager = fileManager 45 self.callbackTimeout = callbackTimeout 46 viewControllerProvider = { 47 try RadrootsAppleUIKitPresentation.activeViewController(service: "media picker") 48 } 49 } 50 51 public init( 52 fileAccess: RadrootsAppleFileAccess, 53 fileManager: FileManager = .default, 54 callbackTimeout: TimeInterval = 120, 55 viewControllerProvider: @escaping RadrootsAppleViewControllerProvider 56 ) { 57 self.fileAccess = fileAccess 58 self.fileManager = fileManager 59 self.callbackTimeout = callbackTimeout 60 self.viewControllerProvider = viewControllerProvider 61 } 62 #else 63 public init( 64 fileAccess: RadrootsAppleFileAccess, 65 fileManager: FileManager = .default, 66 callbackTimeout: TimeInterval = 120 67 ) { 68 self.fileAccess = fileAccess 69 self.fileManager = fileManager 70 self.callbackTimeout = callbackTimeout 71 } 72 #endif 73 74 public func currentSupport() async throws -> RadrootsMediaPickerSupport { 75 #if canImport(UIKit) && canImport(PhotosUI) 76 try await MainActor.run { 77 try Self.liveSupport() 78 } 79 #else 80 try Self.unavailableSupport() 81 #endif 82 } 83 84 public func importMedia(_ request: RadrootsMediaImportRequest) async throws 85 -> RadrootsMediaImportResult 86 { 87 #if canImport(UIKit) && canImport(PhotosUI) 88 let support = try await currentSupport() 89 guard support.importAvailable else { 90 throw RadrootsCaptureIntakeError.unavailable 91 } 92 let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) 93 let presenter: UIViewController 94 do { 95 presenter = try await MainActor.run { 96 try viewControllerProvider() 97 } 98 } catch { 99 throw Self.adapt(error: error) 100 } 101 let coordinatorID = UUID() 102 return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( 103 timeout: callbackTimeout, 104 timeoutMessage: "timed out while presenting media import" 105 ) { completion, setCleanup in 106 var configuration = PHPickerConfiguration(photoLibrary: .shared()) 107 configuration.selectionLimit = request.selectionLimit 108 configuration.filter = .images 109 let picker = PHPickerViewController(configuration: configuration) 110 let coordinator = RadrootsApplePhotoPickerCoordinator( 111 writer: writer, 112 request: request, 113 coordinatorID: coordinatorID 114 ) 115 coordinator.completion = completion 116 picker.delegate = coordinator 117 setCleanup { 118 coordinator.cancelPresentation(picker) 119 } 120 RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) 121 presenter.present(picker, animated: true) 122 } 123 #else 124 throw RadrootsCaptureIntakeError.unavailable 125 #endif 126 } 127 128 public func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws 129 -> RadrootsMediaCaptureResult 130 { 131 #if canImport(UIKit) 132 let support = try await currentSupport() 133 guard support.cameraCaptureAvailable else { 134 throw RadrootsCaptureIntakeError.unavailable 135 } 136 try await Self.requestCameraAccessIfNeeded() 137 let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) 138 let presenter: UIViewController 139 do { 140 presenter = try await MainActor.run { 141 try viewControllerProvider() 142 } 143 } catch { 144 throw Self.adapt(error: error) 145 } 146 let coordinatorID = UUID() 147 return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( 148 timeout: callbackTimeout, 149 timeoutMessage: "timed out while presenting camera photo capture" 150 ) { completion, setCleanup in 151 let picker = UIImagePickerController() 152 picker.sourceType = .camera 153 picker.mediaTypes = [Self.imageTypeIdentifier()] 154 picker.cameraCaptureMode = .photo 155 let coordinator = RadrootsAppleCameraCaptureCoordinator( 156 writer: writer, 157 request: request, 158 coordinatorID: coordinatorID 159 ) 160 coordinator.completion = completion 161 picker.delegate = coordinator 162 setCleanup { 163 coordinator.cancelPresentation(picker) 164 } 165 RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) 166 presenter.present(picker, animated: true) 167 } 168 #else 169 throw RadrootsCaptureIntakeError.unavailable 170 #endif 171 } 172 173 static func unavailableSupport() throws -> RadrootsMediaPickerSupport { 174 try RadrootsMediaPickerSupport( 175 importAvailable: false, 176 cameraCaptureAvailable: false, 177 supportedImportKinds: [], 178 supportedCaptureKinds: [], 179 multipleSelectionSupported: false 180 ) 181 } 182 183 static func adapt(error: Error) -> RadrootsCaptureIntakeError { 184 if let captureError = error as? RadrootsCaptureIntakeError { 185 return captureError 186 } 187 if let fileError = error as? RadrootsAppleFileError { 188 return adapt(fileError: fileError) 189 } 190 return RadrootsCaptureIntakeError.transientFailure 191 } 192 193 static func adapt(fileError: RadrootsAppleFileError) -> RadrootsCaptureIntakeError { 194 switch fileError { 195 case .invalidRequest: 196 .invalidRequest 197 case .notFound: 198 .transientFailure 199 case .permissionDenied: 200 .permissionDenied 201 case .transientFailure: 202 .transientFailure 203 case .permanentFailure: 204 .permanentFailure 205 case .spaceInsufficient: 206 .spaceInsufficient 207 } 208 } 209 } 210 211 #if canImport(UIKit) 212 extension RadrootsAppleMediaPicker { 213 @MainActor 214 fileprivate static func liveSupport() throws -> RadrootsMediaPickerSupport { 215 let cameraAvailable = 216 UIImagePickerController.isSourceTypeAvailable(.camera) 217 && UIImagePickerController.availableMediaTypes(for: .camera)?.contains( 218 imageTypeIdentifier()) == true 219 return try RadrootsMediaPickerSupport( 220 importAvailable: true, 221 cameraCaptureAvailable: cameraAvailable, 222 supportedImportKinds: [.image], 223 supportedCaptureKinds: cameraAvailable ? [.image] : [], 224 multipleSelectionSupported: true 225 ) 226 } 227 228 fileprivate static func imageTypeIdentifier() -> String { 229 #if canImport(UniformTypeIdentifiers) 230 UTType.image.identifier 231 #else 232 "public.image" 233 #endif 234 } 235 236 fileprivate static func requestCameraAccessIfNeeded() async throws { 237 #if canImport(AVFoundation) 238 switch AVCaptureDevice.authorizationStatus(for: .video) { 239 case .authorized: 240 return 241 case .notDetermined: 242 let granted = await AVCaptureDevice.requestAccess(for: .video) 243 guard granted else { 244 throw RadrootsCaptureIntakeError.permissionDenied 245 } 246 case .denied: 247 throw RadrootsCaptureIntakeError.permissionDenied 248 case .restricted: 249 throw RadrootsCaptureIntakeError.permissionDenied 250 @unknown default: 251 throw RadrootsCaptureIntakeError.unavailable 252 } 253 #else 254 throw RadrootsCaptureIntakeError.unavailable 255 #endif 256 } 257 } 258 259 @MainActor 260 enum RadrootsAppleUIKitPresentation { 261 static func activeViewController(service: String) throws -> UIViewController { 262 let scenes = UIApplication.shared.connectedScenes 263 .compactMap { $0 as? UIWindowScene } 264 .filter { scene in 265 scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive 266 } 267 let windows = scenes.flatMap(\.windows) 268 guard 269 let window = windows.first(where: \.isKeyWindow) ?? windows.first(where: { !$0.isHidden }) 270 else { 271 throw RadrootsCaptureIntakeError.unavailable 272 } 273 guard let rootViewController = window.rootViewController else { 274 throw RadrootsCaptureIntakeError.unavailable 275 } 276 return topViewController(rootViewController) 277 } 278 279 private static func topViewController(_ viewController: UIViewController) -> UIViewController { 280 if let presentedViewController = viewController.presentedViewController { 281 return topViewController(presentedViewController) 282 } 283 if let navigationController = viewController as? UINavigationController, 284 let visibleViewController = navigationController.visibleViewController 285 { 286 return topViewController(visibleViewController) 287 } 288 if let tabBarController = viewController as? UITabBarController, 289 let selectedViewController = tabBarController.selectedViewController 290 { 291 return topViewController(selectedViewController) 292 } 293 return viewController 294 } 295 } 296 297 @MainActor 298 private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { 299 var completion: (@Sendable (Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) -> Void)? 300 301 private let writer: RadrootsAppleMediaAssetWriter 302 private let request: RadrootsMediaImportRequest 303 private let coordinatorID: UUID 304 private var selectedResults: [PHPickerResult] 305 private var didResolve: Bool 306 307 init( 308 writer: RadrootsAppleMediaAssetWriter, 309 request: RadrootsMediaImportRequest, 310 coordinatorID: UUID 311 ) { 312 self.writer = writer 313 self.request = request 314 self.coordinatorID = coordinatorID 315 selectedResults = [] 316 didResolve = false 317 } 318 319 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { 320 picker.dismiss(animated: true) 321 selectedResults = Array(results.prefix(request.selectionLimit)) 322 guard !selectedResults.isEmpty else { 323 finish(.failure(.userCancelled)) 324 return 325 } 326 loadResult(at: 0, collected: []) 327 } 328 329 private func loadResult(at index: Int, collected: [RadrootsMediaAsset]) { 330 guard index < selectedResults.count else { 331 do { 332 try finish(.success(RadrootsMediaImportResult(items: collected))) 333 } catch { 334 finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) 335 } 336 return 337 } 338 let provider = selectedResults[index].itemProvider 339 let suggestedName = provider.suggestedName ?? "photo" 340 guard 341 provider.hasItemConformingToTypeIdentifier(RadrootsAppleMediaPicker.imageTypeIdentifier()) 342 else { 343 finish(.failure(.transientFailure)) 344 return 345 } 346 let writer = writer 347 let destinationScope = request.destinationScope 348 let mediaTypeHint = mediaTypeHint(from: provider) 349 provider.loadFileRepresentation( 350 forTypeIdentifier: RadrootsAppleMediaPicker.imageTypeIdentifier() 351 ) { url, error in 352 if let error { 353 Task { @MainActor in 354 self.finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) 355 } 356 return 357 } 358 guard let url else { 359 Task { @MainActor in 360 self.finish(.failure(.transientFailure)) 361 } 362 return 363 } 364 let result: Result<RadrootsMediaAsset, RadrootsCaptureIntakeError> 365 do { 366 result = try .success( 367 writer.persistExternalImage( 368 sourceURL: url, 369 source: .libraryImport, 370 destinationScope: destinationScope, 371 suggestedFilename: suggestedName, 372 mediaTypeHint: mediaTypeHint 373 ) 374 ) 375 } catch { 376 result = .failure(RadrootsAppleMediaPicker.adapt(error: error)) 377 } 378 Task { @MainActor in 379 switch result { 380 case .success(let asset): 381 var nextCollected = collected 382 nextCollected.append(asset) 383 self.loadResult(at: index + 1, collected: nextCollected) 384 case .failure(let error): 385 self.finish(.failure(error)) 386 } 387 } 388 } 389 } 390 391 private func mediaTypeHint(from provider: NSItemProvider) -> String? { 392 #if canImport(UniformTypeIdentifiers) 393 provider.registeredTypeIdentifiers 394 .compactMap(UTType.init) 395 .first(where: { $0.conforms(to: .image) })? 396 .preferredMIMEType 397 #else 398 nil 399 #endif 400 } 401 402 func cancelPresentation(_ picker: PHPickerViewController) { 403 guard !didResolve else { return } 404 picker.dismiss(animated: true) 405 finish(.failure(.transientFailure)) 406 } 407 408 private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { 409 guard !didResolve else { return } 410 didResolve = true 411 let completion = completion 412 self.completion = nil 413 RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) 414 completion?(result) 415 } 416 } 417 418 @MainActor 419 private final class RadrootsAppleCameraCaptureCoordinator: NSObject, 420 UIImagePickerControllerDelegate, UINavigationControllerDelegate 421 { 422 var completion: (@Sendable (Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) -> Void)? 423 424 private let writer: RadrootsAppleMediaAssetWriter 425 private let request: RadrootsMediaCaptureRequest 426 private let coordinatorID: UUID 427 private var didResolve: Bool 428 429 init( 430 writer: RadrootsAppleMediaAssetWriter, 431 request: RadrootsMediaCaptureRequest, 432 coordinatorID: UUID 433 ) { 434 self.writer = writer 435 self.request = request 436 self.coordinatorID = coordinatorID 437 didResolve = false 438 } 439 440 func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { 441 picker.dismiss(animated: true) 442 finish(.failure(.userCancelled)) 443 } 444 445 func imagePickerController( 446 _ picker: UIImagePickerController, 447 didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] 448 ) { 449 picker.dismiss(animated: true) 450 do { 451 try finish(.success(RadrootsMediaCaptureResult(item: buildAsset(info: info)))) 452 } catch { 453 finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) 454 } 455 } 456 457 private func buildAsset(info: [UIImagePickerController.InfoKey: Any]) throws 458 -> RadrootsMediaAsset 459 { 460 if let imageURL = info[.imageURL] as? URL { 461 return try writer.persistExternalImage( 462 sourceURL: imageURL, 463 source: .cameraCapture, 464 destinationScope: request.destinationScope, 465 suggestedFilename: imageURL.lastPathComponent, 466 mediaTypeHint: nil 467 ) 468 } 469 guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), 470 let jpegData = image.jpegData(compressionQuality: 0.92) 471 else { 472 throw RadrootsCaptureIntakeError.transientFailure 473 } 474 return try writer.persistCapturedJPEG( 475 data: jpegData, 476 image: image, 477 destinationScope: request.destinationScope 478 ) 479 } 480 481 func cancelPresentation(_ picker: UIImagePickerController) { 482 guard !didResolve else { return } 483 picker.dismiss(animated: true) 484 finish(.failure(.transientFailure)) 485 } 486 487 private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { 488 guard !didResolve else { return } 489 didResolve = true 490 let completion = completion 491 self.completion = nil 492 RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) 493 completion?(result) 494 } 495 } 496 #endif 497 498 @MainActor 499 final class RadrootsApplePresentationRetainer { 500 static let shared = RadrootsApplePresentationRetainer() 501 private var retainers: [UUID: AnyObject] 502 503 private init() { 504 retainers = [:] 505 } 506 507 func store(_ retainer: AnyObject, id: UUID) { 508 retainers[id] = retainer 509 } 510 511 func release(id: UUID) { 512 retainers.removeValue(forKey: id) 513 } 514 } 515 516 private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { 517 private let fileAccess: RadrootsAppleFileAccess 518 private let fileManager: FileManager 519 520 init(fileAccess: RadrootsAppleFileAccess, fileManager: FileManager) { 521 self.fileAccess = fileAccess 522 self.fileManager = fileManager 523 } 524 525 func persistExternalImage( 526 sourceURL: URL, 527 source: RadrootsMediaSource, 528 destinationScope: RadrootsFileScope, 529 suggestedFilename: String, 530 mediaTypeHint: String? 531 ) throws -> RadrootsMediaAsset { 532 let filename = try sanitizedFilename( 533 suggestedFilename, 534 fallbackBasename: "photo", 535 fallbackExtension: fallbackExtension(mediaType: mediaTypeHint) 536 ) 537 let file = try destinationFile(source: source, scope: destinationScope, filename: filename) 538 let mediaType = try normalizedImageMediaType(mediaTypeHint, filename: filename) 539 let imported = try fileAccess.copyExternalFile( 540 sourceURL, 541 to: file, 542 mediaType: mediaType, 543 suggestedFilename: filename 544 ) 545 let destinationURL = try fileAccess.roots.resolvedURL(for: imported.file) 546 let dimensions = imageDimensions(fileURL: destinationURL) 547 return try RadrootsMediaAsset( 548 source: source, 549 kind: .image, 550 file: imported.file, 551 mediaType: imported.mediaType ?? mediaType, 552 suggestedFilename: imported.suggestedFilename, 553 sizeBytes: imported.sizeBytes, 554 pixelWidth: dimensions?.width, 555 pixelHeight: dimensions?.height, 556 capturedAt: Date() 557 ) 558 } 559 560 #if canImport(UIKit) 561 func persistCapturedJPEG( 562 data: Data, 563 image: UIImage, 564 destinationScope: RadrootsFileScope 565 ) throws -> RadrootsMediaAsset { 566 let filename = try sanitizedFilename( 567 "captured_photo.jpg", 568 fallbackBasename: "captured_photo", 569 fallbackExtension: "jpg" 570 ) 571 let file = try destinationFile( 572 source: .cameraCapture, scope: destinationScope, filename: filename) 573 try fileAccess.write(.inline(data), to: file) 574 return try RadrootsMediaAsset( 575 source: .cameraCapture, 576 kind: .image, 577 file: file, 578 mediaType: "image/jpeg", 579 suggestedFilename: filename, 580 sizeBytes: UInt64(data.count), 581 pixelWidth: image.cgImage.map { UInt32($0.width) } 582 ?? positiveRoundedUInt32(image.size.width), 583 pixelHeight: image.cgImage.map { UInt32($0.height) } 584 ?? positiveRoundedUInt32(image.size.height), 585 capturedAt: Date() 586 ) 587 } 588 #endif 589 590 private func destinationFile( 591 source: RadrootsMediaSource, 592 scope: RadrootsFileScope, 593 filename: String 594 ) throws -> RadrootsFileReference { 595 let namespace = 596 switch source { 597 case .libraryImport: 598 "library_import" 599 case .cameraCapture: 600 "camera_capture" 601 } 602 let validatedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(filename) 603 return RadrootsFileReference( 604 scope: scope, 605 relativePath: 606 "capture_intake/media/\(namespace)/\(UUID().uuidString.lowercased())/\(validatedFilename)" 607 ) 608 } 609 610 private func sanitizedFilename( 611 _ value: String, 612 fallbackBasename: String, 613 fallbackExtension: String 614 ) throws -> String { 615 let fallback = "\(fallbackBasename).\(fallbackExtension)" 616 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) 617 let lastComponent = URL(fileURLWithPath: trimmed).lastPathComponent 618 let raw = lastComponent.isEmpty || lastComponent == "/" ? fallback : lastComponent 619 let sanitizedScalars = raw.unicodeScalars.map { scalar -> Character in 620 if CharacterSet.controlCharacters.contains(scalar) || scalar == "/" || scalar == "\\" 621 || scalar == "\0" || scalar == ":" 622 { 623 return "_" 624 } 625 return Character(scalar) 626 } 627 var sanitized = String(sanitizedScalars).trimmingCharacters(in: .whitespacesAndNewlines) 628 if sanitized.isEmpty || sanitized == "." || sanitized == ".." { 629 sanitized = fallback 630 } 631 if URL(fileURLWithPath: sanitized).pathExtension.isEmpty { 632 sanitized = "\(sanitized).\(fallbackExtension)" 633 } 634 return try RadrootsCaptureIntakeValidation.normalizedFilename(sanitized) 635 } 636 637 private func normalizedImageMediaType(_ mediaType: String?, filename: String) throws -> String { 638 if let mediaType { 639 return try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) 640 } 641 #if canImport(UniformTypeIdentifiers) 642 if let type = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension), 643 let preferredMIMEType = type.preferredMIMEType 644 { 645 return try RadrootsCaptureIntakeValidation.normalizedMediaType(preferredMIMEType) 646 } 647 #endif 648 return "image/jpeg" 649 } 650 651 private func fallbackExtension(mediaType: String?) -> String { 652 switch mediaType?.lowercased() { 653 case "image/png": 654 "png" 655 case "image/heic": 656 "heic" 657 case "image/heif": 658 "heif" 659 default: 660 "jpg" 661 } 662 } 663 664 private func imageDimensions(fileURL: URL) -> (width: UInt32, height: UInt32)? { 665 #if canImport(ImageIO) 666 guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), 667 let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) 668 as? [CFString: Any], 669 let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, 670 let height = properties[kCGImagePropertyPixelHeight] as? NSNumber, 671 width.uint32Value > 0, 672 height.uint32Value > 0 673 else { 674 return nil 675 } 676 return (width.uint32Value, height.uint32Value) 677 #else 678 return nil 679 #endif 680 } 681 682 private func positiveRoundedUInt32(_ value: Double) -> UInt32? { 683 guard value.isFinite, value > 0 else { 684 return nil 685 } 686 return UInt32(value.rounded()) 687 } 688 } 689 690 enum RadrootsAppleCaptureAsyncSupport { 691 static func awaitMainActorCallback<Value: Sendable>( 692 timeout: TimeInterval, 693 timeoutMessage: String, 694 operation: 695 @escaping @MainActor ( 696 @escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void, 697 @escaping @MainActor @Sendable (@escaping @MainActor @Sendable () -> Void) -> Void 698 ) -> Void 699 ) async throws -> Value { 700 let state = RadrootsAppleCaptureAsyncCallbackState<Value>() 701 return try await withTaskCancellationHandler { 702 try await withCheckedThrowingContinuation { continuation in 703 state.start(continuation: continuation) 704 let timeoutTask = Task { 705 let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) 706 do { 707 try await Task.sleep(nanoseconds: nanoseconds) 708 } catch { 709 return 710 } 711 state.resolve(.failure(.transientFailure)) 712 } 713 Task { @MainActor in 714 operation( 715 { result in 716 timeoutTask.cancel() 717 state.resolve(result) 718 }, 719 { cleanup in 720 state.setCleanup(cleanup) 721 } 722 ) 723 } 724 } 725 } onCancel: { 726 state.resolve(.failure(.userCancelled)) 727 } 728 } 729 } 730 731 private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @unchecked Sendable { 732 private let lock: NSLock 733 private var continuation: CheckedContinuation<Value, any Error>? 734 private var cleanup: (@MainActor @Sendable () -> Void)? 735 private var resolvedResult: Result<Value, RadrootsCaptureIntakeError>? 736 private var didResolve: Bool 737 738 init() { 739 lock = NSLock() 740 continuation = nil 741 cleanup = nil 742 resolvedResult = nil 743 didResolve = false 744 } 745 746 func start(continuation: CheckedContinuation<Value, any Error>) { 747 lock.lock() 748 if let resolvedResult { 749 lock.unlock() 750 resume(continuation, with: resolvedResult) 751 return 752 } 753 self.continuation = continuation 754 lock.unlock() 755 } 756 757 func setCleanup(_ cleanup: @escaping @MainActor @Sendable () -> Void) { 758 lock.lock() 759 let shouldRun = didResolve 760 if !didResolve { 761 self.cleanup = cleanup 762 } 763 lock.unlock() 764 if shouldRun { 765 Task { @MainActor in 766 cleanup() 767 } 768 } 769 } 770 771 func resolve(_ result: Result<Value, RadrootsCaptureIntakeError>) { 772 lock.lock() 773 guard !didResolve else { 774 lock.unlock() 775 return 776 } 777 didResolve = true 778 let continuation = continuation 779 self.continuation = nil 780 if continuation == nil { 781 resolvedResult = result 782 } 783 let cleanup = cleanup 784 self.cleanup = nil 785 lock.unlock() 786 787 if let cleanup { 788 Task { @MainActor in 789 cleanup() 790 } 791 } 792 guard let continuation else { 793 return 794 } 795 resume(continuation, with: result) 796 } 797 798 private func resume( 799 _ continuation: CheckedContinuation<Value, any Error>, 800 with result: Result<Value, RadrootsCaptureIntakeError> 801 ) { 802 switch result { 803 case .success(let value): 804 continuation.resume(returning: value) 805 case .failure(let error): 806 continuation.resume(throwing: error) 807 } 808 } 809 }