field_ios

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

RelaySettings.swift (4916B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 public enum RelaySettingsError: LocalizedError {
      5     case noRelaysConfigured
      6     case invalidRelayURL(String)
      7     case invalidStoredRelaySettings
      8 
      9     public var errorDescription: String? {
     10         switch self {
     11         case .noRelaysConfigured:
     12             "No Nostr relays configured. Set 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS'."
     13         case .invalidRelayURL(let value):
     14             "Invalid Nostr relay URL: \(value)."
     15         case .invalidStoredRelaySettings:
     16             "Stored Nostr relay settings are invalid."
     17         }
     18     }
     19 }
     20 
     21 public enum RelaySettingsSource: String {
     22     case buildConfig
     23     case userImported
     24 
     25     var displayName: String {
     26         switch self {
     27         case .buildConfig:
     28             "Build Config"
     29         case .userImported:
     30             "Imported"
     31         }
     32     }
     33 }
     34 
     35 public struct RelaySettingsSnapshot: Equatable {
     36     public let source: RelaySettingsSource
     37     public let relays: [String]
     38 }
     39 
     40 public enum RelaySettings {
     41     private struct StoredRelaySettingsDocument: Codable {
     42         static let format = "radroots_field_ios_relay_settings_v1"
     43 
     44         let format: String
     45         let relays: [String]
     46     }
     47 
     48     private static let storedSettingsFile = RadrootsFileReference(
     49         scope: .data,
     50         relativePath: "settings/relay_settings.json"
     51     )
     52 
     53     public static func relays() throws -> [String] {
     54         guard let parts = BuildConfig.array(.nostrRelayUrls) else {
     55             throw RelaySettingsError.noRelaysConfigured
     56         }
     57         return try validatedRelays(parts)
     58     }
     59 
     60     public static func effectiveSnapshot(bundleIdentifier: String) throws -> RelaySettingsSnapshot {
     61         if let importedRelays = try userImportedRelays(bundleIdentifier: bundleIdentifier) {
     62             return RelaySettingsSnapshot(source: .userImported, relays: importedRelays)
     63         }
     64         return RelaySettingsSnapshot(source: .buildConfig, relays: try relays())
     65     }
     66 
     67     @discardableResult
     68     public static func storeUserImportedRelays(
     69         _ relays: [String],
     70         bundleIdentifier: String
     71     ) throws -> RelaySettingsSnapshot {
     72         let normalized = try validatedRelays(relays)
     73         let encoder = JSONEncoder()
     74         encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
     75         let document = StoredRelaySettingsDocument(
     76             format: StoredRelaySettingsDocument.format,
     77             relays: normalized
     78         )
     79         let data = try encoder.encode(document)
     80         try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write(
     81             .inline(data),
     82             to: storedSettingsFile
     83         )
     84         return RelaySettingsSnapshot(source: .userImported, relays: normalized)
     85     }
     86 
     87     public static func clearUserImportedRelays(bundleIdentifier: String) throws {
     88         try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).delete(storedSettingsFile)
     89     }
     90 
     91     public static func validatedRelays(_ urls: [String]) throws -> [String] {
     92         var seen = Set<String>()
     93         var out: [String] = []
     94         for u in urls {
     95             let trimmed = u.trimmingCharacters(in: .whitespacesAndNewlines)
     96             let unquoted = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
     97             guard !unquoted.isEmpty else {
     98                 continue
     99             }
    100             guard let components = URLComponents(string: unquoted),
    101                   let scheme = components.scheme?.lowercased(),
    102                   scheme == "ws" || scheme == "wss",
    103                   components.host != nil,
    104                   unquoted.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
    105                 throw RelaySettingsError.invalidRelayURL(u)
    106             }
    107             let lower = unquoted.lowercased()
    108             if seen.insert(lower).inserted {
    109                 out.append(unquoted)
    110             }
    111         }
    112         guard !out.isEmpty else {
    113             throw RelaySettingsError.noRelaysConfigured
    114         }
    115         return out
    116     }
    117 
    118     private static func userImportedRelays(bundleIdentifier: String) throws -> [String]? {
    119         do {
    120             let result = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).read(
    121                 storedSettingsFile,
    122                 mode: .inline
    123             )
    124             guard case .inline(let data) = result else {
    125                 throw RelaySettingsError.invalidStoredRelaySettings
    126             }
    127             let document = try JSONDecoder().decode(StoredRelaySettingsDocument.self, from: data)
    128             guard document.format == StoredRelaySettingsDocument.format else {
    129                 throw RelaySettingsError.invalidStoredRelaySettings
    130             }
    131             return try validatedRelays(document.relays)
    132         } catch RadrootsAppleFileError.notFound(_) {
    133             return nil
    134         } catch is DecodingError {
    135             throw RelaySettingsError.invalidStoredRelaySettings
    136         }
    137     }
    138 }