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:
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)
+ }
+}