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 }