field_ios

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

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:
MRadroots.xcodeproj/project.pbxproj | 12++++--------
MRadroots/App/AppEntry.swift | 37+++++++++++++++++++++++++++++++++++++
MRadroots/App/AppState.swift | 275++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
DRadroots/Runtime/AuthSettings.swift | 25-------------------------
MRadroots/Runtime/BuildConfig.swift | 2--
ARadroots/Runtime/FieldRuntimeService.swift | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DRadroots/Runtime/FieldSessionCredentialStore.swift | 52----------------------------------------------------
MRadroots/Runtime/LoggingSettings.swift | 2--
MRadroots/Runtime/Nostr.swift | 73+++++++++++++++++++++++++------------------------------------------------
MRadroots/Runtime/Radroots.swift | 6+++++-
MRadroots/Runtime/TradeListing.swift | 50++++++++++++++++++++++++--------------------------
MRadroots/Views/HomeView.swift | 9+++++++--
MRadroots/Views/MarketView.swift | 16++++------------
MRadroots/Views/PostCreateView.swift | 6+++---
MRadroots/Views/PostFeedViewModel.swift | 32+++++++++++---------------------
MRadroots/Views/ProfileView.swift | 10+++++-----
MRadroots/Views/SettingsView.swift | 55+++++++++++++++++++++++++++++++++++++++++++++----------
MRadroots/Views/SetupView.swift | 350+++++++++++++++++++++++++++----------------------------------------------------
MRadroots/Views/TradeListingCreateView.swift | 16++++------------
MRadroots/Views/TradeListingDetailView.swift | 26++++++++------------------
MRadroots/Views/TradeOrderRequestView.swift | 16++++------------
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) }