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 }