field_ios

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

commit 06c1dc402ec42601719422a9f07d7c8093cfee52
parent 9af86307f7de9b4914660fab768be1b9a048a401
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 13:24:05 -0700

user-presence: add identity gate

Add the field-owned user-presence gate around the AppleKit service with deterministic UI-test fakes.
Add the Face ID usage string required for local Nostr identity custody.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/Info.plist | 2++
ARadroots/Runtime/FieldUserPresenceGate.swift | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 197 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; }; 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */; }; 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; }; + 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */; }; 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; @@ -105,6 +106,7 @@ CCF0F7B3C57D8D770F178329 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingSettings.swift; sourceTree = "<group>"; }; + D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldUserPresenceGate.swift; sourceTree = "<group>"; }; DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchange.swift; sourceTree = "<group>"; }; @@ -200,6 +202,7 @@ 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */, E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */, + D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, 8F0F21496E7A8490EB14AC5B /* Radroots.swift */, @@ -444,6 +447,7 @@ 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */, D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */, + 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -24,6 +24,8 @@ <string>Radroots uses your location only when you tap Location Check-in.</string> <key>NSCameraUsageDescription</key> <string>Radroots uses the camera only when you capture photo evidence or scan a document.</string> + <key>NSFaceIDUsageDescription</key> + <string>Radroots uses Face ID only to unlock, save, or delete the local Nostr identity stored on this iPhone.</string> <key>LSApplicationQueriesSchemes</key> <array> <string>nostr</string> diff --git a/Radroots/Runtime/FieldUserPresenceGate.swift b/Radroots/Runtime/FieldUserPresenceGate.swift @@ -0,0 +1,191 @@ +import Foundation +import RadrootsKit +import RadrootsKitTesting + +enum FieldUserPresenceAction: Equatable, Sendable { + case unlockIdentity + case saveIdentity + case deleteIdentity + + var reason: String { + switch self { + case .unlockIdentity: + "Unlock your local Nostr identity." + case .saveIdentity: + "Save your local Nostr identity on this iPhone." + case .deleteIdentity: + "Delete your local Nostr identity from this iPhone." + } + } + + var verifiedStatusText: String { + switch self { + case .unlockIdentity: + "Verified user presence to unlock identity." + case .saveIdentity: + "Verified user presence to save identity." + case .deleteIdentity: + "Verified user presence to delete identity." + } + } +} + +struct FieldUserPresenceRequestRecord: Equatable, Sendable { + let action: FieldUserPresenceAction + let statusText: String +} + +enum FieldUserPresenceGateError: LocalizedError, Equatable { + case notVerified + + var errorDescription: String? { + switch self { + case .notVerified: + "User presence was not verified." + } + } +} + +final class FieldUserPresenceGate: Sendable { + private let userPresence: any RadrootsUserPresence + + init(userPresence: any RadrootsUserPresence) { + self.userPresence = userPresence + } + + static func configured() -> FieldUserPresenceGate { + if uiTestWasRequested { + return FieldUserPresenceGate(userPresence: uiTestUserPresence()) + } + return FieldUserPresenceGate(userPresence: RadrootsAppleUserPresence()) + } + + func requirePresence(for action: FieldUserPresenceAction) async throws -> FieldUserPresenceRequestRecord { + let request = try RadrootsUserPresenceRequest(reason: action.reason) + let result = try await userPresence.verify(request) + guard result.verified else { + throw FieldUserPresenceGateError.notVerified + } + return FieldUserPresenceRequestRecord(action: action, statusText: action.verifiedStatusText) + } + + private static var uiTestWasRequested: Bool { + let arguments = ProcessInfo.processInfo.arguments + let environment = ProcessInfo.processInfo.environment + return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || + arguments.contains("--radroots-field-ios-ui-test") + } + + private static func uiTestUserPresence() -> any RadrootsUserPresence { + let outcomes = uiTestOutcomes() + let status = uiTestStatus() + if outcomes.count <= 1 { + return RadrootsFakeUserPresence( + status: status, + verificationOutcome: outcomes.first?.result ?? .success(true) + ) + } + return FieldSequentialUserPresence(status: status, outcomes: outcomes.map(\.result)) + } + + private static func uiTestStatus() -> RadrootsUserPresenceStatus { + let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch raw { + case "unavailable": + return .unavailable + case "device_credential": + return RadrootsUserPresenceStatus( + support: .deviceCredential, + biometryKind: .none, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: false + ) + case nil, "", "available", "biometrics": + return RadrootsUserPresenceStatus( + support: .biometricsOrDeviceCredential, + biometryKind: .faceID, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: true + ) + default: + return .unavailable + } + } + + private static func uiTestOutcomes() -> [FieldUserPresenceUITestOutcome] { + let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME"]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parts = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + if parts.isEmpty { + return [.success] + } + return parts.map { FieldUserPresenceUITestOutcome(rawValue: $0) ?? .denied } + } +} + +private actor FieldSequentialUserPresence: RadrootsUserPresence { + private let statusValue: RadrootsUserPresenceStatus + private let outcomes: [Result<Bool, RadrootsUserPresenceError>] + private var requestCount: Int + + init( + status: RadrootsUserPresenceStatus, + outcomes: [Result<Bool, RadrootsUserPresenceError>] + ) { + self.statusValue = status + self.outcomes = outcomes + self.requestCount = 0 + } + + func currentStatus() async throws -> RadrootsUserPresenceStatus { + statusValue + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + let outcome = outcomes[min(requestCount, outcomes.count - 1)] + requestCount += 1 + switch outcome { + case .success(let verified): + return RadrootsUserPresenceResult(policy: request.policy, verified: verified) + case .failure(let error): + throw error + } + } +} + +private enum FieldUserPresenceUITestOutcome: String { + case success + case unverified + case cancelled + case denied + case unavailable + case timeout + case transientFailure = "transient_failure" + case permanentFailure = "permanent_failure" + + var result: Result<Bool, RadrootsUserPresenceError> { + switch self { + case .success: + .success(true) + case .unverified: + .success(false) + case .cancelled: + .failure(.userCancelled("User presence was cancelled.")) + case .denied: + .failure(.permissionDenied("User presence permission is denied.")) + case .unavailable: + .failure(.unavailable("User presence is unavailable.")) + case .timeout: + .failure(.timeout("User presence timed out.")) + case .transientFailure: + .failure(.transientFailure("User presence failed. Please retry.")) + case .permanentFailure: + .failure(.permanentFailure("User presence failed.")) + } + } +}