apple_kit

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

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 }