field_ios

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

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 }