RelaysView.swift (7062B)
1 import RadrootsKit 2 import SwiftUI 3 4 struct RelaysView: View { 5 @EnvironmentObject private var app: AppState 6 @State private var preparedExport: RadrootsPreparedExportDocument? 7 @State private var activeExport: RadrootsPreparedExportDocument? 8 @State private var importRequest: RadrootsDocumentImportRequest? 9 @State private var fileAccess: RadrootsAppleFileAccess? 10 @State private var importedRelays: [String] = [] 11 @State private var documentMessage: String? 12 @State private var documentError: String? 13 14 private var configuredRelays: [String] { 15 app.configuredRelayURLs 16 } 17 18 var body: some View { 19 List { 20 Section("Relays") { 21 RelayMetricRow( 22 label: "Connected", 23 systemImage: "dot.radiowaves.left.and.right", 24 value: app.relayConnectedCount, 25 accessibilityID: "field_ios.relays.connected_count" 26 ) 27 RelayMetricRow( 28 label: "Connecting", 29 systemImage: "antenna.radiowaves.left.and.right", 30 value: app.relayConnectingCount, 31 accessibilityID: "field_ios.relays.connecting_count" 32 ) 33 if let last = app.relayLastError { 34 Text(last) 35 .foregroundStyle(.red) 36 .font(.footnote) 37 .accessibilityIdentifier("field_ios.relays.last_error") 38 } 39 } 40 41 Section("Configured Relays") { 42 LabeledContent("Source", value: app.relaySettingsSourceLabel) 43 .accessibilityIdentifier("field_ios.relays.settings_source") 44 if configuredRelays.isEmpty { 45 Text("No relays configured") 46 .foregroundStyle(.secondary) 47 } else { 48 ForEach(configuredRelays, id: \.self) { url in 49 Text(url) 50 .font(.callout.monospaced()) 51 .accessibilityIdentifier("field_ios.relays.configured_url") 52 } 53 } 54 } 55 56 Section("Document Interchange") { 57 Button { 58 prepareRelayExport() 59 } label: { 60 Label("Export Relay Config", systemImage: "square.and.arrow.up") 61 } 62 .accessibilityIdentifier("field_ios.relays.export") 63 64 Button { 65 prepareRelayImport() 66 } label: { 67 Label("Import Relay Config", systemImage: "square.and.arrow.down") 68 } 69 .accessibilityIdentifier("field_ios.relays.import") 70 71 if let documentMessage { 72 Text(documentMessage) 73 .foregroundStyle(.secondary) 74 .accessibilityIdentifier("field_ios.relays.document_status") 75 } 76 if let documentError { 77 Text(documentError) 78 .foregroundStyle(.red) 79 .font(.footnote) 80 .accessibilityIdentifier("field_ios.relays.document_error") 81 } 82 } 83 84 if !importedRelays.isEmpty { 85 Section("Imported Relays") { 86 ForEach(importedRelays, id: \.self) { url in 87 Text(url) 88 .font(.callout.monospaced()) 89 .accessibilityIdentifier("field_ios.relays.imported_url") 90 } 91 } 92 } 93 } 94 .listStyle(.insetGrouped) 95 .inlineNavigationTitle("Relays") 96 .task { 97 fileAccess = try? app.documentFileAccess() 98 } 99 .radrootsDocumentExporter(preparedExport: $preparedExport) { result in 100 handleRelayExportCompletion(result) 101 } 102 .background { 103 if let fileAccess { 104 Color.clear.radrootsDocumentImporter( 105 request: $importRequest, 106 fileAccess: fileAccess 107 ) { result in 108 handleRelayImportCompletion(result) 109 } 110 } 111 } 112 .accessibilityIdentifier("field_ios.relays") 113 } 114 115 private func prepareRelayExport() { 116 documentMessage = nil 117 documentError = nil 118 do { 119 let export = try app.prepareRelayConfigDocumentExport() 120 activeExport = export 121 preparedExport = export 122 } catch { 123 documentError = error.fieldRuntimeMessage 124 } 125 } 126 127 private func prepareRelayImport() { 128 documentMessage = nil 129 documentError = nil 130 do { 131 if fileAccess == nil { 132 fileAccess = try app.documentFileAccess() 133 } 134 importRequest = try RadrootsDocumentImportRequest( 135 allowedContentKinds: [.json], 136 allowsMultipleSelection: false, 137 destinationScope: .temporary 138 ) 139 } catch { 140 documentError = error.fieldRuntimeMessage 141 } 142 } 143 144 private func handleRelayExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) { 145 if let activeExport { 146 app.releasePreparedDocumentExport(activeExport) 147 } 148 activeExport = nil 149 switch result { 150 case .success(let exportResult): 151 documentMessage = "Exported \(exportResult.exportedFilename)" 152 documentError = nil 153 case .failure(let error): 154 documentError = error.fieldRuntimeMessage 155 } 156 } 157 158 private func handleRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) { 159 Task { 160 await applyRelayImportCompletion(result) 161 } 162 } 163 164 @MainActor 165 private func applyRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) async { 166 do { 167 let importResult = try result.get() 168 guard let document = importResult.documents.first else { 169 throw FieldDocumentInterchangeError.invalidRelayConfigDocument 170 } 171 importedRelays = try await app.applyImportedRelayConfig(from: document) 172 documentMessage = "Imported and applied \(importedRelays.count) relay config entries" 173 documentError = nil 174 } catch { 175 documentError = error.fieldRuntimeMessage 176 } 177 } 178 } 179 180 private struct RelayMetricRow: View { 181 let label: String 182 let systemImage: String 183 let value: UInt32 184 let accessibilityID: String 185 186 var body: some View { 187 HStack { 188 Label(label, systemImage: systemImage) 189 Spacer() 190 Text("\(value)") 191 } 192 .accessibilityElement(children: .ignore) 193 .accessibilityLabel(label) 194 .accessibilityValue("\(value)") 195 .accessibilityIdentifier(accessibilityID) 196 } 197 }