field_ios

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

AppState.swift (38726B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 enum FieldAppRuntimeError: LocalizedError {
      5     case runtimeNotReady
      6     case forcedStartupFailure
      7 
      8     var errorDescription: String? {
      9         switch self {
     10         case .runtimeNotReady:
     11             "Runtime not ready. Please retry."
     12         case .forcedStartupFailure:
     13             "Startup failure requested by field iOS runtime mode."
     14         }
     15     }
     16 }
     17 
     18 @MainActor
     19 public final class AppState: ObservableObject {
     20     public enum BootstrapPhase: Equatable {
     21         case idle
     22         case starting
     23         case ready
     24         case failed(String)
     25     }
     26 
     27     public enum RelayLight {
     28         case red, yellow, green
     29     }
     30 
     31     @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle
     32     @Published public private(set) var infoJSONString: String = ""
     33     @Published public private(set) var hasKey: Bool = false
     34     @Published public private(set) var storedIdentityAvailable: Bool = false
     35     @Published public private(set) var runtimeIdentityReady: Bool = false
     36     @Published public private(set) var isLocked: Bool = false
     37     @Published public private(set) var npub: String?
     38     @Published public private(set) var identityLabel: String?
     39     @Published public private(set) var identities: [NostrIdentityRecord] = []
     40     @Published public private(set) var relayConnectedCount: UInt32 = 0
     41     @Published public private(set) var relayConnectingCount: UInt32 = 0
     42     @Published public private(set) var relayLight: RelayLight = .red
     43     @Published public private(set) var relayLastError: String?
     44     @Published public private(set) var configuredRelayURLs: [String] = []
     45     @Published public private(set) var relaySettingsSourceLabel: String = RelaySettingsSource.buildConfig.displayName
     46     @Published public private(set) var fileAccessProbeValue: String?
     47     @Published public private(set) var documentInterchangeProbeValue: String?
     48     @Published public private(set) var identityPolicyProbeValue: String?
     49     @Published public private(set) var identityImportFailureProbeValue: String?
     50     @Published public private(set) var telemetryProbeValue: String?
     51     @Published public private(set) var backgroundExecutionProbeValue: String?
     52     @Published public private(set) var externalActionStatus: String?
     53     @Published public private(set) var userPresenceStatus: String?
     54     @Published public private(set) var canOpenNostrProfile: Bool = false
     55     @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle(
     56         RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable)
     57     )
     58     @Published public private(set) var captureIntakeState: FieldCaptureIntakeState = .idle
     59 
     60     public var canShowAppContent: Bool {
     61         bootstrapPhase == .ready && runtimeIdentityReady && !isLocked
     62     }
     63 
     64     public var requiresSetup: Bool {
     65         bootstrapPhase == .ready && (!storedIdentityAvailable || isLocked || !runtimeIdentityReady)
     66     }
     67 
     68     public var identityDisplayName: String {
     69         if let label = identityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
     70            !label.isEmpty {
     71             return label
     72         }
     73         if let npub {
     74             return shortNpub(npub)
     75         }
     76         return "Local Nostr identity"
     77     }
     78 
     79     public let radroots: Radroots
     80     private let telemetry: FieldTelemetry
     81 
     82     public var runtimeService: FieldRuntimeService? {
     83         radroots.runtimeService
     84     }
     85 
     86     private let lockKey = "field_ios.identity_locked"
     87     private var statusTask: Task<Void, Never>?
     88     private var telemetryProbeTask: Task<Void, Never>?
     89     private var secureIdentityStore: FieldSecureIdentityStore?
     90     private var identityMetadataStore: FieldIdentityPublicMetadataStore?
     91     private var captureIntake: FieldCaptureIntake?
     92     private var backgroundExecution: FieldBackgroundExecution?
     93     private let locationCheckIn = FieldLocationCheckIn.configured()
     94     private let externalActions = FieldExternalActions.configured()
     95     private let userPresenceGate = FieldUserPresenceGate.configured()
     96     private var lastTelemetryRelayStatus: FieldTelemetryRelayStatus?
     97 
     98     init(radroots: Radroots = Radroots(), telemetry: FieldTelemetry = .shared) {
     99         self.radroots = radroots
    100         self.telemetry = telemetry
    101         self.isLocked = UserDefaults.standard.bool(forKey: lockKey)
    102     }
    103 
    104     deinit {
    105         statusTask?.cancel()
    106         telemetryProbeTask?.cancel()
    107     }
    108 
    109     public func start() async throws {
    110         guard bootstrapPhase == .idle || isFailed else { return }
    111         telemetry.appStartupBegan()
    112         bootstrapPhase = .starting
    113         do {
    114             try await holdBootstrapSplashForUITestIfRequested()
    115             if startupFailureWasRequested {
    116                 throw FieldAppRuntimeError.forcedStartupFailure
    117             }
    118             let service = try radroots.start(telemetry: telemetry)
    119             let secureStore = try FieldSecureIdentityStore.configured()
    120             let metadataStore = try FieldIdentityPublicMetadataStore.configured()
    121             #if DEBUG
    122             identityPolicyProbeValue = try FieldIdentityPolicyUITestProbe.value()
    123             #endif
    124             let appBundleIdentifier = try bundleIdentifier()
    125             let resetLocalStateRequested = BuildConfig.bool(.resetLocalState) == true
    126             let backgroundExecution = try FieldBackgroundExecution.configured(
    127                 bundleIdentifier: appBundleIdentifier,
    128                 telemetry: telemetry
    129             )
    130             self.backgroundExecution = backgroundExecution
    131             await FieldBackgroundURLSessionEvents.shared.attach(backgroundExecution)
    132             try FieldFileAccessUITestProbe.seedDestructiveResetSentinelIfRequested(
    133                 bundleIdentifier: appBundleIdentifier,
    134                 resetLocalStateRequested: resetLocalStateRequested
    135             )
    136             secureIdentityStore = secureStore
    137             identityMetadataStore = metadataStore
    138             if resetLocalStateRequested {
    139                 await backgroundExecution.cancelAll()
    140                 try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier)
    141                 try RelaySettings.clearUserImportedRelays(bundleIdentifier: appBundleIdentifier)
    142                 try secureStore.deleteSelectedSecret()
    143                 metadataStore.delete()
    144                 try await resetRuntimeIdentityState(using: service)
    145                 applyNoIdentity()
    146                 setLocked(false)
    147             } else {
    148                 loadStoredIdentityMetadata(metadataStore)
    149             }
    150             try refreshRelaySettingsSnapshot(bundleIdentifier: appBundleIdentifier)
    151             let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier)
    152             self.captureIntake = captureIntake
    153             try await backgroundExecution.start()
    154             await refreshBackgroundExecutionProbe(using: backgroundExecution)
    155             await refreshRuntimeState(using: service)
    156             #if DEBUG
    157             identityImportFailureProbeValue = await FieldIdentityImportFailureUITestProbe.value(
    158                 secureStore: secureStore,
    159                 service: service
    160             )
    161             #endif
    162             if runtimeIdentityReady && !isLocked {
    163                 startConnectingAndPollingStatus(using: service)
    164             }
    165             await refreshNostrProfileExternalActionCapability()
    166             try refreshFileAccessProbe(
    167                 bundleIdentifier: appBundleIdentifier,
    168                 resetLocalStateRequested: resetLocalStateRequested,
    169                 identityResetObserved: false
    170             )
    171             try await refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier)
    172             await refreshLocationCheckInStatus()
    173             await refreshCaptureIntakeState(using: captureIntake)
    174             bootstrapPhase = .ready
    175             telemetry.appStartupSucceeded(
    176                 storedIdentityAvailable: storedIdentityAvailable,
    177                 runtimeIdentityReady: runtimeIdentityReady,
    178                 locked: isLocked
    179             )
    180             startTelemetryProbeRefreshForUITest()
    181         } catch {
    182             statusTask?.cancel()
    183             statusTask = nil
    184             telemetryProbeTask?.cancel()
    185             telemetryProbeTask = nil
    186             await FieldBackgroundURLSessionEvents.shared.completePendingAfterStartupFailure()
    187             backgroundExecution = nil
    188             let message = error.fieldRuntimeMessage
    189             bootstrapPhase = .failed(message)
    190             telemetry.appStartupFailed(error)
    191             startTelemetryProbeRefreshForUITest()
    192             throw error
    193         }
    194     }
    195 
    196     public func retryStartup() {
    197         bootstrapPhase = .idle
    198         Task {
    199             try? await start()
    200         }
    201     }
    202 
    203     public func refresh() {
    204         Task {
    205             await refreshRuntimeState()
    206         }
    207     }
    208 
    209     public func appDidBecomeActive() {
    210         Task {
    211             try? await backgroundExecution?.schedulePermittedTasks(reason: "active")
    212         }
    213     }
    214 
    215     public func appDidEnterBackground() {
    216         Task {
    217             _ = try? await backgroundExecution?.schedulePermittedTasks(reason: "background")
    218             await backgroundExecution?.performMaintenance(reason: "background")
    219         }
    220     }
    221 
    222     public func continueWithLocalIdentity() async throws {
    223         let service = try requireRuntimeService()
    224         do {
    225             try await requireUserPresence(for: .unlockIdentity)
    226             try await restoreStoredIdentity(using: service)
    227             setLocked(false)
    228             await refreshRuntimeState(using: service)
    229             await refreshNostrProfileExternalActionCapability()
    230             startConnectingAndPollingStatus(using: service)
    231             telemetry.identityCustody(action: "unlock", outcome: "success")
    232         } catch {
    233             telemetry.identityCustody(action: "unlock", outcome: FieldTelemetry.userPresenceOutcome(for: error))
    234             throw error
    235         }
    236     }
    237 
    238     public func createLocalIdentity() async throws {
    239         let service = try requireRuntimeService()
    240         do {
    241             try await requireUserPresence(for: .saveIdentity)
    242             try await createHostCustodyIdentity(using: service)
    243             setLocked(false)
    244             await refreshRuntimeState(using: service)
    245             await refreshNostrProfileExternalActionCapability()
    246             startConnectingAndPollingStatus(using: service)
    247             telemetry.identityCustody(action: "create", outcome: "success")
    248         } catch {
    249             telemetry.identityCustody(action: "create", outcome: FieldTelemetry.userPresenceOutcome(for: error))
    250             throw error
    251         }
    252     }
    253 
    254     public func importNostrSecret(_ secretKey: String) async throws {
    255         let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
    256         guard !trimmed.isEmpty else { return }
    257         let service = try requireRuntimeService()
    258         do {
    259             try await requireUserPresence(for: .saveIdentity)
    260             let record = try await secureIdentityStoreOrConfigured().importSecret(
    261                 trimmed,
    262                 label: "Imported Field Identity",
    263                 using: service
    264             )
    265             try persistIdentity(record)
    266             setLocked(false)
    267             await refreshRuntimeState(using: service)
    268             await refreshNostrProfileExternalActionCapability()
    269             startConnectingAndPollingStatus(using: service)
    270             telemetry.identityCustody(action: "import", outcome: "success")
    271         } catch {
    272             telemetry.identityCustody(action: "import", outcome: FieldTelemetry.userPresenceOutcome(for: error))
    273             throw error
    274         }
    275     }
    276 
    277     public func signOut() {
    278         telemetry.identityCustody(action: "lock", outcome: "success")
    279         setLocked(true)
    280         statusTask?.cancel()
    281         statusTask = nil
    282         relayConnectedCount = 0
    283         relayConnectingCount = 0
    284         relayLight = .red
    285         Task {
    286             await lockRuntimeIdentity()
    287         }
    288     }
    289 
    290     public func resetLocalIdentity() async throws {
    291         let service = try requireRuntimeService()
    292         do {
    293             try await requireUserPresence(for: .deleteIdentity)
    294             await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
    295             await backgroundExecution?.cancelAll()
    296             try secureIdentityStoreOrConfigured().deleteSelectedSecret()
    297             try identityMetadataStoreOrConfigured().delete()
    298             try await resetRuntimeIdentityState(using: service)
    299             applyNoIdentity()
    300             setLocked(false)
    301             relayConnectedCount = 0
    302             relayConnectingCount = 0
    303             relayLight = .red
    304             relayLastError = nil
    305             canOpenNostrProfile = false
    306             externalActionStatus = nil
    307             await refreshRuntimeState(using: service)
    308             try refreshFileAccessProbe(
    309                 bundleIdentifier: try bundleIdentifier(),
    310                 resetLocalStateRequested: false,
    311                 identityResetObserved: true
    312             )
    313             statusTask?.cancel()
    314             statusTask = nil
    315             telemetry.identityCustody(action: "delete", outcome: "success")
    316         } catch {
    317             telemetry.identityCustody(action: "delete", outcome: FieldTelemetry.userPresenceOutcome(for: error))
    318             throw error
    319         }
    320     }
    321 
    322     public func requireRuntimeService() throws -> FieldRuntimeService {
    323         guard let service = runtimeService else {
    324             throw FieldAppRuntimeError.runtimeNotReady
    325         }
    326         return service
    327     }
    328 
    329     public func refreshLocationCheckInStatus() async {
    330         switch locationCheckInState {
    331         case .idle:
    332             break
    333         case .checking, .checkedIn, .failed:
    334             return
    335         }
    336         let refreshedState = await locationCheckIn.status()
    337         switch locationCheckInState {
    338         case .idle:
    339             locationCheckInState = refreshedState
    340         case .checking, .checkedIn, .failed:
    341             return
    342         }
    343     }
    344 
    345     public func performLocationCheckIn() async {
    346         let currentState = await locationCheckIn.status()
    347         if let availability = currentState.availability {
    348             locationCheckInState = .checking(availability)
    349         }
    350         locationCheckInState = await locationCheckIn.checkIn()
    351     }
    352 
    353     public func refreshCaptureIntakeState() async {
    354         guard let captureIntake else {
    355             captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription
    356             captureIntakeState.recoveryAction = nil
    357             return
    358         }
    359         await refreshCaptureIntakeState(using: captureIntake)
    360     }
    361 
    362     public func importPhotoEvidence() async {
    363         await performCaptureIntakeOperation(.importingPhoto) { captureIntake, records in
    364             try await captureIntake.importPhoto(records: records)
    365         }
    366     }
    367 
    368     public func capturePhotoEvidence() async {
    369         await performCaptureIntakeOperation(.capturingPhoto) { captureIntake, records in
    370             try await captureIntake.capturePhoto(records: records)
    371         }
    372     }
    373 
    374     public func scanDocumentEvidence() async {
    375         await performCaptureIntakeOperation(.scanningDocument) { captureIntake, records in
    376             try await captureIntake.scanDocument(records: records)
    377         }
    378     }
    379 
    380     public func refreshNostrProfileExternalActionCapability() async {
    381         guard let npub else {
    382             canOpenNostrProfile = false
    383             return
    384         }
    385         canOpenNostrProfile = await externalActions.canOpenPublicNostrProfile(npub: npub)
    386     }
    387 
    388     public func openAppSettingsRecovery() async {
    389         await requestExternalAction {
    390             try await externalActions.openAppSettings()
    391         }
    392     }
    393 
    394     public func openCurrentNostrProfile() async {
    395         guard let npub else {
    396             externalActionStatus = "No public Nostr identity is selected."
    397             canOpenNostrProfile = false
    398             return
    399         }
    400         await requestExternalAction {
    401             try await externalActions.openPublicNostrProfile(npub: npub)
    402         }
    403     }
    404 
    405     func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument {
    406         do {
    407             let relays = try effectiveRelaySettings().relays
    408             let document = try documentInterchange().prepareDiagnosticsExport(
    409                 infoJSONString: infoJSONString,
    410                 relays: relays,
    411                 connectedCount: relayConnectedCount,
    412                 connectingCount: relayConnectingCount,
    413                 lastError: relayLastError
    414             )
    415             telemetry.documentInterchange(operation: "diagnostics_export", outcome: "success", relayCount: relays.count)
    416             return document
    417         } catch {
    418             telemetry.documentInterchange(
    419                 operation: "diagnostics_export",
    420                 outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
    421             )
    422             throw error
    423         }
    424     }
    425 
    426     func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument {
    427         do {
    428             let relays = try effectiveRelaySettings().relays
    429             let document = try documentInterchange().prepareRelayConfigExport(relays: relays)
    430             telemetry.documentInterchange(operation: "relay_config_export", outcome: "success", relayCount: relays.count)
    431             return document
    432         } catch {
    433             telemetry.documentInterchange(
    434                 operation: "relay_config_export",
    435                 outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
    436             )
    437             throw error
    438         }
    439     }
    440 
    441     func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] {
    442         do {
    443             let relays = try documentInterchange().importedRelayConfig(from: importedDocument)
    444             telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count)
    445             return relays
    446         } catch {
    447             telemetry.documentInterchange(
    448                 operation: "relay_config_import",
    449                 outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
    450             )
    451             throw error
    452         }
    453     }
    454 
    455     func applyImportedRelayConfig(from importedDocument: RadrootsImportedDocument) async throws -> [String] {
    456         do {
    457             let relays = try documentInterchange().importedRelayConfig(from: importedDocument)
    458             let snapshot = try RelaySettings.storeUserImportedRelays(
    459                 relays,
    460                 bundleIdentifier: bundleIdentifier()
    461             )
    462             apply(relaySettings: snapshot)
    463             if let service = runtimeService, runtimeIdentityReady && !isLocked {
    464                 relayConnectedCount = 0
    465                 relayConnectingCount = 0
    466                 relayLight = .yellow
    467                 relayLastError = nil
    468                 try await service.nostrSetDefaultRelays(snapshot.relays)
    469                 try await service.nostrConnectIfKeyPresent()
    470                 await refreshRelayStatus(using: service)
    471                 await backgroundExecution?.updateRuntimeState(
    472                     service: service,
    473                     identityUnlocked: true
    474                 )
    475             }
    476             telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count)
    477             return snapshot.relays
    478         } catch {
    479             telemetry.documentInterchange(
    480                 operation: "relay_config_import",
    481                 outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
    482             )
    483             throw error
    484         }
    485     }
    486 
    487     func publicPostShareRequest(content: String) throws -> RadrootsShareRequest {
    488         do {
    489             let request = try documentInterchange().publicPostShareRequest(content: content)
    490             telemetry.documentInterchange(operation: "public_share_prepare", outcome: "success")
    491             return request
    492         } catch {
    493             telemetry.documentInterchange(
    494                 operation: "public_share_prepare",
    495                 outcome: FieldTelemetry.documentInterchangeOutcome(for: error)
    496             )
    497             throw error
    498         }
    499     }
    500 
    501     func documentFileAccess() throws -> RadrootsAppleFileAccess {
    502         try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier())
    503     }
    504 
    505     func releasePreparedDocumentExport(_ preparedExport: RadrootsPreparedExportDocument) {
    506         try? documentFileAccess().releasePreparedExport(preparedExport)
    507     }
    508 
    509     private func documentInterchange() throws -> FieldDocumentInterchange {
    510         try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier())
    511     }
    512 
    513     private func refreshRelaySettingsSnapshot(bundleIdentifier: String) throws {
    514         apply(relaySettings: try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier))
    515     }
    516 
    517     private func effectiveRelaySettings() throws -> RelaySettingsSnapshot {
    518         let snapshot = try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier())
    519         apply(relaySettings: snapshot)
    520         return snapshot
    521     }
    522 
    523     private func apply(relaySettings snapshot: RelaySettingsSnapshot) {
    524         configuredRelayURLs = snapshot.relays
    525         relaySettingsSourceLabel = snapshot.source.displayName
    526     }
    527 
    528     private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async {
    529         captureIntakeState.operation = .refreshing
    530         captureIntakeState.lastError = nil
    531         captureIntakeState.recoveryAction = nil
    532         do {
    533             captureIntakeState.records = try captureIntake.loadRecords()
    534             captureIntakeState.support = try await captureIntake.support()
    535             captureIntakeState.operation = .idle
    536             telemetry.captureSupportRefreshed(
    537                 support: captureIntakeState.support,
    538                 recordCount: captureIntakeState.records.count,
    539                 outcome: "success"
    540             )
    541         } catch {
    542             captureIntakeState.support = .unavailable
    543             captureIntakeState.operation = .idle
    544             captureIntakeState.lastError = error.fieldRuntimeMessage
    545             captureIntakeState.recoveryAction = nil
    546             telemetry.captureSupportRefreshed(
    547                 support: captureIntakeState.support,
    548                 recordCount: captureIntakeState.records.count,
    549                 outcome: FieldTelemetry.captureOutcome(for: error)
    550             )
    551         }
    552     }
    553 
    554     private func performCaptureIntakeOperation(
    555         _ operation: FieldCaptureIntakeOperation,
    556         action: (FieldCaptureIntake, [FieldCaptureRecord]) async throws -> [FieldCaptureRecord]
    557     ) async {
    558         guard let captureIntake else {
    559             captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription
    560             return
    561         }
    562         captureIntakeState.operation = operation
    563         captureIntakeState.lastError = nil
    564         captureIntakeState.recoveryAction = nil
    565         do {
    566             let updatedRecords = try await action(captureIntake, captureIntakeState.records)
    567             captureIntakeState.records = updatedRecords
    568             captureIntakeState.support = try await captureIntake.support()
    569             captureIntakeState.operation = .idle
    570             captureIntakeState.recoveryAction = nil
    571             telemetry.captureOperation(
    572                 operation: operation,
    573                 outcome: "success",
    574                 recordCount: captureIntakeState.records.count,
    575                 recoveryAction: nil
    576             )
    577         } catch {
    578             captureIntakeState.operation = .idle
    579             captureIntakeState.lastError = error.fieldRuntimeMessage
    580             captureIntakeState.recoveryAction = captureRecoveryAction(for: error)
    581             telemetry.captureOperation(
    582                 operation: operation,
    583                 outcome: FieldTelemetry.captureOutcome(for: error),
    584                 recordCount: captureIntakeState.records.count,
    585                 recoveryAction: captureIntakeState.recoveryAction
    586             )
    587         }
    588     }
    589 
    590     private func captureRecoveryAction(for error: Error) -> FieldExternalActionRecovery? {
    591         guard let captureError = error as? RadrootsCaptureIntakeError else {
    592             return nil
    593         }
    594         switch captureError {
    595         case .permissionDenied:
    596             return .appSettings
    597         case .invalidRequest, .unavailable, .userCancelled, .transientFailure, .permanentFailure:
    598             return nil
    599         }
    600     }
    601 
    602     private var isFailed: Bool {
    603         if case .failed = bootstrapPhase {
    604             return true
    605         }
    606         return false
    607     }
    608 
    609     private var uiTestWasRequested: Bool {
    610         #if DEBUG
    611         return FieldUITestHarness.isRequested
    612         #else
    613         return false
    614         #endif
    615     }
    616 
    617     private var uiTestBootstrapSplashHoldNanoseconds: UInt64? {
    618         #if DEBUG
    619         guard uiTestWasRequested else { return nil }
    620         guard let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_BOOTSTRAP_SPLASH_HOLD_SECONDS"),
    621               let seconds = Double(raw),
    622               seconds.isFinite,
    623               seconds > 0 else {
    624             return nil
    625         }
    626         return UInt64(seconds * 1_000_000_000)
    627         #else
    628         return nil
    629         #endif
    630     }
    631 
    632     private func holdBootstrapSplashForUITestIfRequested() async throws {
    633         guard let nanoseconds = uiTestBootstrapSplashHoldNanoseconds else { return }
    634         try await Task.sleep(nanoseconds: nanoseconds)
    635     }
    636 
    637     private var startupFailureWasRequested: Bool {
    638         #if DEBUG
    639         guard uiTestWasRequested else {
    640             return false
    641         }
    642         let arguments = ProcessInfo.processInfo.arguments
    643         if BuildConfig.string(.runtimeMode) == "ui-test-startup-failure" {
    644             return true
    645         }
    646         if FieldUITestHarness.bool("RADROOTS_FIELD_IOS_FORCE_STARTUP_FAILURE", default: false) {
    647             return true
    648         }
    649         return arguments.contains("--radroots-field-ios-force-startup-failure")
    650         #else
    651         return false
    652         #endif
    653     }
    654 
    655     private func configureRelays(using service: FieldRuntimeService) async throws {
    656         try await service.nostrSetDefaultRelays(try effectiveRelaySettings().relays)
    657     }
    658 
    659     private func connect(using service: FieldRuntimeService) async throws {
    660         try await configureRelays(using: service)
    661         try await service.nostrConnectIfKeyPresent()
    662         await refreshRelayStatus(using: service)
    663         relayLastError = nil
    664     }
    665 
    666     private func refreshRuntimeState() async {
    667         guard let service = runtimeService else { return }
    668         await refreshRuntimeState(using: service)
    669     }
    670 
    671     private func refreshRuntimeState(using service: FieldRuntimeService) async {
    672         infoJSONString = await service.infoJson()
    673         do {
    674             let snapshot = try await service.nostrIdentitySnapshot()
    675             apply(identity: snapshot)
    676         } catch {
    677             relayLastError = error.fieldRuntimeMessage
    678         }
    679         await refreshRelayStatus(using: service)
    680         await backgroundExecution?.updateRuntimeState(
    681             service: service,
    682             identityUnlocked: runtimeIdentityReady && !isLocked
    683         )
    684     }
    685 
    686     private func refreshRelayStatus(using service: FieldRuntimeService) async {
    687         let status = await service.nostrConnectionStatus()
    688         relayConnectedCount = status.connected
    689         relayConnectingCount = status.connecting
    690         relayLastError = status.lastError ?? relayLastError
    691         switch status.light {
    692         case .green:
    693             relayLight = .green
    694         case .yellow:
    695             relayLight = .yellow
    696         case .red:
    697             relayLight = .red
    698         }
    699         let telemetryStatus = FieldTelemetryRelayStatus(
    700             connectedCount: relayConnectedCount,
    701             connectingCount: relayConnectingCount,
    702             configuredRelayCount: configuredRelayURLs.count,
    703             light: relayLight.telemetryValue
    704         )
    705         if telemetryStatus != lastTelemetryRelayStatus {
    706             lastTelemetryRelayStatus = telemetryStatus
    707             telemetry.relayStatusChanged(
    708                 connectedCount: telemetryStatus.connectedCount,
    709                 connectingCount: telemetryStatus.connectingCount,
    710                 configuredRelayCount: telemetryStatus.configuredRelayCount,
    711                 light: telemetryStatus.light
    712             )
    713         }
    714     }
    715 
    716     private func apply(identity snapshot: NostrIdentitySnapshot) {
    717         runtimeIdentityReady = snapshot.hasSelectedSigningIdentity
    718         identities = snapshot.identities
    719         if snapshot.hasSelectedSigningIdentity {
    720             storedIdentityAvailable = true
    721             hasKey = true
    722             npub = snapshot.selectedNpub
    723             identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label
    724         } else if storedIdentityAvailable {
    725             hasKey = true
    726         } else {
    727             hasKey = false
    728             npub = nil
    729             identityLabel = nil
    730             canOpenNostrProfile = false
    731         }
    732     }
    733 
    734     private func lockRuntimeIdentityState(using service: FieldRuntimeService) async throws {
    735         try await service.nostrIdentityLockHostCustodyRuntime()
    736         runtimeIdentityReady = false
    737         identities = []
    738     }
    739 
    740     private func resetRuntimeIdentityState(using service: FieldRuntimeService) async throws {
    741         try await service.nostrIdentityResetHostCustodyRuntime()
    742         runtimeIdentityReady = false
    743         identities = []
    744     }
    745 
    746     private func loadStoredIdentityMetadata(_ metadataStore: FieldIdentityPublicMetadataStore) {
    747         guard let metadata = metadataStore.load() else {
    748             applyNoIdentity()
    749             setLocked(false)
    750             return
    751         }
    752         apply(storedIdentity: metadata)
    753         setLocked(true)
    754     }
    755 
    756     private func restoreStoredIdentity(using service: FieldRuntimeService) async throws {
    757         let existingMetadata = try identityMetadataStoreOrConfigured().load()
    758         let record = try await secureIdentityStoreOrConfigured().restoreStoredIdentity(
    759             label: existingMetadata?.label ?? "Radroots Field",
    760             using: service
    761         )
    762         try persistIdentity(record)
    763     }
    764 
    765     private func requireUserPresence(for action: FieldUserPresenceAction) async throws {
    766         do {
    767             let record = try await userPresenceGate.requirePresence(for: action)
    768             userPresenceStatus = record.statusText
    769             telemetry.userPresence(action: action, outcome: "success")
    770         } catch {
    771             userPresenceStatus = error.fieldRuntimeMessage
    772             telemetry.userPresence(action: action, outcome: FieldTelemetry.userPresenceOutcome(for: error))
    773             throw error
    774         }
    775     }
    776 
    777     private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws {
    778         let record = try await secureIdentityStoreOrConfigured().createIdentity(
    779             label: "Radroots Field",
    780             using: service
    781         )
    782         try persistIdentity(record)
    783     }
    784 
    785     private func persistIdentity(_ record: NostrIdentityRecord) throws {
    786         let metadata = FieldIdentityPublicMetadata(record: record)
    787         try identityMetadataStoreOrConfigured().save(metadata)
    788         apply(storedIdentity: metadata)
    789         runtimeIdentityReady = true
    790         hasKey = true
    791         identities = [record]
    792     }
    793 
    794     private func lockRuntimeIdentity() async {
    795         guard let service = runtimeService else {
    796             runtimeIdentityReady = false
    797             identities = []
    798             hasKey = storedIdentityAvailable
    799             return
    800         }
    801         do {
    802             try await lockRuntimeIdentityState(using: service)
    803         } catch {
    804             relayLastError = error.fieldRuntimeMessage
    805         }
    806         hasKey = storedIdentityAvailable
    807         await refreshRelayStatus(using: service)
    808         await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
    809     }
    810 
    811     private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) {
    812         storedIdentityAvailable = true
    813         hasKey = true
    814         npub = metadata.publicKeyNpub
    815         identityLabel = metadata.label
    816     }
    817 
    818     private func applyNoIdentity() {
    819         hasKey = false
    820         storedIdentityAvailable = false
    821         runtimeIdentityReady = false
    822         npub = nil
    823         identityLabel = nil
    824         identities = []
    825         canOpenNostrProfile = false
    826     }
    827 
    828     private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore {
    829         if let secureIdentityStore {
    830             return secureIdentityStore
    831         }
    832         let configured = try FieldSecureIdentityStore.configured()
    833         secureIdentityStore = configured
    834         return configured
    835     }
    836 
    837     private func identityMetadataStoreOrConfigured() throws -> FieldIdentityPublicMetadataStore {
    838         if let identityMetadataStore {
    839             return identityMetadataStore
    840         }
    841         let configured = try FieldIdentityPublicMetadataStore.configured()
    842         identityMetadataStore = configured
    843         return configured
    844     }
    845 
    846     private func bundleIdentifier() throws -> String {
    847         guard let bundleIdentifier = Bundle.main.bundleIdentifier,
    848               !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
    849             throw FieldSecureIdentityStoreError.missingBundleIdentifier
    850         }
    851         return bundleIdentifier
    852     }
    853 
    854     private func refreshFileAccessProbe(
    855         bundleIdentifier: String,
    856         resetLocalStateRequested: Bool,
    857         identityResetObserved: Bool
    858     ) throws {
    859         let loggingSettings = LoggingSettings.load()
    860         if identityResetObserved {
    861             fileAccessProbeValue = try FieldFileAccessUITestProbe.identityResetValue(
    862                 bundleIdentifier: bundleIdentifier,
    863                 loggingFileEnabled: loggingSettings.fileEnabled,
    864                 loggingFileName: loggingSettings.fileName
    865             )
    866         } else {
    867             fileAccessProbeValue = try FieldFileAccessUITestProbe.startupValue(
    868                 bundleIdentifier: bundleIdentifier,
    869                 resetLocalStateRequested: resetLocalStateRequested,
    870                 loggingFileEnabled: loggingSettings.fileEnabled,
    871                 loggingFileName: loggingSettings.fileName
    872             )
    873         }
    874     }
    875 
    876     private func refreshDocumentInterchangeProbe(bundleIdentifier: String) async throws {
    877         documentInterchangeProbeValue = try FieldDocumentInterchangeUITestProbe.startupValue(
    878             bundleIdentifier: bundleIdentifier,
    879             infoJSONString: infoJSONString,
    880             relays: effectiveRelaySettings().relays,
    881             connectedCount: relayConnectedCount,
    882             connectingCount: relayConnectingCount,
    883             lastError: relayLastError
    884         )
    885         guard FieldDocumentInterchangeUITestProbe.isRequested else {
    886             return
    887         }
    888         let diagnosticsExport = try prepareDiagnosticsDocumentExport()
    889         releasePreparedDocumentExport(diagnosticsExport)
    890         let relayConfigExport = try prepareRelayConfigDocumentExport()
    891         releasePreparedDocumentExport(relayConfigExport)
    892         if let relayImportDocument = try FieldDocumentInterchangeUITestProbe.relayImportDocument(
    893             bundleIdentifier: bundleIdentifier
    894         ) {
    895             let importedRelays = try await applyImportedRelayConfig(from: relayImportDocument)
    896             documentInterchangeProbeValue = [
    897                 documentInterchangeProbeValue,
    898                 "relay_import_applied=true",
    899                 "relay_settings_source=\(relaySettingsSourceLabel)",
    900                 "relay_settings_count=\(importedRelays.count)",
    901                 "relay_settings_contains_production=\(importedRelays.contains("wss://radroots.org"))"
    902             ].compactMap { $0 }.joined(separator: ";")
    903         }
    904         _ = try publicPostShareRequest(content: "  public field update  ")
    905     }
    906 
    907     private func requestExternalAction(
    908         _ action: () async throws -> FieldExternalActionRequestRecord
    909     ) async {
    910         do {
    911             let record = try await action()
    912             externalActionStatus = record.statusText
    913             telemetry.externalAction(operation: "open", kind: record.kind, outcome: "success")
    914         } catch {
    915             externalActionStatus = error.fieldRuntimeMessage
    916             telemetry.externalAction(
    917                 operation: "open",
    918                 kind: nil,
    919                 outcome: FieldTelemetry.externalActionOutcome(for: error)
    920             )
    921         }
    922     }
    923 
    924     private func setLocked(_ value: Bool) {
    925         isLocked = value
    926         UserDefaults.standard.set(value, forKey: lockKey)
    927     }
    928 
    929     private func startConnectingAndPollingStatus(using service: FieldRuntimeService) {
    930         statusTask?.cancel()
    931         statusTask = Task { [weak self] in
    932             do {
    933                 try await self?.connect(using: service)
    934             } catch {
    935                 self?.relayLastError = error.fieldRuntimeMessage
    936                 self?.relayLight = .red
    937             }
    938             while !Task.isCancelled {
    939                 await self?.refreshRuntimeState(using: service)
    940                 try? await Task.sleep(nanoseconds: 1_000_000_000)
    941             }
    942         }
    943     }
    944 
    945     private func startTelemetryProbeRefreshForUITest() {
    946         guard FieldTelemetryUITestProbe.isRequested else {
    947             return
    948         }
    949         telemetryProbeTask?.cancel()
    950         telemetryProbeTask = Task { [weak self] in
    951             while !Task.isCancelled {
    952                 await self?.refreshTelemetryProbeValue()
    953                 try? await Task.sleep(nanoseconds: 250_000_000)
    954             }
    955         }
    956     }
    957 
    958     private func refreshTelemetryProbeValue() async {
    959         telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry)
    960     }
    961 
    962     private func refreshBackgroundExecutionProbe(using backgroundExecution: FieldBackgroundExecution) async {
    963         backgroundExecutionProbeValue = await backgroundExecution.uiTestProbeValue()
    964     }
    965 
    966     private func shortNpub(_ value: String) -> String {
    967         guard value.count > 18 else { return value }
    968         return "\(value.prefix(12))...\(value.suffix(6))"
    969     }
    970 }
    971 
    972 private struct FieldTelemetryRelayStatus: Equatable {
    973     let connectedCount: UInt32
    974     let connectingCount: UInt32
    975     let configuredRelayCount: Int
    976     let light: String
    977 }
    978 
    979 private extension AppState.RelayLight {
    980     var telemetryValue: String {
    981         switch self {
    982         case .red:
    983             "red"
    984         case .yellow:
    985             "yellow"
    986         case .green:
    987             "green"
    988         }
    989     }
    990 }