commit f389c69a922cf47ce41de9d7db9c9f6ef64390d0
parent adb5bbcfe2602417de3e62222542ec001b217e29
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 23:57:12 -0700
app: replace auth login with nostr onboarding
- add an async runtime service for serialized Rust FFI calls
- replace accounts login state with local Nostr identity setup
- make sign out non-destructive and identity reset explicit
- remove auth endpoint config and session token storage from Swift
Diffstat:
21 files changed, 555 insertions(+), 606 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -35,12 +35,11 @@
C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; };
C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE790CA1CD31208947913B9 /* Logger.swift */; };
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 */; };
- D57D0AED4A22098FB804B5AF /* AuthSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F392F542A3EE3AF60E02D45 /* AuthSettings.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 */; };
- DF1CB54078BD8238247D3D3A /* FieldSessionCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.swift */; };
E1EDAEE6B182025ACAF754A6 /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */; };
E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA7BAA021EE13E829390B /* Bundle+Build.swift */; };
EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; };
@@ -63,8 +62,6 @@
41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; };
466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; };
4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; };
- 4F392F542A3EE3AF60E02D45 /* AuthSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSettings.swift; sourceTree = "<group>"; };
- 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSessionCredentialStore.swift; sourceTree = "<group>"; };
54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; };
63189EB90A86A9929BECD9ED /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; };
676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
@@ -94,6 +91,7 @@
DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; };
E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDump.swift; sourceTree = "<group>"; };
+ E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldRuntimeService.swift; sourceTree = "<group>"; };
F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedViewModel.swift; sourceTree = "<group>"; };
F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = "<group>"; };
F4C7DE4207398DE242519F9C /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyButton.swift; sourceTree = "<group>"; };
@@ -171,9 +169,8 @@
688000357CB95AB5B3067911 /* Runtime */ = {
isa = PBXGroup;
children = (
- 4F392F542A3EE3AF60E02D45 /* AuthSettings.swift */,
A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */,
- 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.swift */,
+ E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */,
D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */,
63189EB90A86A9929BECD9ED /* Nostr.swift */,
8F0F21496E7A8490EB14AC5B /* Radroots.swift */,
@@ -402,13 +399,12 @@
C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */,
360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */,
049D620DD8C02816893BF765 /* AppState.swift in Sources */,
- D57D0AED4A22098FB804B5AF /* AuthSettings.swift in Sources */,
3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */,
E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */,
9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */,
D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */,
4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */,
- DF1CB54078BD8238247D3D3A /* FieldSessionCredentialStore.swift in Sources */,
+ D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */,
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */,
C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */,
B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */,
diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift
@@ -13,6 +13,10 @@ public struct AppEntry<Main: View>: View {
switch appState.bootstrapPhase {
case .idle, .starting:
SplashView()
+ case .failed(let message):
+ StartupFailureView(message: message) {
+ appState.retryStartup()
+ }
case .ready:
if appState.canShowAppContent {
main()
@@ -27,6 +31,39 @@ public struct AppEntry<Main: View>: View {
}
}
+private struct StartupFailureView: View {
+ let message: String
+ let onRetry: () -> Void
+
+ var body: some View {
+ VStack(spacing: 18) {
+ Spacer()
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 56, weight: .semibold))
+ .foregroundStyle(.red)
+ Text("Startup failed")
+ .font(.title2.weight(.semibold))
+ Text(message)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .textSelection(.enabled)
+ Spacer()
+ Button {
+ onRetry()
+ } label: {
+ Label("Retry", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .accessibilityIdentifier("field_ios.bootstrap.retry")
+ }
+ .padding()
+ .accessibilityIdentifier("field_ios.bootstrap.failed")
+ }
+}
+
private struct SplashView: View {
var body: some View {
ZStack {
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -1,25 +1,23 @@
import Foundation
-enum FieldAppSessionError: LocalizedError {
+enum FieldAppRuntimeError: LocalizedError {
case runtimeNotReady
- case missingSessionTokenBundle
var errorDescription: String? {
switch self {
case .runtimeNotReady:
- "Runtime not ready. Please relaunch."
- case .missingSessionTokenBundle:
- "The authenticated session did not return tokens."
+ "Runtime not ready. Please retry."
}
}
}
@MainActor
public final class AppState: ObservableObject {
- public enum BootstrapPhase {
+ public enum BootstrapPhase: Equatable {
case idle
case starting
case ready
+ case failed(String)
}
public enum RelayLight {
@@ -28,32 +26,47 @@ 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 sessionPhase: FieldSessionPhase = .signedOut
- @Published public private(set) var username: String?
- @Published public private(set) var accountDisplayName: String?
- @Published public private(set) var pendingChallenge: FieldLoginChallenge?
@Published public private(set) var hasKey: Bool = false
+ @Published public private(set) var isLocked: Bool = false
@Published public private(set) var npub: String?
+ @Published public private(set) var identityLabel: String?
+ @Published public private(set) var identities: [NostrIdentityRecord] = []
@Published public private(set) var relayConnectedCount: UInt32 = 0
@Published public private(set) var relayConnectingCount: UInt32 = 0
@Published public private(set) var relayLight: RelayLight = .red
@Published public private(set) var relayLastError: String?
public var canShowAppContent: Bool {
- bootstrapPhase == .ready && sessionPhase == .authenticated
+ bootstrapPhase == .ready && hasKey && !isLocked
}
public var requiresSetup: Bool {
- bootstrapPhase == .ready && sessionPhase != .authenticated
+ bootstrapPhase == .ready && (!hasKey || isLocked)
+ }
+
+ public var identityDisplayName: String {
+ if let label = identityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !label.isEmpty {
+ return label
+ }
+ if let npub {
+ return shortNpub(npub)
+ }
+ return "Local Nostr identity"
}
public let radroots: Radroots
- private var sessionStore: FieldSessionCredentialStore?
+ public var runtimeService: FieldRuntimeService? {
+ radroots.runtimeService
+ }
+
+ private let lockKey = "field_ios.identity_locked"
private var statusTask: Task<Void, Never>?
public init(radroots: Radroots = Radroots()) {
self.radroots = radroots
+ self.isLocked = UserDefaults.standard.bool(forKey: lockKey)
}
deinit {
@@ -61,155 +74,185 @@ public final class AppState: ObservableObject {
}
public func start() async throws {
- guard bootstrapPhase == .idle else { return }
+ guard bootstrapPhase == .idle || isFailed else { return }
bootstrapPhase = .starting
do {
- try radroots.start()
- let store = try FieldSessionCredentialStore()
- sessionStore = store
+ let service = try radroots.start()
if BuildConfig.bool(.resetLocalState) == true {
- try? store.delete()
+ try await removeAllIdentities(using: service)
+ setLocked(false)
}
- if let rt = radroots.runtime {
- try configure(runtime: rt)
- try restoreSessionIfPossible(runtime: rt, store: store)
+ try await configureRelays(using: service)
+ try await refreshRuntimeState(using: service)
+ if hasKey && !isLocked {
+ try await connect(using: service)
+ startPollingStatus()
}
- refresh()
bootstrapPhase = .ready
} catch {
- bootstrapPhase = .idle
+ statusTask?.cancel()
+ statusTask = nil
+ let message = error.localizedDescription
+ bootstrapPhase = .failed(message)
throw error
}
}
+ public func retryStartup() {
+ bootstrapPhase = .idle
+ Task {
+ try? await start()
+ }
+ }
+
public func refresh() {
- guard let rt = radroots.runtime else { return }
- infoJSONString = rt.infoJson()
- apply(snapshot: rt.fieldSessionSnapshot())
+ Task {
+ await refreshRuntimeState()
+ }
}
- @discardableResult
- public func startLogin(username: String) throws -> FieldLoginChallenge {
- let rt = try requireRuntime()
- let challenge = try rt.fieldStartLogin(username: username)
- apply(snapshot: rt.fieldSessionSnapshot())
- return challenge
+ public func continueWithLocalIdentity() async throws {
+ let service = try requireRuntimeService()
+ setLocked(false)
+ try await connect(using: service)
+ await refreshRuntimeState(using: service)
+ startPollingStatus()
}
- @discardableResult
- public func resendLoginChallenge(challengeId: String) throws -> FieldLoginChallenge {
- let rt = try requireRuntime()
- let challenge = try rt.fieldResendLoginChallenge(challengeId: challengeId)
- apply(snapshot: rt.fieldSessionSnapshot())
- return challenge
+ public func createLocalIdentity() async throws {
+ let service = try requireRuntimeService()
+ _ = try await service.nostrIdentityGenerate(label: "Radroots Field", makeSelected: true)
+ setLocked(false)
+ try await connect(using: service)
+ await refreshRuntimeState(using: service)
+ startPollingStatus()
}
- public func verifyLogin(challengeId: String, code: String) throws {
- let rt = try requireRuntime()
- let snapshot = try rt.fieldVerifyLoginChallenge(challengeId: challengeId, code: code)
- apply(snapshot: snapshot)
- guard let tokens = try rt.fieldSessionTokenBundle() else {
- throw FieldAppSessionError.missingSessionTokenBundle
- }
- try sessionStore?.save(tokens)
- prepareAuthenticatedRuntime()
+ public func importNostrSecret(_ secretKey: String) async throws {
+ let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ let service = try requireRuntimeService()
+ _ = try await service.nostrIdentityImportSecret(
+ secretKey: trimmed,
+ label: "Imported Field Identity",
+ makeSelected: true
+ )
+ setLocked(false)
+ try await connect(using: service)
+ await refreshRuntimeState(using: service)
+ startPollingStatus()
}
- public func logout() {
- guard let rt = radroots.runtime else { return }
- do {
- _ = try rt.fieldRevokeSession()
- } catch {
- relayLastError = error.localizedDescription
- }
- try? sessionStore?.delete()
- apply(snapshot: rt.fieldClearSession())
+ public func signOut() {
+ setLocked(true)
statusTask?.cancel()
statusTask = nil
}
- private func configure(runtime: RadrootsRuntime) throws {
- try runtime.fieldConfigureAuth(
- authApiBaseUrl: try AuthSettings.authApiBaseURL(),
- accountsApiBaseUrl: AuthSettings.accountsApiBaseURL()
- )
+ public func resetLocalIdentity() async throws {
+ let service = try requireRuntimeService()
+ try await removeAllIdentities(using: service)
+ setLocked(false)
+ relayConnectedCount = 0
+ relayConnectingCount = 0
+ relayLight = .red
+ relayLastError = nil
+ await refreshRuntimeState(using: service)
+ statusTask?.cancel()
+ statusTask = nil
}
- private func restoreSessionIfPossible(
- runtime: RadrootsRuntime,
- store: FieldSessionCredentialStore
- ) throws {
- guard let tokens = try store.load() else {
- apply(snapshot: runtime.fieldSessionSnapshot())
- return
- }
- do {
- let snapshot = try runtime.fieldRestoreSession(
- accessToken: tokens.accessToken,
- refreshToken: tokens.refreshToken
- )
- apply(snapshot: snapshot)
- prepareAuthenticatedRuntime()
- } catch {
- try? store.delete()
- apply(snapshot: runtime.fieldClearSession())
+ public func requireRuntimeService() throws -> FieldRuntimeService {
+ guard let service = runtimeService else {
+ throw FieldAppRuntimeError.runtimeNotReady
}
+ return service
}
- private func prepareAuthenticatedRuntime() {
- guard let rt = radroots.runtime else { return }
- do {
- let snapshot = try rt.fieldPrepareAuthenticatedNostr(relays: try RelaySettings.relays())
- relayLastError = nil
- apply(snapshot: snapshot)
- } catch {
- relayLastError = error.localizedDescription
+ private var isFailed: Bool {
+ if case .failed = bootstrapPhase {
+ return true
}
- startPollingStatus()
+ return false
}
- private func startPollingStatus() {
- statusTask?.cancel()
- statusTask = Task { [weak self] in
- while !Task.isCancelled {
- await MainActor.run { self?.refreshRelayStatus() }
- try? await Task.sleep(nanoseconds: 1_000_000_000)
- }
- }
+ private func configureRelays(using service: FieldRuntimeService) async throws {
+ try await service.nostrSetDefaultRelays(try RelaySettings.relays())
}
- private func refreshRelayStatus() {
- guard let rt = radroots.runtime else { return }
- apply(snapshot: rt.fieldSessionSnapshot())
+ private func connect(using service: FieldRuntimeService) async throws {
+ try await configureRelays(using: service)
+ try await service.nostrConnectIfKeyPresent()
+ await refreshRelayStatus(using: service)
+ relayLastError = nil
}
- private func apply(snapshot: FieldSessionSnapshot) {
- sessionPhase = snapshot.phase
- pendingChallenge = snapshot.pendingChallenge
- username = snapshot.account?.username
- accountDisplayName = snapshot.account?.displayName
- npub = snapshot.selectedNpub
- hasKey = snapshot.selectedNpub != nil
- relayConnectedCount = snapshot.nostrConnected
- relayConnectingCount = snapshot.nostrConnecting
- relayLastError = snapshot.nostrLastError ?? relayLastError
+ private func refreshRuntimeState() async {
+ guard let service = runtimeService else { return }
+ await refreshRuntimeState(using: service)
+ }
- switch snapshot.nostrLight {
+ private func refreshRuntimeState(using service: FieldRuntimeService) async {
+ infoJSONString = await service.infoJson()
+ do {
+ let snapshot = try await service.nostrIdentitySnapshot()
+ apply(identity: snapshot)
+ } catch {
+ relayLastError = error.localizedDescription
+ }
+ await refreshRelayStatus(using: service)
+ }
+
+ private func refreshRelayStatus(using service: FieldRuntimeService) async {
+ let status = await service.nostrConnectionStatus()
+ relayConnectedCount = status.connected
+ relayConnectingCount = status.connecting
+ relayLastError = status.lastError ?? relayLastError
+ switch status.light {
case .green:
relayLight = .green
case .yellow:
relayLight = .yellow
case .red:
relayLight = .red
- @unknown default:
- relayLight = .red
}
}
- private func requireRuntime() throws -> RadrootsRuntime {
- guard let rt = radroots.runtime else {
- throw FieldAppSessionError.runtimeNotReady
+ private func apply(identity snapshot: NostrIdentitySnapshot) {
+ hasKey = snapshot.hasSelectedSigningIdentity
+ npub = snapshot.selectedNpub
+ identities = snapshot.identities
+ identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label
+ }
+
+ private func removeAllIdentities(using service: FieldRuntimeService) async throws {
+ let existing = try await service.nostrIdentityList()
+ for identity in existing {
+ try await service.nostrIdentityRemove(identityId: identity.id)
+ }
+ hasKey = false
+ npub = nil
+ identityLabel = nil
+ identities = []
+ }
+
+ private func setLocked(_ value: Bool) {
+ isLocked = value
+ UserDefaults.standard.set(value, forKey: lockKey)
+ }
+
+ private func startPollingStatus() {
+ statusTask?.cancel()
+ statusTask = Task { [weak self] in
+ while !Task.isCancelled {
+ await self?.refreshRuntimeState()
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ }
}
- return rt
+ }
+
+ private func shortNpub(_ value: String) -> String {
+ guard value.count > 18 else { return value }
+ return "\(value.prefix(12))...\(value.suffix(6))"
}
}
diff --git a/Radroots/Runtime/AuthSettings.swift b/Radroots/Runtime/AuthSettings.swift
@@ -1,25 +0,0 @@
-import Foundation
-
-enum AuthSettingsError: LocalizedError {
- case missingAuthApiBaseURL
-
- var errorDescription: String? {
- switch self {
- case .missingAuthApiBaseURL:
- "No auth API base URL configured. Set 'RADROOTS_FIELD_IOS_AUTH_API_BASE_URL'."
- }
- }
-}
-
-enum AuthSettings {
- static func authApiBaseURL() throws -> String {
- guard let value = BuildConfig.string(.authApiBaseUrl) else {
- throw AuthSettingsError.missingAuthApiBaseURL
- }
- return value
- }
-
- static func accountsApiBaseURL() -> String? {
- BuildConfig.string(.accountsApiBaseUrl)
- }
-}
diff --git a/Radroots/Runtime/BuildConfig.swift b/Radroots/Runtime/BuildConfig.swift
@@ -8,8 +8,6 @@ enum BuildConfigKey: String {
case loggingFileEnabled = "RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED"
case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME"
case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS"
- case authApiBaseUrl = "RADROOTS_FIELD_IOS_AUTH_API_BASE_URL"
- case accountsApiBaseUrl = "RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL"
case keychainServicePrefix = "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX"
case resetLocalState = "RADROOTS_FIELD_IOS_RESET_LOCAL_STATE"
case tradeRhiPubkey = "RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY"
diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift
@@ -0,0 +1,91 @@
+import Foundation
+
+public final class FieldRuntimeService: @unchecked Sendable {
+ private let runtime: RadrootsRuntime
+ private let queue = DispatchQueue(label: "org.radroots.field_ios.runtime", qos: .userInitiated)
+
+ public init(runtime: RadrootsRuntime) {
+ self.runtime = runtime
+ }
+
+ func run<T>(_ work: @escaping @Sendable (RadrootsRuntime) throws -> T) async throws -> T {
+ try await withCheckedThrowingContinuation { continuation in
+ queue.async {
+ do {
+ continuation.resume(returning: try work(self.runtime))
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ func runValue<T>(_ work: @escaping @Sendable (RadrootsRuntime) -> T) async -> T {
+ await withCheckedContinuation { continuation in
+ queue.async {
+ continuation.resume(returning: work(self.runtime))
+ }
+ }
+ }
+
+ public func infoJson() async -> String {
+ await runValue { $0.infoJson() }
+ }
+
+ public func nostrSetDefaultRelays(_ relays: [String]) async throws {
+ try await run { try $0.nostrSetDefaultRelays(relays: relays) }
+ }
+
+ public func nostrConnectIfKeyPresent() async throws {
+ try await run { try $0.nostrConnectIfKeyPresent() }
+ }
+
+ public func nostrConnectionStatus() async -> NostrConnectionStatus {
+ await runValue { $0.nostrConnectionStatus() }
+ }
+
+ public func nostrIdentitySnapshot() async throws -> NostrIdentitySnapshot {
+ try await run { try $0.nostrIdentitySnapshot() }
+ }
+
+ public func nostrIdentityList() async throws -> [NostrIdentityRecord] {
+ try await run { try $0.nostrIdentityList() }
+ }
+
+ public func nostrIdentityGenerate(label: String?, makeSelected: Bool) async throws -> String {
+ try await run { try $0.nostrIdentityGenerate(label: label, makeSelected: makeSelected) }
+ }
+
+ public func nostrIdentityImportSecret(
+ secretKey: String,
+ label: String?,
+ makeSelected: Bool
+ ) async throws -> String {
+ try await run {
+ try $0.nostrIdentityImportSecret(
+ secretKey: secretKey,
+ label: label,
+ makeSelected: makeSelected
+ )
+ }
+ }
+
+ public func nostrIdentityRemove(identityId: String) async throws {
+ try await run { try $0.nostrIdentityRemove(identityId: identityId) }
+ }
+
+ public func nostrProfileForSelf() async -> NostrProfileEventMetadata? {
+ await runValue { $0.nostrProfileForSelf() }
+ }
+
+ public func nostrFetchTextNotes(
+ limit: UInt16,
+ sinceUnix: UInt64?
+ ) async throws -> [NostrPostEventMetadata] {
+ try await run { try $0.nostrFetchTextNotes(limit: limit, sinceUnix: sinceUnix) }
+ }
+
+ public func nostrNextPostStreamEvent() async -> NostrPostEventMetadata? {
+ await runValue { $0.nostrNextPostEvent() }
+ }
+}
diff --git a/Radroots/Runtime/FieldSessionCredentialStore.swift b/Radroots/Runtime/FieldSessionCredentialStore.swift
@@ -1,52 +0,0 @@
-import Foundation
-import RadrootsKit
-
-struct StoredFieldSessionTokens: Codable, Equatable {
- let accessToken: String
- let refreshToken: String
-}
-
-enum FieldSessionCredentialStoreError: LocalizedError {
- case missingKeychainServicePrefix
-
- var errorDescription: String? {
- switch self {
- case .missingKeychainServicePrefix:
- "No Keychain service prefix configured. Set 'RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX'."
- }
- }
-}
-
-final class FieldSessionCredentialStore {
- private let secureStore: any RadrootsSecureStore
- private let key = RadrootsSecureStoreKey(namespace: "field_ios", name: "session_tokens")
-
- init(secureStore: (any RadrootsSecureStore)? = nil) throws {
- if let secureStore {
- self.secureStore = secureStore
- } else {
- guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else {
- throw FieldSessionCredentialStoreError.missingKeychainServicePrefix
- }
- self.secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
- }
- }
-
- func load() throws -> StoredFieldSessionTokens? {
- guard let data = try secureStore.get(key) else { return nil }
- return try JSONDecoder().decode(StoredFieldSessionTokens.self, from: data)
- }
-
- func save(_ tokens: FieldSessionTokenBundle) throws {
- let stored = StoredFieldSessionTokens(
- accessToken: tokens.accessToken,
- refreshToken: tokens.refreshToken
- )
- let data = try JSONEncoder().encode(stored)
- try secureStore.put(data, for: key, policy: .secureLocalSecret)
- }
-
- func delete() throws {
- try secureStore.delete(key)
- }
-}
diff --git a/Radroots/Runtime/LoggingSettings.swift b/Radroots/Runtime/LoggingSettings.swift
@@ -35,8 +35,6 @@ struct LoggingSettings: Equatable {
.loggingFileEnabled,
.loggingFileName,
.nostrRelayUrls,
- .authApiBaseUrl,
- .accountsApiBaseUrl,
.keychainServicePrefix,
.resetLocalState,
.tradeRhiPubkey,
diff --git a/Radroots/Runtime/Nostr.swift b/Radroots/Runtime/Nostr.swift
@@ -20,35 +20,26 @@ public extension NostrProfileEventMetadata {
}
}
-public enum RadrootsRuntimeError: LocalizedError {
- case runtimeNotStarted
-
- public var errorDescription: String? {
- "Radroots runtime not started."
- }
-}
-
-@MainActor
-public extension Radroots {
+public extension FieldRuntimeService {
func nostrPostProfile(
name: String? = nil,
displayName: String? = nil,
nip05: String? = nil,
about: String? = nil
- ) throws -> NostrEventId {
- let rt = try requireRuntime()
- let id = try rt.nostrPostProfile(
- name: name,
- displayName: displayName,
- nip05: nip05,
- about: about
- )
+ ) async throws -> NostrEventId {
+ let id = try await run {
+ try $0.nostrPostProfile(
+ name: name,
+ displayName: displayName,
+ nip05: nip05,
+ about: about
+ )
+ }
return NostrEventId(id)
}
- func nostrPostTextNote(content: String) throws -> NostrEventId {
- let rt = try requireRuntime()
- let id = try rt.nostrPostTextNote(content: content)
+ func nostrPostTextNote(content: String) async throws -> NostrEventId {
+ let id = try await run { try $0.nostrPostTextNote(content: content) }
return NostrEventId(id)
}
@@ -57,37 +48,23 @@ public extension Radroots {
parentAuthorHex: String,
content: String,
rootEventIdHex: String? = nil
- ) throws -> NostrEventId {
- let rt = try requireRuntime()
- let id = try rt.nostrPostReply(
- parentEventIdHex: parentEventIdHex,
- parentAuthorHex: parentAuthorHex,
- content: content,
- rootEventIdHex: rootEventIdHex
- )
+ ) async throws -> NostrEventId {
+ let id = try await run {
+ try $0.nostrPostReply(
+ parentEventIdHex: parentEventIdHex,
+ parentAuthorHex: parentAuthorHex,
+ content: content,
+ rootEventIdHex: rootEventIdHex
+ )
+ }
return NostrEventId(id)
}
- func nostrStartPostStream(sinceUnix: UInt64? = nil) throws {
- let rt = try requireRuntime()
- try rt.nostrStartPostEventStream(sinceUnix: sinceUnix)
+ func nostrStartPostStream(sinceUnix: UInt64? = nil) async throws {
+ try await run { try $0.nostrStartPostEventStream(sinceUnix: sinceUnix) }
}
- func nostrNextPostStreamEvent() -> NostrPostEventMetadata? {
- guard let rt = runtime else { return nil }
- return rt.nostrNextPostEvent()
- }
-
- func nostrStopPostStream() throws {
- let rt = try requireRuntime()
- try rt.nostrStopPostEventStream()
- }
-}
-
-@MainActor
-extension Radroots {
- func requireRuntime() throws -> RadrootsRuntime {
- guard let rt = runtime else { throw RadrootsRuntimeError.runtimeNotStarted }
- return rt
+ func nostrStopPostStream() async throws {
+ try await run { try $0.nostrStopPostEventStream() }
}
}
diff --git a/Radroots/Runtime/Radroots.swift b/Radroots/Runtime/Radroots.swift
@@ -3,6 +3,7 @@ import Foundation
@MainActor
public final class Radroots: ObservableObject {
public private(set) var runtime: RadrootsRuntime?
+ public private(set) var runtimeService: FieldRuntimeService?
public init() {}
@@ -11,7 +12,7 @@ public final class Radroots: ObservableObject {
version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0",
build: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0",
buildSha: String? = nil
- ) throws {
+ ) throws -> FieldRuntimeService {
let settings = LoggingSettings.load()
do {
try settings.apply()
@@ -30,6 +31,9 @@ public final class Radroots: ObservableObject {
buildSha: resolvedSha
)
self.runtime = rt
+ let service = FieldRuntimeService(runtime: rt)
+ self.runtimeService = service
+ return service
}
deinit {
diff --git a/Radroots/Runtime/TradeListing.swift b/Radroots/Runtime/TradeListing.swift
@@ -1,16 +1,13 @@
import Foundation
-@MainActor
-public extension Radroots {
- func tradeListingPublish(draft: TradeListingDraft) throws -> NostrEventId {
- let rt = try requireRuntime()
- let id = try rt.tradeListingPublish(draft: draft)
+public extension FieldRuntimeService {
+ func tradeListingPublish(draft: TradeListingDraft) async throws -> NostrEventId {
+ let id = try await run { try $0.tradeListingPublish(draft: draft) }
return NostrEventId(id)
}
- func tradeListingsFetch(limit: UInt16, sinceUnix: UInt64? = nil) throws -> [TradeListingSummary] {
- let rt = try requireRuntime()
- return try rt.tradeListingsFetch(limit: limit, sinceUnix: sinceUnix)
+ func tradeListingsFetch(limit: UInt16, sinceUnix: UInt64? = nil) async throws -> [TradeListingSummary] {
+ try await run { try $0.tradeListingsFetch(limit: limit, sinceUnix: sinceUnix) }
}
func tradeListingSendValidationRequest(
@@ -18,33 +15,34 @@ public extension Radroots {
sellerPubkey: String,
listingId: String,
recipientPubkey: String
- ) throws -> NostrEventId {
- let rt = try requireRuntime()
- let id = try rt.tradeListingSendValidationRequest(
- listingEventId: listingEventId,
- sellerPubkey: sellerPubkey,
- listingId: listingId,
- recipientPubkey: recipientPubkey
- )
+ ) async throws -> NostrEventId {
+ let id = try await run {
+ try $0.tradeListingSendValidationRequest(
+ listingEventId: listingEventId,
+ sellerPubkey: sellerPubkey,
+ listingId: listingId,
+ recipientPubkey: recipientPubkey
+ )
+ }
return NostrEventId(id)
}
- func tradeListingSendOrderRequest(draft: TradeOrderDraft) throws -> TradeOrderSendResult {
- let rt = try requireRuntime()
- return try rt.tradeListingSendOrderRequest(draft: draft)
+ func tradeListingSendOrderRequest(draft: TradeOrderDraft) async throws -> TradeOrderSendResult {
+ try await run { try $0.tradeListingSendOrderRequest(draft: draft) }
}
func tradeListingFetchMessages(
listingAddr: String,
limit: UInt16,
sinceUnix: UInt64? = nil
- ) throws -> [TradeListingMessageSummary] {
- let rt = try requireRuntime()
- return try rt.tradeListingFetchMessages(
- listingAddr: listingAddr,
- limit: limit,
- sinceUnix: sinceUnix
- )
+ ) async throws -> [TradeListingMessageSummary] {
+ try await run {
+ try $0.tradeListingFetchMessages(
+ listingAddr: listingAddr,
+ limit: limit,
+ sinceUnix: sinceUnix
+ )
+ }
}
}
diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift
@@ -53,7 +53,7 @@ private struct TodayView: View {
VStack(alignment: .leading, spacing: 12) {
Text("Today")
.font(.largeTitle.weight(.bold))
- Text(app.accountDisplayName ?? app.username ?? "Field operator")
+ Text(app.identityDisplayName)
.font(.title3.weight(.semibold))
HStack(spacing: 10) {
Label(syncLabel, systemImage: syncImage)
@@ -122,7 +122,7 @@ private struct ActivityView: View {
var body: some View {
List {
Section("Recent Activity") {
- ActivityRow(title: "Session ready", detail: app.username ?? "Signed in", systemImage: "person.crop.circle.badge.checkmark")
+ ActivityRow(title: "Identity ready", detail: app.npub.map(shortNpub) ?? "Local key selected", systemImage: "person.crop.circle.badge.checkmark")
ActivityRow(title: "Relay posture", detail: "\(app.relayConnectedCount) connected, \(app.relayConnectingCount) connecting", systemImage: "dot.radiowaves.left.and.right")
ActivityRow(title: "Draft queue", detail: "No local drafts", systemImage: "tray")
}
@@ -131,6 +131,11 @@ private struct ActivityView: View {
.inlineNavigationTitle("Activity")
.accessibilityIdentifier("field_ios.activity")
}
+
+ private func shortNpub(_ value: String) -> String {
+ guard value.count > 18 else { return value }
+ return "\(value.prefix(12))...\(value.suffix(6))"
+ }
}
private struct FieldActionRow: View {
diff --git a/Radroots/Views/MarketView.swift b/Radroots/Views/MarketView.swift
@@ -14,23 +14,15 @@ final class TradeListingsViewModel: ObservableObject {
}
func refresh(app: AppState) async {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
isLoading = true
errorMessage = nil
- let result: Result<[TradeListingSummary], Error> = await Task.detached { @Sendable in
- do {
- return .success(try rt.tradeListingsFetch(limit: 60, sinceUnix: nil))
- } catch {
- return .failure(error)
- }
- }.value
-
- switch result {
- case .success(let items):
+ do {
+ let items = try await service.tradeListingsFetch(limit: 60, sinceUnix: nil)
listings = items
isLoading = false
- case .failure(let error):
+ } catch {
errorMessage = String(describing: error)
isLoading = false
}
diff --git a/Radroots/Views/PostCreateView.swift b/Radroots/Views/PostCreateView.swift
@@ -76,16 +76,16 @@ struct PostCreateView: View {
}
private func post() {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
let content = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
isPosting = true
resultMessage = nil
Task {
do {
- let id = try rt.nostrPostTextNote(content: content)
+ let id = try await service.nostrPostTextNote(content: content)
await MainActor.run {
- resultMessage = "Posted kind:1 event: \(id)"
+ resultMessage = "Posted kind:1 event: \(id.rawValue)"
showResult = true
text = ""
isPosting = false
diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift
@@ -21,15 +21,15 @@ final class PostFeedViewModel: ObservableObject {
func onDisappear(app: AppState) {
liveTask?.cancel()
liveTask = nil
- Task { try? app.radroots.nostrStopPostStream() }
+ Task { try? await app.runtimeService?.nostrStopPostStream() }
}
func load(app: AppState) async {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
isLoading = true
errorMessage = nil
do {
- let fetched = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: nil)
+ let fetched = try await service.nostrFetchTextNotes(limit: 50, sinceUnix: nil)
posts = fetched.sorted { $0.publishedAt > $1.publishedAt }
isLoading = false
} catch {
@@ -58,41 +58,31 @@ final class PostFeedViewModel: ObservableObject {
to post: NostrPostEventMetadata,
setResult: @escaping @MainActor @Sendable (_ title: String, _ message: String) -> Void
) {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
let raw = draftReplies[post.id, default: ""]
let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !reply.isEmpty else { return }
sendingReplyFor.insert(post.id)
Task { @MainActor in
- let runtime = rt
let parentId = post.id
let parentAuthor = post.author
let text = reply
- let result: Result<String, Error> = await Task.detached { @Sendable in
- do {
- let id = try runtime.nostrPostReply(
+ do {
+ let id = try await service.nostrPostReply(
parentEventIdHex: parentId,
parentAuthorHex: parentAuthor,
content: text,
rootEventIdHex: nil as String?
)
- return .success(id)
- } catch {
- return .failure(error)
- }
- }.value
-
- switch result {
- case .success(let id):
draftReplies[parentId] = ""
expandedReplyFor = nil
sendingReplyFor.remove(parentId)
- setResult("Reply Posted", "Event \(id)")
- case .failure(let e):
+ setResult("Reply Posted", "Event \(id.rawValue)")
+ } catch {
sendingReplyFor.remove(parentId)
- setResult("Failed to Post Reply", String(describing: e))
+ setResult("Failed to Post Reply", String(describing: error))
}
}
}
@@ -105,7 +95,7 @@ final class PostFeedViewModel: ObservableObject {
var knownIds = Set(posts.map(\.id))
let since = posts.map(\.publishedAt).max()
do {
- try app.radroots.nostrStartPostStream(sinceUnix: since)
+ try await app.runtimeService?.nostrStartPostStream(sinceUnix: since)
} catch {
errorMessage = String(describing: error)
}
@@ -120,7 +110,7 @@ final class PostFeedViewModel: ObservableObject {
knownIds = Set(posts.map(\.id))
}
- if let event = app.radroots.nostrNextPostStreamEvent() {
+ if let event = await app.runtimeService?.nostrNextPostStreamEvent() {
if knownIds.insert(event.id).inserted {
posts.insert(event, at: 0)
posts.sort { $0.publishedAt > $1.publishedAt }
diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift
@@ -108,10 +108,10 @@ public struct ProfileView: View {
}
private func loadProfile() {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
isLoading = true
Task {
- let meta = rt.nostrProfileForSelf()
+ let meta = await service.nostrProfileForSelf()
await MainActor.run {
self.original = OriginalProfile.from(meta)
self.name = original.name
@@ -124,13 +124,13 @@ public struct ProfileView: View {
}
private func post() {
- guard let rt = app.radroots.runtime, isPostEnabled else { return }
+ guard let service = app.runtimeService, isPostEnabled else { return }
isPosting = true
postMessage = nil
let payload = PostPayload(name: name, displayName: displayName, nip05: nip05, about: about)
Task {
do {
- let id = try rt.nostrPostProfile(
+ let id = try await service.nostrPostProfile(
name: payload.name,
displayName: payload.displayName,
nip05: payload.nip05,
@@ -139,7 +139,7 @@ public struct ProfileView: View {
await MainActor.run {
self.original = OriginalProfile(name: name, displayName: displayName, nip05: nip05, about: about)
self.isPosting = false
- self.postMessage = "Posted kind:0 event: \(id)"
+ self.postMessage = "Posted kind:0 event: \(id.rawValue)"
self.showMessage = true
self.app.refresh()
}
diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift
@@ -2,22 +2,21 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var app: AppState
+ @State private var showResetConfirmation = false
+ @State private var resetError: String?
var body: some View {
List {
- Section("Account") {
- if let displayName = app.accountDisplayName {
- Text(displayName)
- }
- if let username = app.username {
- CopyRow(title: "Username", value: username)
- }
+ Section("Identity") {
+ Text(app.identityDisplayName)
+ .font(.headline)
if let npub = app.npub {
CopyRow(title: "npub", value: npub)
} else {
- Text("Nostr identity is prepared after sign-in.")
+ Text("No local Nostr identity is selected.")
.foregroundStyle(.secondary)
}
+ LabeledContent("Stored identities", value: "\(app.identities.count)")
NavigationLink {
ProfileView()
@@ -44,15 +43,51 @@ struct SettingsView: View {
}
Section {
+ Button {
+ app.signOut()
+ } label: {
+ Label("Sign Out", systemImage: "lock.fill")
+ }
+ .accessibilityIdentifier("field_ios.settings.sign_out")
+
Button(role: .destructive) {
- app.logout()
+ showResetConfirmation = true
} label: {
- Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
+ Label("Reset Local Identity", systemImage: "trash")
+ }
+ .accessibilityIdentifier("field_ios.settings.reset_identity")
+ } footer: {
+ if let resetError {
+ Text(resetError)
+ .foregroundStyle(.red)
}
}
}
.listStyle(.insetGrouped)
.inlineNavigationTitle("Settings")
+ .confirmationDialog(
+ "Reset local Nostr identity?",
+ isPresented: $showResetConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Reset Identity", role: .destructive) {
+ resetIdentity()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This removes the local identity from this app. Sign out keeps it.")
+ }
.accessibilityIdentifier("field_ios.settings")
}
+
+ private func resetIdentity() {
+ resetError = nil
+ Task {
+ do {
+ try await app.resetLocalIdentity()
+ } catch {
+ resetError = error.localizedDescription
+ }
+ }
+ }
}
diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift
@@ -5,56 +5,132 @@ struct SetupView: View {
var onSuccess: (() -> Void)? = nil
- @State private var step: Step = .welcome
- @State private var username = ""
- @State private var code = ""
- @State private var challenge: FieldLoginChallenge?
+ @State private var secretKey = ""
@State private var isWorking = false
@State private var errorMessage: String?
+ @State private var showImport = false
+ @FocusState private var secretFocused: Bool
var body: some View {
- ZStack {
- switch step {
- case .welcome:
- SetupWelcomeView {
- withAnimation(.easeInOut(duration: 0.25)) {
- step = .login
+ ScrollView {
+ VStack(spacing: 20) {
+ Spacer(minLength: 40)
+
+ Image(systemName: app.hasKey ? "lock.open.fill" : "key.radiowaves.forward.fill")
+ .font(.system(size: 64, weight: .semibold))
+ .foregroundStyle(.green)
+ .frame(width: 112, height: 112)
+
+ VStack(spacing: 8) {
+ Text(app.hasKey ? "Local identity ready" : "Create a Nostr identity")
+ .font(.title.weight(.semibold))
+ .multilineTextAlignment(.center)
+
+ if let npub = app.npub {
+ Text(npub)
+ .font(.footnote.monospaced())
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .textSelection(.enabled)
+ } else {
+ Text("Radroots uses your local Nostr identity to publish and read field events.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
}
}
- .transition(.opacity.combined(with: .move(edge: .leading)))
- case .login:
- SetupLoginView(
- username: $username,
- isWorking: isWorking,
- errorMessage: errorMessage,
- onSubmit: startLogin
- )
- .transition(.opacity.combined(with: .move(edge: .trailing)))
- case .verify:
- SetupVerifyView(
- code: $code,
- challenge: challenge,
- isWorking: isWorking,
- errorMessage: errorMessage,
- onVerify: verifyLogin,
- onResend: resendCode
- )
- .transition(.opacity.combined(with: .move(edge: .trailing)))
+
+ SetupErrorText(errorMessage)
+
+ if isWorking {
+ ProgressView()
+ .controlSize(.large)
+ }
+
+ if app.hasKey {
+ Button {
+ continueWithIdentity()
+ } label: {
+ Label("Continue", systemImage: "arrow.right.circle.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(isWorking)
+ .accessibilityIdentifier("field_ios.setup.continue")
+ } else {
+ Button {
+ createIdentity()
+ } label: {
+ Label("Create Identity", systemImage: "plus.circle.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(isWorking)
+ .accessibilityIdentifier("field_ios.setup.create_identity")
+
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showImport.toggle()
+ secretFocused = showImport
+ }
+ } label: {
+ Label("Import Secret Key", systemImage: "square.and.arrow.down")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .disabled(isWorking)
+ .accessibilityIdentifier("field_ios.setup.import_identity")
+
+ if showImport {
+ VStack(spacing: 12) {
+ SecureField("nsec or hex secret key", text: $secretKey)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .focused($secretFocused)
+ .padding(14)
+ .background(Color(.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .accessibilityIdentifier("field_ios.setup.secret_key")
+
+ Button {
+ importIdentity()
+ } label: {
+ Label("Use Secret Key", systemImage: "checkmark.circle.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(
+ isWorking ||
+ secretKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ )
+ .accessibilityIdentifier("field_ios.setup.use_secret_key")
+ }
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ }
+
+ Spacer(minLength: 24)
}
+ .padding()
+ .frame(maxWidth: 560)
+ .frame(maxWidth: .infinity)
}
- .animation(.easeInOut(duration: 0.25), value: step)
+ .background(Color(.systemGroupedBackground))
.toolbar(.hidden, for: .navigationBar)
.accessibilityIdentifier("field_ios.setup")
}
- private func startLogin() {
+ private func continueWithIdentity() {
errorMessage = nil
isWorking = true
- Task { @MainActor in
+ Task {
do {
- challenge = try app.startLogin(username: username)
- code = ""
- step = .verify
+ try await app.continueWithLocalIdentity()
+ onSuccess?()
} catch {
errorMessage = error.localizedDescription
}
@@ -62,13 +138,13 @@ struct SetupView: View {
}
}
- private func resendCode() {
- guard let challenge else { return }
+ private func createIdentity() {
errorMessage = nil
isWorking = true
- Task { @MainActor in
+ Task {
do {
- self.challenge = try app.resendLoginChallenge(challengeId: challenge.id)
+ try await app.createLocalIdentity()
+ onSuccess?()
} catch {
errorMessage = error.localizedDescription
}
@@ -76,13 +152,13 @@ struct SetupView: View {
}
}
- private func verifyLogin() {
- guard let challenge else { return }
+ private func importIdentity() {
errorMessage = nil
isWorking = true
- Task { @MainActor in
+ Task {
do {
- try app.verifyLogin(challengeId: challenge.id, code: code)
+ try await app.importNostrSecret(secretKey)
+ secretKey = ""
onSuccess?()
} catch {
errorMessage = error.localizedDescription
@@ -92,194 +168,6 @@ struct SetupView: View {
}
}
-private enum Step {
- case welcome
- case login
- case verify
-}
-
-private struct SetupWelcomeView: View {
- let onContinue: () -> Void
-
- var body: some View {
- VStack(spacing: 20) {
- Spacer()
-
- Image(systemName: "person.badge.key.fill")
- .font(.system(size: 72, weight: .semibold))
- .foregroundStyle(.secondary)
- .frame(width: 120, height: 120)
-
- Text(Ls.setupGreetingHeader)
- .font(.title.weight(.semibold))
- .multilineTextAlignment(.center)
-
- Text("Sign in to your Radroots account to prepare the field runtime.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
-
- Spacer()
-
- Button {
- onContinue()
- } label: {
- Text("Continue")
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .accessibilityIdentifier("field_ios.setup.continue")
- }
- .padding()
- .accessibilityIdentifier("field_ios.setup.welcome")
- }
-}
-
-private struct SetupLoginView: View {
- @Binding var username: String
- let isWorking: Bool
- let errorMessage: String?
- let onSubmit: () -> Void
-
- var body: some View {
- VStack(spacing: 20) {
- VStack(spacing: 10) {
- Image(systemName: "envelope.badge.shield.half.filled")
- .font(.system(size: 44, weight: .semibold))
- .foregroundStyle(.secondary)
-
- Text("Sign in")
- .font(.title2.weight(.semibold))
- .multilineTextAlignment(.center)
-
- Text("Enter your Radroots username to receive a verification code.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- }
-
- TextField("Username", text: $username)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .textContentType(.username)
- .keyboardType(.emailAddress)
- .padding(14)
- .background(Color(.secondarySystemGroupedBackground))
- .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
- .accessibilityIdentifier("field_ios.setup.username")
-
- SetupErrorText(errorMessage)
-
- if isWorking {
- ProgressView()
- .controlSize(.large)
- }
-
- Button {
- onSubmit()
- } label: {
- HStack(spacing: 10) {
- Image(systemName: "paperplane.fill")
- Text("Send Code")
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(isWorking || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- .accessibilityIdentifier("field_ios.setup.start_login")
-
- Spacer()
- }
- .padding()
- .accessibilityIdentifier("field_ios.setup.login")
- }
-}
-
-private struct SetupVerifyView: View {
- @Binding var code: String
- let challenge: FieldLoginChallenge?
- let isWorking: Bool
- let errorMessage: String?
- let onVerify: () -> Void
- let onResend: () -> Void
-
- var body: some View {
- VStack(spacing: 20) {
- VStack(spacing: 10) {
- Image(systemName: "checkmark.shield.fill")
- .font(.system(size: 44, weight: .semibold))
- .foregroundStyle(.secondary)
-
- Text("Enter verification code")
- .font(.title2.weight(.semibold))
- .multilineTextAlignment(.center)
-
- if let challenge {
- Text("We sent a code to \(challenge.maskedEmail).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- }
- }
-
- TextField("Code", text: $code)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .textContentType(.oneTimeCode)
- .keyboardType(.numberPad)
- .padding(14)
- .background(Color(.secondarySystemGroupedBackground))
- .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
- .accessibilityIdentifier("field_ios.setup.code")
-
- SetupErrorText(errorMessage)
-
- if isWorking {
- ProgressView()
- .controlSize(.large)
- }
-
- VStack(spacing: 12) {
- Button {
- onVerify()
- } label: {
- HStack(spacing: 10) {
- Image(systemName: "checkmark.circle.fill")
- Text("Verify")
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(isWorking || code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- .accessibilityIdentifier("field_ios.setup.verify_login")
-
- Button {
- onResend()
- } label: {
- Text("Resend Code")
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.bordered)
- .controlSize(.large)
- .disabled(isWorking || challenge == nil)
- .accessibilityIdentifier("field_ios.setup.resend_code")
- }
-
- Spacer()
- }
- .padding()
- .accessibilityIdentifier("field_ios.setup.verify")
- }
-}
-
private struct SetupErrorText: View {
let message: String?
diff --git a/Radroots/Views/TradeListingCreateView.swift b/Radroots/Views/TradeListingCreateView.swift
@@ -138,26 +138,18 @@ struct TradeListingCreateView: View {
}
private func publish() {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
errorMessage = nil
isPosting = true
let draftValue = draft.toTradeListingDraft()
Task { @MainActor in
- let result: Result<String, Error> = await Task.detached { @Sendable in
- do {
- return .success(try rt.tradeListingPublish(draft: draftValue))
- } catch {
- return .failure(error)
- }
- }.value
-
- switch result {
- case .success:
+ do {
+ _ = try await service.tradeListingPublish(draft: draftValue)
isPosting = false
onCreated?()
dismiss()
- case .failure(let error):
+ } catch {
isPosting = false
errorMessage = String(describing: error)
}
diff --git a/Radroots/Views/TradeListingDetailView.swift b/Radroots/Views/TradeListingDetailView.swift
@@ -12,31 +12,21 @@ final class TradeListingDetailViewModel: ObservableObject {
}
func refresh(app: AppState) async {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
isLoading = true
errorMessage = nil
let listingAddr = listing.listingAddr
- let result: Result<[TradeListingMessageSummary], Error> = await Task.detached { @Sendable in
- do {
- return .success(
- try rt.tradeListingFetchMessages(
- listingAddr: listingAddr,
- limit: 80,
- sinceUnix: nil
- )
- )
- } catch {
- return .failure(error)
- }
- }.value
-
- switch result {
- case .success(let items):
+ do {
+ let items = try await service.tradeListingFetchMessages(
+ listingAddr: listingAddr,
+ limit: 80,
+ sinceUnix: nil
+ )
messages = items
isLoading = false
- case .failure(let error):
+ } catch {
errorMessage = String(describing: error)
isLoading = false
}
diff --git a/Radroots/Views/TradeOrderRequestView.swift b/Radroots/Views/TradeOrderRequestView.swift
@@ -96,7 +96,7 @@ struct TradeOrderRequestView: View {
}
private func sendOrder() {
- guard let rt = app.radroots.runtime else { return }
+ guard let service = app.runtimeService else { return }
guard let rhiPubkey = TradeSettings.rhiPubkeyOptional else {
errorMessage = "Missing RHI pubkey."
return
@@ -122,20 +122,12 @@ struct TradeOrderRequestView: View {
)
Task { @MainActor in
- let result: Result<TradeOrderSendResult, Error> = await Task.detached { @Sendable in
- do {
- return .success(try rt.tradeListingSendOrderRequest(draft: draft))
- } catch {
- return .failure(error)
- }
- }.value
-
- switch result {
- case .success(let out):
+ do {
+ let out = try await service.tradeListingSendOrderRequest(draft: draft)
isSending = false
onComplete(out)
dismiss()
- case .failure(let error):
+ } catch {
isSending = false
errorMessage = String(describing: error)
}