apple_kit

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

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 }