field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

FieldUserPresenceGate.swift (4907B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 enum FieldUserPresenceAction: Equatable, Sendable {
      5     case unlockIdentity
      6     case saveIdentity
      7     case deleteIdentity
      8 
      9     var reason: String {
     10         switch self {
     11         case .unlockIdentity:
     12             "Unlock your local Nostr identity."
     13         case .saveIdentity:
     14             "Save your local Nostr identity on this iPhone."
     15         case .deleteIdentity:
     16             "Delete your local Nostr identity from this iPhone."
     17         }
     18     }
     19 
     20     var verifiedStatusText: String {
     21         switch self {
     22         case .unlockIdentity:
     23             "Verified user presence to unlock identity."
     24         case .saveIdentity:
     25             "Verified user presence to save identity."
     26         case .deleteIdentity:
     27             "Verified user presence to delete identity."
     28         }
     29     }
     30 }
     31 
     32 struct FieldUserPresenceRequestRecord: Equatable, Sendable {
     33     let action: FieldUserPresenceAction
     34     let statusText: String
     35 }
     36 
     37 enum FieldUserPresenceGateError: LocalizedError, Equatable {
     38     case notVerified
     39 
     40     var errorDescription: String? {
     41         switch self {
     42         case .notVerified:
     43             "User presence was not verified."
     44         }
     45     }
     46 }
     47 
     48 final class FieldUserPresenceGate: Sendable {
     49     private let userPresence: any RadrootsUserPresence
     50 
     51     init(userPresence: any RadrootsUserPresence) {
     52         self.userPresence = userPresence
     53     }
     54 
     55     static func configured() -> FieldUserPresenceGate {
     56         #if DEBUG
     57         if FieldUITestHarness.isRequested {
     58             return FieldUserPresenceGate(userPresence: uiTestUserPresence())
     59         }
     60         #endif
     61         return FieldUserPresenceGate(userPresence: RadrootsAppleUserPresence())
     62     }
     63 
     64     func requirePresence(for action: FieldUserPresenceAction) async throws -> FieldUserPresenceRequestRecord {
     65         let request = try RadrootsUserPresenceRequest(reason: action.reason)
     66         let result = try await userPresence.verify(request)
     67         guard result.verified else {
     68             throw FieldUserPresenceGateError.notVerified
     69         }
     70         return FieldUserPresenceRequestRecord(action: action, statusText: action.verifiedStatusText)
     71     }
     72 
     73     #if DEBUG
     74     private static func uiTestUserPresence() -> any RadrootsUserPresence {
     75         let outcomes = uiTestOutcomes()
     76         let status = uiTestStatus()
     77         return FieldUITestUserPresence(status: status, outcomes: outcomes.map(\.result))
     78     }
     79 
     80     private static func uiTestStatus() -> RadrootsUserPresenceStatus {
     81         let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS")?.lowercased()
     82         switch raw {
     83         case "unavailable":
     84             return .unavailable
     85         case "device_credential":
     86             return RadrootsUserPresenceStatus(
     87                 support: .deviceCredential,
     88                 biometryKind: .none,
     89                 canEvaluateDeviceCredential: true,
     90                 canEvaluateBiometrics: false
     91             )
     92         case nil, "", "available", "biometrics":
     93             return RadrootsUserPresenceStatus(
     94                 support: .biometricsOrDeviceCredential,
     95                 biometryKind: .faceID,
     96                 canEvaluateDeviceCredential: true,
     97                 canEvaluateBiometrics: true
     98             )
     99         default:
    100             return .unavailable
    101         }
    102     }
    103 
    104     private static func uiTestOutcomes() -> [FieldUserPresenceUITestOutcome] {
    105         let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME") ?? ""
    106         let parts = raw
    107             .split(separator: ",")
    108             .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
    109             .filter { !$0.isEmpty }
    110         if parts.isEmpty {
    111             return [.success]
    112         }
    113         return parts.map { FieldUserPresenceUITestOutcome(rawValue: $0) ?? .denied }
    114     }
    115     #endif
    116 }
    117 
    118 #if DEBUG
    119 private enum FieldUserPresenceUITestOutcome: String {
    120     case success
    121     case unverified
    122     case cancelled
    123     case denied
    124     case unavailable
    125     case timeout
    126     case transientFailure = "transient_failure"
    127     case permanentFailure = "permanent_failure"
    128 
    129     var result: Result<Bool, RadrootsUserPresenceError> {
    130         switch self {
    131         case .success:
    132             .success(true)
    133         case .unverified:
    134             .success(false)
    135         case .cancelled:
    136             .failure(.userCancelled("User presence was cancelled."))
    137         case .denied:
    138             .failure(.permissionDenied("User presence permission is denied."))
    139         case .unavailable:
    140             .failure(.unavailable("User presence is unavailable."))
    141         case .timeout:
    142             .failure(.timeout("User presence timed out."))
    143         case .transientFailure:
    144             .failure(.transientFailure("User presence failed. Please retry."))
    145         case .permanentFailure:
    146             .failure(.permanentFailure("User presence failed."))
    147         }
    148     }
    149 }
    150 #endif