field_ios

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

commit 5642f537c0f1da2ce610ac0139e95075b3f022dd
parent cdd53a3d82c49dbd5901e907fa605b80b543e638
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 02:21:06 -0700

app: harden nostr key custody state

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppState.swift | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
MRadroots/Config/Base.xcconfig | 1+
MRadroots/Info.plist | 2++
MRadroots/Runtime/BuildConfig.swift | 1+
MRadroots/Runtime/FieldIdentityCustodyStore.swift | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
ARadroots/Runtime/FieldIdentityPublicMetadataStore.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldRuntimeService.swift | 26++++++++++++++++++++------
MRadroots/Views/SettingsView.swift | 3++-
MRadroots/Views/SetupView.swift | 5+++--
10 files changed, 262 insertions(+), 45 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */; }; D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; }; + D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */; }; D5C58A98C950D45AD027962A /* TradeListing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */; }; D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227028B4EBDC6703999FB9DA /* ToastModifier.swift */; }; DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; @@ -84,6 +85,7 @@ C1D9496F9F05A4E79E73A247 /* RelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaysView.swift; sourceTree = "<group>"; }; C71A93F98C7B93188748B99B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCreateView.swift; sourceTree = "<group>"; }; + CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityPublicMetadataStore.swift; sourceTree = "<group>"; }; CBE1472FFD63A33F3AEA6C6C /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; }; 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>"; }; @@ -172,6 +174,7 @@ children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, F60FA44F589B6E94223CE685 /* FieldIdentityCustodyStore.swift */, + CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, @@ -398,6 +401,7 @@ D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, 3DA8DBB426B71C53724F09B4 /* FieldIdentityCustodyStore.swift in Sources */, + D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -30,6 +30,8 @@ public final class AppState: ObservableObject { @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle @Published public private(set) var infoJSONString: String = "" @Published public private(set) var hasKey: Bool = false + @Published public private(set) var storedIdentityAvailable: Bool = false + @Published public private(set) var runtimeIdentityReady: Bool = false @Published public private(set) var isLocked: Bool = false @Published public private(set) var npub: String? @Published public private(set) var identityLabel: String? @@ -40,11 +42,11 @@ public final class AppState: ObservableObject { @Published public private(set) var relayLastError: String? public var canShowAppContent: Bool { - bootstrapPhase == .ready && hasKey && !isLocked + bootstrapPhase == .ready && runtimeIdentityReady && !isLocked } public var requiresSetup: Bool { - bootstrapPhase == .ready && (!hasKey || isLocked) + bootstrapPhase == .ready && (!storedIdentityAvailable || isLocked || !runtimeIdentityReady) } public var identityDisplayName: String { @@ -67,6 +69,7 @@ public final class AppState: ObservableObject { private let lockKey = "field_ios.identity_locked" private var statusTask: Task<Void, Never>? private var identityCustodyStore: FieldIdentityCustodyStore? + private var identityMetadataStore: FieldIdentityPublicMetadataStore? public init(radroots: Radroots = Radroots()) { self.radroots = radroots @@ -86,16 +89,20 @@ public final class AppState: ObservableObject { } let service = try radroots.start() let custodyStore = try FieldIdentityCustodyStore.configured() + let metadataStore = try FieldIdentityPublicMetadataStore.configured() identityCustodyStore = custodyStore + identityMetadataStore = metadataStore if BuildConfig.bool(.resetLocalState) == true { try custodyStore.resetLocalState(bundleIdentifier: try bundleIdentifier()) - try await removeAllIdentities(using: service) + metadataStore.delete() + try await clearRuntimeIdentityState(using: service) + applyNoIdentity() setLocked(false) } else { - try await restorePersistedIdentity(using: service, custodyStore: custodyStore) + loadStoredIdentityMetadata(metadataStore) } try await refreshRuntimeState(using: service) - if hasKey && !isLocked { + if runtimeIdentityReady && !isLocked { try await connect(using: service) startPollingStatus() } @@ -124,7 +131,7 @@ public final class AppState: ObservableObject { public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() - try await persistSelectedIdentity(using: service) + try await restoreStoredIdentity(using: service) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -133,8 +140,7 @@ public final class AppState: ObservableObject { public func createLocalIdentity() async throws { let service = try requireRuntimeService() - _ = try await service.nostrIdentityGenerate(label: "Radroots Field", makeSelected: true) - try await persistSelectedIdentity(using: service) + try await createHostCustodyIdentity(using: service) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -145,12 +151,12 @@ public final class AppState: ObservableObject { let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let service = try requireRuntimeService() - _ = try await service.nostrIdentityImportSecret( + let record = try await service.nostrIdentityImportSecret( secretKey: trimmed, label: "Imported Field Identity", makeSelected: true ) - try await persistSelectedIdentity(using: service) + try persistIdentity(record, secret: trimmed) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -161,12 +167,20 @@ public final class AppState: ObservableObject { setLocked(true) statusTask?.cancel() statusTask = nil + relayConnectedCount = 0 + relayConnectingCount = 0 + relayLight = .red + Task { + await lockRuntimeIdentity() + } } public func resetLocalIdentity() async throws { let service = try requireRuntimeService() try identityCustodyStoreOrConfigured().resetLocalState(bundleIdentifier: try bundleIdentifier()) - try await removeAllIdentities(using: service) + try identityMetadataStoreOrConfigured().delete() + try await clearRuntimeIdentityState(using: service) + applyNoIdentity() setLocked(false) relayConnectedCount = 0 relayConnectingCount = 0 @@ -250,39 +264,112 @@ public final class AppState: ObservableObject { } private func apply(identity snapshot: NostrIdentitySnapshot) { - hasKey = snapshot.hasSelectedSigningIdentity - npub = snapshot.selectedNpub + runtimeIdentityReady = snapshot.hasSelectedSigningIdentity identities = snapshot.identities - identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label + if snapshot.hasSelectedSigningIdentity { + storedIdentityAvailable = true + hasKey = true + npub = snapshot.selectedNpub + identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label + } else if storedIdentityAvailable { + hasKey = true + } else { + hasKey = false + npub = nil + identityLabel = nil + } } - private func removeAllIdentities(using service: FieldRuntimeService) async throws { - try await service.nostrIdentityResetAll() - hasKey = false - npub = nil - identityLabel = nil + private func clearRuntimeIdentityState(using service: FieldRuntimeService) async throws { + try await service.nostrIdentityClearRuntimeState() + runtimeIdentityReady = false identities = [] } - private func restorePersistedIdentity( - using service: FieldRuntimeService, - custodyStore: FieldIdentityCustodyStore - ) async throws { - let snapshot = try await service.nostrIdentitySnapshot() - guard !snapshot.hasSelectedSigningIdentity else { return } - guard let secretHex = try custodyStore.loadSelectedSecretHex() else { return } - _ = try await service.nostrIdentityImportSecret( - secretKey: secretHex, - label: "Radroots Field", + private func loadStoredIdentityMetadata(_ metadataStore: FieldIdentityPublicMetadataStore) { + guard let metadata = metadataStore.load() else { + applyNoIdentity() + setLocked(false) + return + } + apply(storedIdentity: metadata) + setLocked(true) + } + + private func restoreStoredIdentity(using service: FieldRuntimeService) async throws { + guard let secret = try identityCustodyStoreOrConfigured().loadSelectedSecretHex() else { + throw FieldIdentityCustodyError.missingSelectedSecret + } + let existingMetadata = try identityMetadataStoreOrConfigured().load() + let record = try await service.nostrIdentityRestoreHostSecret( + secretKey: secret, + label: existingMetadata?.label ?? "Radroots Field", makeSelected: true ) + try persistIdentity(record, secret: secret) + } + + private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws { + var lastError: Error? + for _ in 0..<8 { + let secret = try FieldIdentityCustodyStore.generateSecretHex() + let record: NostrIdentityRecord + do { + record = try await service.nostrIdentityRestoreHostSecret( + secretKey: secret, + label: "Radroots Field", + makeSelected: true + ) + } catch { + lastError = error + continue + } + try persistIdentity(record, secret: secret) + return + } + throw lastError ?? FieldIdentityCustodyError.missingSelectedSecret } - private func persistSelectedIdentity(using service: FieldRuntimeService) async throws { - guard let secretHex = try await service.nostrIdentityExportSelectedSecretHex() else { - throw FieldIdentityCustodyError.missingSelectedSecret + private func persistIdentity(_ record: NostrIdentityRecord, secret: String) throws { + try identityCustodyStoreOrConfigured().saveSelectedSecret(secret) + let metadata = FieldIdentityPublicMetadata(record: record) + try identityMetadataStoreOrConfigured().save(metadata) + apply(storedIdentity: metadata) + runtimeIdentityReady = true + hasKey = true + identities = [record] + } + + private func lockRuntimeIdentity() async { + guard let service = runtimeService else { + runtimeIdentityReady = false + identities = [] + hasKey = storedIdentityAvailable + return + } + do { + try await clearRuntimeIdentityState(using: service) + } catch { + relayLastError = error.localizedDescription } - try identityCustodyStoreOrConfigured().saveSelectedSecretHex(secretHex) + hasKey = storedIdentityAvailable + await refreshRelayStatus(using: service) + } + + private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) { + storedIdentityAvailable = true + hasKey = true + npub = metadata.publicKeyNpub + identityLabel = metadata.label + } + + private func applyNoIdentity() { + hasKey = false + storedIdentityAvailable = false + runtimeIdentityReady = false + npub = nil + identityLabel = nil + identities = [] } private func identityCustodyStoreOrConfigured() throws -> FieldIdentityCustodyStore { @@ -294,6 +381,15 @@ public final class AppState: ObservableObject { return configured } + private func identityMetadataStoreOrConfigured() throws -> FieldIdentityPublicMetadataStore { + if let identityMetadataStore { + return identityMetadataStore + } + let configured = try FieldIdentityPublicMetadataStore.configured() + identityMetadataStore = configured + return configured + } + private func bundleIdentifier() throws -> String { guard let bundleIdentifier = Bundle.main.bundleIdentifier, !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig @@ -8,5 +8,6 @@ RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = true RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios +RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY = user_presence_local RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY = diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -71,6 +71,8 @@ <string>$(RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS)</string> <key>RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX</key> <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX)</string> + <key>RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY</key> + <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY)</string> <key>RADROOTS_FIELD_IOS_RESET_LOCAL_STATE</key> <string>$(RADROOTS_FIELD_IOS_RESET_LOCAL_STATE)</string> <key>RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY</key> diff --git a/Radroots/Runtime/BuildConfig.swift b/Radroots/Runtime/BuildConfig.swift @@ -9,6 +9,7 @@ enum BuildConfigKey: String { case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME" case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS" case keychainServicePrefix = "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX" + case keychainAccessPolicy = "RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY" case resetLocalState = "RADROOTS_FIELD_IOS_RESET_LOCAL_STATE" case tradeRhiPubkey = "RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY" } diff --git a/Radroots/Runtime/FieldIdentityCustodyStore.swift b/Radroots/Runtime/FieldIdentityCustodyStore.swift @@ -1,11 +1,15 @@ import Foundation import RadrootsKit +import Security enum FieldIdentityCustodyError: LocalizedError { case missingKeychainServicePrefix case missingBundleIdentifier case invalidStoredSecret case missingSelectedSecret + case missingKeychainAccessPolicy + case invalidKeychainAccessPolicy(String) + case randomSecretGenerationFailed(Int32) var errorDescription: String? { switch self { @@ -17,6 +21,26 @@ enum FieldIdentityCustodyError: LocalizedError { "Stored Nostr identity secret is invalid." case .missingSelectedSecret: "No selected Nostr identity secret is available to secure." + case .missingKeychainAccessPolicy: + "Missing RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY." + case .invalidKeychainAccessPolicy(let value): + "Invalid RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY: \(value)." + case .randomSecretGenerationFailed(let status): + "Secure Nostr identity generation failed with status \(status)." + } + } +} + +enum FieldIdentityKeychainAccessPolicy: String { + case userPresenceLocal = "user_presence_local" + case secureLocal = "secure_local" + + var storePolicy: RadrootsSecretAccessPolicy { + switch self { + case .userPresenceLocal: + .userPresenceLocalSecret + case .secureLocal: + .secureLocalSecret } } } @@ -56,12 +80,16 @@ struct FieldIdentityCustodyStore { return trimmed.isEmpty ? nil : trimmed } - func saveSelectedSecretHex(_ secretHex: String) throws { - let trimmed = secretHex.trimmingCharacters(in: .whitespacesAndNewlines) + func saveSelectedSecret(_ secret: String) throws { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw FieldIdentityCustodyError.missingSelectedSecret } - try store.put(Data(trimmed.utf8), for: Self.selectedSecretKey, policy: .secureLocalSecret) + try store.put( + Data(trimmed.utf8), + for: Self.selectedSecretKey, + policy: try Self.configuredAccessPolicy().storePolicy + ) } func deleteSelectedSecret() throws { @@ -85,6 +113,25 @@ struct FieldIdentityCustodyStore { try selectedSecretKey.serviceName(servicePrefix: servicePrefix) } + static func configuredAccessPolicy() throws -> FieldIdentityKeychainAccessPolicy { + guard let rawValue = BuildConfig.string(.keychainAccessPolicy) else { + throw FieldIdentityCustodyError.missingKeychainAccessPolicy + } + guard let policy = FieldIdentityKeychainAccessPolicy(rawValue: rawValue) else { + throw FieldIdentityCustodyError.invalidKeychainAccessPolicy(rawValue) + } + return policy + } + + static func generateSecretHex() throws -> String { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw FieldIdentityCustodyError.randomSecretGenerationFailed(status) + } + return bytes.map { String(format: "%02x", $0) }.joined() + } + private static var selectedSecretKey: RadrootsSecureStoreKey { RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName) } diff --git a/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift b/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift @@ -0,0 +1,50 @@ +import Foundation + +struct FieldIdentityPublicMetadata: Codable, Equatable, Sendable { + let selectedIdentityId: String + let publicKeyHex: String + let publicKeyNpub: String + let label: String? + let updatedAtUnix: UInt64 + + init(record: NostrIdentityRecord, updatedAtUnix: UInt64 = UInt64(Date().timeIntervalSince1970)) { + self.selectedIdentityId = record.id + self.publicKeyHex = record.publicKeyHex + self.publicKeyNpub = record.publicKeyNpub + self.label = record.label + self.updatedAtUnix = updatedAtUnix + } +} + +struct FieldIdentityPublicMetadataStore { + private let userDefaults: UserDefaults + private let key: String + + init(servicePrefix: String, userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + self.key = "field_ios.identity.public_metadata.\(servicePrefix)" + } + + static func configured() throws -> FieldIdentityPublicMetadataStore { + guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { + throw FieldIdentityCustodyError.missingKeychainServicePrefix + } + return FieldIdentityPublicMetadataStore(servicePrefix: servicePrefix) + } + + func load() -> FieldIdentityPublicMetadata? { + guard let data = userDefaults.data(forKey: key) else { + return nil + } + return try? JSONDecoder().decode(FieldIdentityPublicMetadata.self, from: data) + } + + func save(_ metadata: FieldIdentityPublicMetadata) throws { + let data = try JSONEncoder().encode(metadata) + userDefaults.set(data, forKey: key) + } + + func delete() { + userDefaults.removeObject(forKey: key) + } +} diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -52,7 +52,7 @@ public final class FieldRuntimeService: @unchecked Sendable { try await run { try $0.nostrIdentityList() } } - public func nostrIdentityGenerate(label: String?, makeSelected: Bool) async throws -> String { + public func nostrIdentityGenerate(label: String?, makeSelected: Bool) async throws -> NostrIdentityRecord { try await run { try $0.nostrIdentityGenerate(label: label, makeSelected: makeSelected) } } @@ -60,7 +60,7 @@ public final class FieldRuntimeService: @unchecked Sendable { secretKey: String, label: String?, makeSelected: Bool - ) async throws -> String { + ) async throws -> NostrIdentityRecord { try await run { try $0.nostrIdentityImportSecret( secretKey: secretKey, @@ -70,16 +70,30 @@ public final class FieldRuntimeService: @unchecked Sendable { } } + public func nostrIdentityRestoreHostSecret( + secretKey: String, + label: String?, + makeSelected: Bool + ) async throws -> NostrIdentityRecord { + try await run { + try $0.nostrIdentityRestoreHostSecret( + secretKey: secretKey, + label: label, + makeSelected: makeSelected + ) + } + } + public func nostrIdentityRemove(identityId: String) async throws { try await run { try $0.nostrIdentityRemove(identityId: identityId) } } - public func nostrIdentityResetAll() async throws { - try await run { try $0.nostrIdentityResetAll() } + public func nostrIdentityClearRuntimeState() async throws { + try await run { try $0.nostrIdentityClearRuntimeState() } } - public func nostrIdentityExportSelectedSecretHex() async throws -> String? { - try await run { try $0.nostrIdentityExportSelectedSecretHex() } + public func nostrIdentityResetAll() async throws { + try await run { try $0.nostrIdentityResetAll() } } public func nostrProfileForSelf() async -> NostrProfileEventMetadata? { diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -16,7 +16,8 @@ struct SettingsView: View { Text("No local Nostr identity is selected.") .foregroundStyle(.secondary) } - LabeledContent("Stored identities", value: "\(app.identities.count)") + LabeledContent("Stored identities", value: app.storedIdentityAvailable ? "1" : "0") + LabeledContent("Runtime identity", value: app.runtimeIdentityReady ? "Ready" : "Locked") NavigationLink { ProfileView() diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -155,10 +155,11 @@ struct SetupView: View { private func importIdentity() { errorMessage = nil isWorking = true + let submittedSecret = secretKey + secretKey = "" Task { do { - try await app.importNostrSecret(secretKey) - secretKey = "" + try await app.importNostrSecret(submittedSecret) onSuccess?() } catch { errorMessage = error.localizedDescription