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:
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