field_ios

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

commit 33fdd9f3010502e248502fe19a7368006fc1cc7f
parent e54953ce64691ee034353cae7a652519559e2e61
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:23:20 -0700

app: add document interchange runtime

- add Nostr-safe diagnostics and relay config export preparation
- validate imported relay config JSON without backend dependencies
- expose AppState document interchange entry points for SwiftUI screens
- include the runtime file in the generated Xcode project

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppState.swift | 27+++++++++++++++++++++++++++
ARadroots/Runtime/FieldDocumentInterchange.swift | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 225 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; 3A7FA9E5BCC7590B2EAC5349 /* RelaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBB081610305940C7849C7C /* RelaySettings.swift */; }; 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */; }; + 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */; }; 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; }; 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; @@ -97,6 +98,7 @@ D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingSettings.swift; sourceTree = "<group>"; }; 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>"; }; + E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchange.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>"; }; @@ -177,6 +179,7 @@ isa = PBXGroup; children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, + E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */, CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */, @@ -406,6 +409,7 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, + 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */, D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -1,4 +1,5 @@ import Foundation +import RadrootsKit enum FieldAppRuntimeError: LocalizedError { case runtimeNotReady @@ -217,6 +218,32 @@ public final class AppState: ObservableObject { return service } + func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { + try documentInterchange().prepareDiagnosticsExport( + infoJSONString: infoJSONString, + relays: RelaySettings.relays(), + connectedCount: relayConnectedCount, + connectingCount: relayConnectingCount, + lastError: relayLastError + ) + } + + func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument { + try documentInterchange().prepareRelayConfigExport(relays: RelaySettings.relays()) + } + + func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { + try documentInterchange().importedRelayConfig(from: importedDocument) + } + + func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { + try documentInterchange().publicPostShareRequest(content: content) + } + + private func documentInterchange() throws -> FieldDocumentInterchange { + try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier()) + } + private var isFailed: Bool { if case .failed = bootstrapPhase { return true diff --git a/Radroots/Runtime/FieldDocumentInterchange.swift b/Radroots/Runtime/FieldDocumentInterchange.swift @@ -0,0 +1,194 @@ +import Foundation +import RadrootsKit + +enum FieldDocumentInterchangeError: LocalizedError, Equatable { + case emptyRelayConfig + case invalidRelayURL(String) + case invalidRelayConfigDocument + + var errorDescription: String? { + switch self { + case .emptyRelayConfig: + "Relay config must include at least one Nostr relay." + case .invalidRelayURL(let value): + "Invalid Nostr relay URL: \(value)." + case .invalidRelayConfigDocument: + "Relay config document is not valid JSON." + } + } +} + +struct FieldRelayStatusDocument: Encodable, Equatable { + let configuredRelays: [String] + let connectedCount: UInt32 + let connectingCount: UInt32 + let lastError: String? +} + +struct FieldRelayConfigDocument: Codable, Equatable { + static let format = "radroots_field_ios_relay_config_v1" + + let format: String + let relays: [String] + + init(relays: [String]) throws { + self.format = Self.format + self.relays = try FieldDocumentInterchange.validatedRelays(relays) + } +} + +struct FieldDiagnosticsDocument: Encodable, Equatable { + let format: String + let runtime: JSONValue + let relay: FieldRelayStatusDocument +} + +enum JSONValue: Encodable, Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + init(jsonObject: Any) { + switch jsonObject { + case let object as [String: Any]: + self = .object(object.mapValues(JSONValue.init(jsonObject:))) + case let array as [Any]: + self = .array(array.map(JSONValue.init(jsonObject:))) + case let string as String: + self = .string(string) + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + self = .bool(number.boolValue) + } else { + self = .number(number.doubleValue) + } + case _ as NSNull: + self = .null + default: + self = .string(String(describing: jsonObject)) + } + } + + func encode(to encoder: Encoder) throws { + switch self { + case .object(let object): + try object.encode(to: encoder) + case .array(let array): + try array.encode(to: encoder) + case .string(let string): + try string.encode(to: encoder) + case .number(let number): + try number.encode(to: encoder) + case .bool(let bool): + try bool.encode(to: encoder) + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } +} + +final class FieldDocumentInterchange { + private let fileAccess: RadrootsAppleFileAccess + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(bundleIdentifier: String) throws { + self.fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) + self.encoder = JSONEncoder() + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.decoder = JSONDecoder() + } + + func prepareDiagnosticsExport( + infoJSONString: String, + relays: [String], + connectedCount: UInt32, + connectingCount: UInt32, + lastError: String? + ) throws -> RadrootsPreparedExportDocument { + let document = FieldDiagnosticsDocument( + format: "radroots_field_ios_diagnostics_v1", + runtime: Self.jsonValue(from: infoJSONString), + relay: FieldRelayStatusDocument( + configuredRelays: try Self.validatedRelays(relays), + connectedCount: connectedCount, + connectingCount: connectingCount, + lastError: lastError + ) + ) + return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-diagnostics.json") + } + + func prepareRelayConfigExport(relays: [String]) throws -> RadrootsPreparedExportDocument { + let document = try FieldRelayConfigDocument(relays: relays) + return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-relays.json") + } + + func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { + try importedRelayConfig(from: importedDocument.file) + } + + func importedRelayConfig(from file: RadrootsFileReference) throws -> [String] { + let result = try fileAccess.read(file, mode: .inline) + guard case .inline(let data) = result else { + throw FieldDocumentInterchangeError.invalidRelayConfigDocument + } + let document = try decoder.decode(FieldRelayConfigDocument.self, from: data) + guard document.format == FieldRelayConfigDocument.format else { + throw FieldDocumentInterchangeError.invalidRelayConfigDocument + } + return try Self.validatedRelays(document.relays) + } + + func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { + try RadrootsShareRequest(items: [.text(content)], subject: "Radroots") + } + + static func validatedRelays(_ relays: [String]) throws -> [String] { + var seen = Set<String>() + var normalized: [String] = [] + for relay in relays { + let trimmed = relay.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + continue + } + guard let components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + scheme == "ws" || scheme == "wss", + components.host != nil, + trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { + throw FieldDocumentInterchangeError.invalidRelayURL(relay) + } + let key = trimmed.lowercased() + if seen.insert(key).inserted { + normalized.append(trimmed) + } + } + guard !normalized.isEmpty else { + throw FieldDocumentInterchangeError.emptyRelayConfig + } + return normalized + } + + private func prepareJSONExport(data: Data, filename: String) throws -> RadrootsPreparedExportDocument { + try fileAccess.prepareExport( + RadrootsExportDocumentRequest( + source: .inlineData(data), + suggestedFilename: filename, + mediaType: "application/json" + ) + ) + } + + private static func jsonValue(from string: String) -> JSONValue { + guard let data = string.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) else { + return .string(string) + } + return JSONValue(jsonObject: object) + } +}