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