FieldDocumentInterchange.swift (6828B)
1 import Foundation 2 import RadrootsKit 3 4 enum FieldDocumentInterchangeError: LocalizedError, Equatable { 5 case emptyRelayConfig 6 case invalidRelayURL(String) 7 case invalidRelayConfigDocument 8 9 var errorDescription: String? { 10 switch self { 11 case .emptyRelayConfig: 12 "Relay config must include at least one Nostr relay." 13 case .invalidRelayURL(let value): 14 "Invalid Nostr relay URL: \(value)." 15 case .invalidRelayConfigDocument: 16 "Relay config document is not valid JSON." 17 } 18 } 19 } 20 21 struct FieldRelayStatusDocument: Encodable, Equatable { 22 let configuredRelays: [String] 23 let connectedCount: UInt32 24 let connectingCount: UInt32 25 let lastError: String? 26 } 27 28 struct FieldRelayConfigDocument: Codable, Equatable { 29 static let format = "radroots_field_ios_relay_config_v1" 30 31 let format: String 32 let relays: [String] 33 34 init(relays: [String]) throws { 35 self.format = Self.format 36 self.relays = try FieldDocumentInterchange.validatedRelays(relays) 37 } 38 } 39 40 struct FieldDiagnosticsDocument: Encodable, Equatable { 41 let format: String 42 let runtime: JSONValue 43 let relay: FieldRelayStatusDocument 44 } 45 46 enum JSONValue: Encodable, Equatable { 47 case object([String: JSONValue]) 48 case array([JSONValue]) 49 case string(String) 50 case number(Double) 51 case bool(Bool) 52 case null 53 54 init(jsonObject: Any) { 55 switch jsonObject { 56 case let object as [String: Any]: 57 self = .object(object.mapValues(JSONValue.init(jsonObject:))) 58 case let array as [Any]: 59 self = .array(array.map(JSONValue.init(jsonObject:))) 60 case let string as String: 61 self = .string(string) 62 case let number as NSNumber: 63 if CFGetTypeID(number) == CFBooleanGetTypeID() { 64 self = .bool(number.boolValue) 65 } else { 66 self = .number(number.doubleValue) 67 } 68 case _ as NSNull: 69 self = .null 70 default: 71 self = .string(String(describing: jsonObject)) 72 } 73 } 74 75 func encode(to encoder: Encoder) throws { 76 switch self { 77 case .object(let object): 78 try object.encode(to: encoder) 79 case .array(let array): 80 try array.encode(to: encoder) 81 case .string(let string): 82 try string.encode(to: encoder) 83 case .number(let number): 84 try number.encode(to: encoder) 85 case .bool(let bool): 86 try bool.encode(to: encoder) 87 case .null: 88 var container = encoder.singleValueContainer() 89 try container.encodeNil() 90 } 91 } 92 } 93 94 final class FieldDocumentInterchange { 95 private let fileAccess: RadrootsAppleFileAccess 96 private let encoder: JSONEncoder 97 private let decoder: JSONDecoder 98 99 init(bundleIdentifier: String) throws { 100 self.fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) 101 self.encoder = JSONEncoder() 102 self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 103 self.decoder = JSONDecoder() 104 } 105 106 func prepareDiagnosticsExport( 107 infoJSONString: String, 108 relays: [String], 109 connectedCount: UInt32, 110 connectingCount: UInt32, 111 lastError: String? 112 ) throws -> RadrootsPreparedExportDocument { 113 let document = FieldDiagnosticsDocument( 114 format: "radroots_field_ios_diagnostics_v1", 115 runtime: Self.jsonValue(from: infoJSONString), 116 relay: FieldRelayStatusDocument( 117 configuredRelays: try Self.validatedRelays(relays), 118 connectedCount: connectedCount, 119 connectingCount: connectingCount, 120 lastError: lastError 121 ) 122 ) 123 return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-diagnostics.json") 124 } 125 126 func prepareRelayConfigExport(relays: [String]) throws -> RadrootsPreparedExportDocument { 127 let document = try FieldRelayConfigDocument(relays: relays) 128 return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-relays.json") 129 } 130 131 func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { 132 try importedRelayConfig(from: importedDocument.file) 133 } 134 135 func importedRelayConfig(from file: RadrootsFileReference) throws -> [String] { 136 let result = try fileAccess.read(file, mode: .inline) 137 guard case .inline(let data) = result else { 138 throw FieldDocumentInterchangeError.invalidRelayConfigDocument 139 } 140 let document = try decoder.decode(FieldRelayConfigDocument.self, from: data) 141 guard document.format == FieldRelayConfigDocument.format else { 142 throw FieldDocumentInterchangeError.invalidRelayConfigDocument 143 } 144 return try Self.validatedRelays(document.relays) 145 } 146 147 func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { 148 try RadrootsShareRequest(items: [.text(content)], subject: "Radroots") 149 } 150 151 static func validatedRelays(_ relays: [String]) throws -> [String] { 152 var seen = Set<String>() 153 var normalized: [String] = [] 154 for relay in relays { 155 let trimmed = relay.trimmingCharacters(in: .whitespacesAndNewlines) 156 guard !trimmed.isEmpty else { 157 continue 158 } 159 guard let components = URLComponents(string: trimmed), 160 let scheme = components.scheme?.lowercased(), 161 scheme == "ws" || scheme == "wss", 162 components.host != nil, 163 trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { 164 throw FieldDocumentInterchangeError.invalidRelayURL(relay) 165 } 166 let key = trimmed.lowercased() 167 if seen.insert(key).inserted { 168 normalized.append(trimmed) 169 } 170 } 171 guard !normalized.isEmpty else { 172 throw FieldDocumentInterchangeError.emptyRelayConfig 173 } 174 return normalized 175 } 176 177 private func prepareJSONExport(data: Data, filename: String) throws -> RadrootsPreparedExportDocument { 178 try fileAccess.prepareExport( 179 RadrootsExportDocumentRequest( 180 source: .inlineData(data), 181 suggestedFilename: filename, 182 mediaType: "application/json" 183 ) 184 ) 185 } 186 187 private static func jsonValue(from string: String) -> JSONValue { 188 guard let data = string.data(using: .utf8), 189 let object = try? JSONSerialization.jsonObject(with: data) else { 190 return .string(string) 191 } 192 return JSONValue(jsonObject: object) 193 } 194 }