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 }