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

RadrootsAppleUserPresence.swift (9713B)


      1 import Foundation
      2 
      3 #if canImport(LocalAuthentication)
      4     @preconcurrency import LocalAuthentication
      5 #endif
      6 
      7 public struct RadrootsAppleUserPresenceAdapters: Sendable {
      8     public let currentStatus: @Sendable () async throws -> RadrootsUserPresenceStatus
      9     public let verify: @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
     10 
     11     public init(
     12         currentStatus: @escaping @Sendable () async throws -> RadrootsUserPresenceStatus,
     13         verify:
     14             @escaping @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
     15     ) {
     16         self.currentStatus = currentStatus
     17         self.verify = verify
     18     }
     19 
     20     public static func live(callbackTimeout: TimeInterval = 30) -> Self {
     21         #if canImport(LocalAuthentication)
     22             Self(
     23                 currentStatus: {
     24                     Self.status(for: LAContext())
     25                 },
     26                 verify: { request in
     27                     let context = LAContext()
     28                     return try await Self.verify(
     29                         request,
     30                         context: context,
     31                         callbackTimeout: callbackTimeout
     32                     )
     33                 }
     34             )
     35         #else
     36             Self(
     37                 currentStatus: {
     38                     throw RadrootsUserPresenceError.unavailable
     39                 },
     40                 verify: { _ in
     41                     throw RadrootsUserPresenceError.unavailable
     42                 }
     43             )
     44         #endif
     45     }
     46 }
     47 
     48 public final class RadrootsAppleUserPresence: RadrootsUserPresence, Sendable {
     49     private let adapters: RadrootsAppleUserPresenceAdapters
     50 
     51     public init(
     52         adapters: RadrootsAppleUserPresenceAdapters = RadrootsAppleUserPresenceAdapters.live()
     53     ) {
     54         self.adapters = adapters
     55     }
     56 
     57     public func currentStatus() async throws -> RadrootsUserPresenceStatus {
     58         do {
     59             return try await adapters.currentStatus()
     60         } catch {
     61             throw RadrootsAppleUserPresenceAdapters.adapt(error: error)
     62         }
     63     }
     64 
     65     public func verify(_ request: RadrootsUserPresenceRequest) async throws
     66         -> RadrootsUserPresenceResult
     67     {
     68         do {
     69             return try await adapters.verify(request)
     70         } catch {
     71             throw RadrootsAppleUserPresenceAdapters.adapt(error: error)
     72         }
     73     }
     74 }
     75 
     76 extension RadrootsAppleUserPresenceAdapters {
     77     static func adapt(error: Error) -> RadrootsUserPresenceError {
     78         if let error = error as? RadrootsUserPresenceError {
     79             return error
     80         }
     81 
     82         #if canImport(LocalAuthentication)
     83             if let error = error as? LAError {
     84                 switch error.code {
     85                 case .userCancel, .userFallback:
     86                     return .userCancelled
     87                 case .appCancel, .systemCancel, .notInteractive:
     88                     return .transientFailure
     89                 case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet:
     90                     return .unavailable
     91                 case .authenticationFailed:
     92                     return .permissionDenied
     93                 default:
     94                     return .permanentFailure
     95                 }
     96             }
     97         #endif
     98 
     99         return .permanentFailure
    100     }
    101 }
    102 
    103 #if canImport(LocalAuthentication)
    104     extension RadrootsAppleUserPresenceAdapters {
    105         static func platformPolicy(_ policy: RadrootsUserPresencePolicy) -> LAPolicy {
    106             switch policy {
    107             case .deviceOwnerAuthentication:
    108                 .deviceOwnerAuthentication
    109             case .deviceOwnerAuthenticationWithBiometrics:
    110                 .deviceOwnerAuthenticationWithBiometrics
    111             }
    112         }
    113 
    114         static func status(for context: LAContext) -> RadrootsUserPresenceStatus {
    115             var biometricsError: NSError?
    116             let canEvaluateBiometrics = context.canEvaluatePolicy(
    117                 .deviceOwnerAuthenticationWithBiometrics,
    118                 error: &biometricsError
    119             )
    120 
    121             var deviceCredentialError: NSError?
    122             let canEvaluateDeviceCredential = context.canEvaluatePolicy(
    123                 .deviceOwnerAuthentication,
    124                 error: &deviceCredentialError
    125             )
    126 
    127             let support: RadrootsUserPresenceSupport =
    128                 if canEvaluateBiometrics {
    129                 .biometricsOrDeviceCredential
    130             } else if canEvaluateDeviceCredential {
    131                 .deviceCredential
    132             } else {
    133                 .none
    134             }
    135 
    136             return RadrootsUserPresenceStatus(
    137                 support: support,
    138                 biometryKind: biometryKind(context.biometryType),
    139                 canEvaluateDeviceCredential: canEvaluateDeviceCredential,
    140                 canEvaluateBiometrics: canEvaluateBiometrics
    141             )
    142         }
    143 
    144         static func biometryKind(_ biometryType: LABiometryType) -> RadrootsBiometryKind {
    145             switch biometryType {
    146             case .none:
    147                 .none
    148             case .touchID:
    149                 .touchID
    150             case .faceID:
    151                 .faceID
    152             case .opticID:
    153                 .opticID
    154             @unknown default:
    155                 .unknown
    156             }
    157         }
    158 
    159         static func verify(
    160             _ request: RadrootsUserPresenceRequest,
    161             context: LAContext,
    162             callbackTimeout: TimeInterval
    163         ) async throws -> RadrootsUserPresenceResult {
    164             try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback(
    165                 timeout: callbackTimeout,
    166                 timeoutMessage: "timed out while completing user presence verification",
    167                 invalidate: { context.invalidate() }
    168             ) { completion in
    169                 context.evaluatePolicy(
    170                     platformPolicy(request.policy),
    171                     localizedReason: request.reason
    172                 ) { success, error in
    173                     if let error {
    174                         completion(.failure(adapt(error: error)))
    175                     } else {
    176                         completion(
    177                             .success(RadrootsUserPresenceResult(policy: request.policy, verified: success)))
    178                     }
    179                 }
    180             }
    181         }
    182 
    183     }
    184 #endif
    185 
    186 enum RadrootsAppleUserPresenceAsyncSupport {
    187     static func awaitCallback<Value: Sendable>(
    188         timeout: TimeInterval,
    189         timeoutMessage: String,
    190         invalidate: @escaping @Sendable () -> Void = {},
    191         _ body: @escaping @Sendable (
    192             @escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void
    193         ) -> Void
    194     ) async throws -> Value {
    195         let nanoseconds = try timeoutNanoseconds(timeout)
    196         let state = RadrootsAppleUserPresenceAsyncCallbackState<Value>(invalidate: invalidate)
    197         return try await withTaskCancellationHandler {
    198             try await withCheckedThrowingContinuation { continuation in
    199                 state.install(continuation, timeoutNanoseconds: nanoseconds, body: body)
    200             }
    201         } onCancel: {
    202             state.resume(.failure(.userCancelled))
    203         }
    204     }
    205 
    206     private static func timeoutNanoseconds(_ timeout: TimeInterval) throws -> UInt64 {
    207         guard timeout.isFinite, timeout > 0 else {
    208             throw RadrootsUserPresenceError.invalidRequest
    209         }
    210         let nanoseconds = timeout * 1_000_000_000
    211         guard nanoseconds >= 1, nanoseconds < Double(UInt64.max) else {
    212             throw RadrootsUserPresenceError.invalidRequest
    213         }
    214         return UInt64(nanoseconds)
    215     }
    216 }
    217 
    218 final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable>:
    219     @unchecked Sendable
    220 {
    221     // All mutable state and context start/invalidation run on this serial queue.
    222     // Cancellation queued before installation cannot launch evaluatePolicy;
    223     // cancellation after evaluation starts invalidates that same context.
    224     // Foreign callbacks only enqueue resolution, including synchronous callbacks.
    225     private let queue = DispatchQueue(label: "org.radroots.user-presence")
    226     private let invalidate: @Sendable () -> Void
    227     private var continuation: CheckedContinuation<Value, any Error>?
    228     private var result: Result<Value, RadrootsUserPresenceError>?
    229     private var timer: Task<Void, Never>?
    230 
    231     init(invalidate: @escaping @Sendable () -> Void) {
    232         self.invalidate = invalidate
    233     }
    234 
    235     func install(
    236         _ continuation: CheckedContinuation<Value, any Error>,
    237         timeoutNanoseconds: UInt64,
    238         body: @escaping @Sendable (
    239             @escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void
    240         ) -> Void
    241     ) {
    242         queue.async {
    243             if let result = self.result {
    244                 continuation.resume(with: result.mapError { $0 as any Error })
    245                 return
    246             }
    247             self.continuation = continuation
    248             self.timer = Task {
    249                 do {
    250                     try await Task.sleep(nanoseconds: timeoutNanoseconds)
    251                     self.resume(.failure(.timeout))
    252                 } catch {
    253                     // Completed evaluations cancel their timer; no second result.
    254                 }
    255             }
    256             body { [weak self] in self?.resume($0) }
    257         }
    258     }
    259 
    260     func resume(_ result: Result<Value, RadrootsUserPresenceError>) {
    261         queue.async {
    262             guard self.result == nil else { return }
    263             self.result = result
    264             self.timer?.cancel()
    265             self.timer = nil
    266             self.invalidate()
    267             let pending = self.continuation
    268             self.continuation = nil
    269             pending?.resume(with: result.mapError { $0 as any Error })
    270         }
    271     }
    272 }