field_ios

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

commit 70fb20459654f4d687f5227ac730718395dc3a4c
parent 9335dce30ed1aa74a0f382503dc48050420260d2
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 13:34:36 -0700

runtime: apply imported relay settings

- add a non-secret stored relay settings document
- apply imported relay documents to unlocked Nostr runtimes
- surface effective relay source and URLs in settings
- route background relay refresh through effective settings

Diffstat:
MRadroots/App/AppState.swift | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MRadroots/Runtime/FieldBackgroundExecution.swift | 3++-
MRadroots/Runtime/FieldTelemetry.swift | 8++++++++
MRadroots/Runtime/RelaySettings.swift | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MRadroots/Views/RelaysView.swift | 15++++++++++++---
5 files changed, 196 insertions(+), 19 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -41,6 +41,8 @@ public final class AppState: ObservableObject { @Published public private(set) var relayConnectingCount: UInt32 = 0 @Published public private(set) var relayLight: RelayLight = .red @Published public private(set) var relayLastError: String? + @Published public private(set) var configuredRelayURLs: [String] = [] + @Published public private(set) var relaySettingsSourceLabel: String = RelaySettingsSource.buildConfig.displayName @Published public private(set) var fileAccessProbeValue: String? @Published public private(set) var documentInterchangeProbeValue: String? @Published public private(set) var telemetryProbeValue: String? @@ -131,6 +133,7 @@ public final class AppState: ObservableObject { if resetLocalStateRequested { await backgroundExecution.cancelAll() try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier) + try RelaySettings.clearUserImportedRelays(bundleIdentifier: appBundleIdentifier) try secureStore.deleteSelectedSecret() metadataStore.delete() try await resetRuntimeIdentityState(using: service) @@ -139,6 +142,7 @@ public final class AppState: ObservableObject { } else { loadStoredIdentityMetadata(metadataStore) } + try refreshRelaySettingsSnapshot(bundleIdentifier: appBundleIdentifier) let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier) self.captureIntake = captureIntake try await backgroundExecution.start() @@ -153,7 +157,7 @@ public final class AppState: ObservableObject { resetLocalStateRequested: resetLocalStateRequested, identityResetObserved: false ) - try refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier) + try await refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier) await refreshLocationCheckInStatus() await refreshCaptureIntakeState(using: captureIntake) bootstrapPhase = .ready @@ -389,7 +393,7 @@ public final class AppState: ObservableObject { func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { do { - let relays = try RelaySettings.relays() + let relays = try effectiveRelaySettings().relays let document = try documentInterchange().prepareDiagnosticsExport( infoJSONString: infoJSONString, relays: relays, @@ -410,7 +414,7 @@ public final class AppState: ObservableObject { func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument { do { - let relays = try RelaySettings.relays() + let relays = try effectiveRelaySettings().relays let document = try documentInterchange().prepareRelayConfigExport(relays: relays) telemetry.documentInterchange(operation: "relay_config_export", outcome: "success", relayCount: relays.count) return document @@ -437,6 +441,38 @@ public final class AppState: ObservableObject { } } + func applyImportedRelayConfig(from importedDocument: RadrootsImportedDocument) async throws -> [String] { + do { + let relays = try documentInterchange().importedRelayConfig(from: importedDocument) + let snapshot = try RelaySettings.storeUserImportedRelays( + relays, + bundleIdentifier: bundleIdentifier() + ) + apply(relaySettings: snapshot) + if let service = runtimeService, runtimeIdentityReady && !isLocked { + relayConnectedCount = 0 + relayConnectingCount = 0 + relayLight = .yellow + relayLastError = nil + try await service.nostrSetDefaultRelays(snapshot.relays) + try await service.nostrConnectIfKeyPresent() + await refreshRelayStatus(using: service) + await backgroundExecution?.updateRuntimeState( + service: service, + identityUnlocked: true + ) + } + telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count) + return snapshot.relays + } catch { + telemetry.documentInterchange( + operation: "relay_config_import", + outcome: FieldTelemetry.documentInterchangeOutcome(for: error) + ) + throw error + } + } + func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { do { let request = try documentInterchange().publicPostShareRequest(content: content) @@ -463,6 +499,21 @@ public final class AppState: ObservableObject { try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier()) } + private func refreshRelaySettingsSnapshot(bundleIdentifier: String) throws { + apply(relaySettings: try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier)) + } + + private func effectiveRelaySettings() throws -> RelaySettingsSnapshot { + let snapshot = try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier()) + apply(relaySettings: snapshot) + return snapshot + } + + private func apply(relaySettings snapshot: RelaySettingsSnapshot) { + configuredRelayURLs = snapshot.relays + relaySettingsSourceLabel = snapshot.source.displayName + } + private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async { captureIntakeState.operation = .refreshing captureIntakeState.lastError = nil @@ -591,7 +642,7 @@ public final class AppState: ObservableObject { } private func configureRelays(using service: FieldRuntimeService) async throws { - try await service.nostrSetDefaultRelays(try RelaySettings.relays()) + try await service.nostrSetDefaultRelays(try effectiveRelaySettings().relays) } private func connect(using service: FieldRuntimeService) async throws { @@ -637,7 +688,7 @@ public final class AppState: ObservableObject { let telemetryStatus = FieldTelemetryRelayStatus( connectedCount: relayConnectedCount, connectingCount: relayConnectingCount, - configuredRelayCount: (try? RelaySettings.relays().count) ?? 0, + configuredRelayCount: configuredRelayURLs.count, light: relayLight.telemetryValue ) if telemetryStatus != lastTelemetryRelayStatus { @@ -811,11 +862,11 @@ public final class AppState: ObservableObject { } } - private func refreshDocumentInterchangeProbe(bundleIdentifier: String) throws { + private func refreshDocumentInterchangeProbe(bundleIdentifier: String) async throws { documentInterchangeProbeValue = try FieldDocumentInterchangeUITestProbe.startupValue( bundleIdentifier: bundleIdentifier, infoJSONString: infoJSONString, - relays: RelaySettings.relays(), + relays: effectiveRelaySettings().relays, connectedCount: relayConnectedCount, connectingCount: relayConnectingCount, lastError: relayLastError @@ -830,7 +881,14 @@ public final class AppState: ObservableObject { if let relayImportDocument = try FieldDocumentInterchangeUITestProbe.relayImportDocument( bundleIdentifier: bundleIdentifier ) { - _ = try importedRelayConfig(from: relayImportDocument) + let importedRelays = try await applyImportedRelayConfig(from: relayImportDocument) + documentInterchangeProbeValue = [ + documentInterchangeProbeValue, + "relay_import_applied=true", + "relay_settings_source=\(relaySettingsSourceLabel)", + "relay_settings_count=\(importedRelays.count)", + "relay_settings_contains_production=\(importedRelays.contains("wss://radroots.org"))" + ].compactMap { $0 }.joined(separator: ";") } _ = try publicPostShareRequest(content: " public field update ") } diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -410,7 +410,8 @@ actor FieldBackgroundExecution { return true } do { - try await runtimeService.nostrSetDefaultRelays(try RelaySettings.relays()) + let relaySettings = try RelaySettings.effectiveSnapshot(bundleIdentifier: roots.appIdentifier) + try await runtimeService.nostrSetDefaultRelays(relaySettings.relays) try await runtimeService.nostrConnectIfKeyPresent() let status = await runtimeService.nostrConnectionStatus() telemetry.backgroundExecution( diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -318,6 +318,10 @@ final class FieldTelemetry: @unchecked Sendable { switch error { case .noRelaysConfigured: return "relay_config_missing" + case .invalidRelayURL: + return "invalid_relay_url" + case .invalidStoredRelaySettings: + return "invalid_relay_settings" } } switch error { @@ -425,6 +429,10 @@ final class FieldTelemetry: @unchecked Sendable { switch error { case .noRelaysConfigured: return "relay_config_missing" + case .invalidRelayURL: + return "invalid_relay_url" + case .invalidStoredRelaySettings: + return "invalid_relay_settings" } default: return "failure" diff --git a/Radroots/Runtime/RelaySettings.swift b/Radroots/Runtime/RelaySettings.swift @@ -1,37 +1,138 @@ import Foundation +import RadrootsKit public enum RelaySettingsError: LocalizedError { case noRelaysConfigured + case invalidRelayURL(String) + case invalidStoredRelaySettings public var errorDescription: String? { - "No Nostr relays configured. Set 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS'." + switch self { + case .noRelaysConfigured: + "No Nostr relays configured. Set 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS'." + case .invalidRelayURL(let value): + "Invalid Nostr relay URL: \(value)." + case .invalidStoredRelaySettings: + "Stored Nostr relay settings are invalid." + } } } +public enum RelaySettingsSource: String { + case buildConfig + case userImported + + var displayName: String { + switch self { + case .buildConfig: + "Build Config" + case .userImported: + "Imported" + } + } +} + +public struct RelaySettingsSnapshot: Equatable { + public let source: RelaySettingsSource + public let relays: [String] +} + public enum RelaySettings { + private struct StoredRelaySettingsDocument: Codable { + static let format = "radroots_field_ios_relay_settings_v1" + + let format: String + let relays: [String] + } + + private static let storedSettingsFile = RadrootsFileReference( + scope: .data, + relativePath: "settings/relay_settings.json" + ) + public static func relays() throws -> [String] { guard let parts = BuildConfig.array(.nostrRelayUrls) else { throw RelaySettingsError.noRelaysConfigured } - let normalized = normalize(parts) - guard !normalized.isEmpty else { - throw RelaySettingsError.noRelaysConfigured + return try validatedRelays(parts) + } + + public static func effectiveSnapshot(bundleIdentifier: String) throws -> RelaySettingsSnapshot { + if let importedRelays = try userImportedRelays(bundleIdentifier: bundleIdentifier) { + return RelaySettingsSnapshot(source: .userImported, relays: importedRelays) } - return normalized + return RelaySettingsSnapshot(source: .buildConfig, relays: try relays()) + } + + @discardableResult + public static func storeUserImportedRelays( + _ relays: [String], + bundleIdentifier: String + ) throws -> RelaySettingsSnapshot { + let normalized = try validatedRelays(relays) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let document = StoredRelaySettingsDocument( + format: StoredRelaySettingsDocument.format, + relays: normalized + ) + let data = try encoder.encode(document) + try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write( + .inline(data), + to: storedSettingsFile + ) + return RelaySettingsSnapshot(source: .userImported, relays: normalized) } - private static func normalize(_ urls: [String]) -> [String] { + public static func clearUserImportedRelays(bundleIdentifier: String) throws { + try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).delete(storedSettingsFile) + } + + public static func validatedRelays(_ urls: [String]) throws -> [String] { var seen = Set<String>() var out: [String] = [] for u in urls { let trimmed = u.trimmingCharacters(in: .whitespacesAndNewlines) let unquoted = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + guard !unquoted.isEmpty else { + continue + } + guard let components = URLComponents(string: unquoted), + let scheme = components.scheme?.lowercased(), + scheme == "ws" || scheme == "wss", + components.host != nil, + unquoted.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { + throw RelaySettingsError.invalidRelayURL(u) + } let lower = unquoted.lowercased() - guard lower.hasPrefix("ws://") || lower.hasPrefix("wss://") else { continue } if seen.insert(lower).inserted { out.append(unquoted) } } + guard !out.isEmpty else { + throw RelaySettingsError.noRelaysConfigured + } return out } + + private static func userImportedRelays(bundleIdentifier: String) throws -> [String]? { + do { + let result = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).read( + storedSettingsFile, + mode: .inline + ) + guard case .inline(let data) = result else { + throw RelaySettingsError.invalidStoredRelaySettings + } + let document = try JSONDecoder().decode(StoredRelaySettingsDocument.self, from: data) + guard document.format == StoredRelaySettingsDocument.format else { + throw RelaySettingsError.invalidStoredRelaySettings + } + return try validatedRelays(document.relays) + } catch RadrootsAppleFileError.notFound(_) { + return nil + } catch is DecodingError { + throw RelaySettingsError.invalidStoredRelaySettings + } + } } diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -12,7 +12,7 @@ struct RelaysView: View { @State private var documentError: String? private var configuredRelays: [String] { - (try? RelaySettings.relays()) ?? [] + app.configuredRelayURLs } var body: some View { @@ -39,6 +39,8 @@ struct RelaysView: View { } Section("Configured Relays") { + LabeledContent("Source", value: app.relaySettingsSourceLabel) + .accessibilityIdentifier("field_ios.relays.settings_source") if configuredRelays.isEmpty { Text("No relays configured") .foregroundStyle(.secondary) @@ -154,13 +156,20 @@ struct RelaysView: View { } private func handleRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) { + Task { + await applyRelayImportCompletion(result) + } + } + + @MainActor + private func applyRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) async { do { let importResult = try result.get() guard let document = importResult.documents.first else { throw FieldDocumentInterchangeError.invalidRelayConfigDocument } - importedRelays = try app.importedRelayConfig(from: document) - documentMessage = "Imported \(importedRelays.count) relay config entries" + importedRelays = try await app.applyImportedRelayConfig(from: document) + documentMessage = "Imported and applied \(importedRelays.count) relay config entries" documentError = nil } catch { documentError = error.localizedDescription