EngagementDebugView.swift (7923B)
1 import Foundation 2 import SwiftUI 3 import UIKit 4 5 struct EngagementDebugView: View { 6 @Environment(\.engagementHost) private var host 7 8 @State private var engagementID = UUID() 9 @State private var localSignalJSON = "" 10 @State private var remoteSignalJSON = "" 11 @State private var outboundMessage = "hello from Crossmate" 12 @State private var events: [String] = [] 13 @State private var isBusy = false 14 15 private let encoder: JSONEncoder = { 16 let encoder = JSONEncoder() 17 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 18 return encoder 19 }() 20 21 var body: some View { 22 List { 23 Section("Engagement ID") { 24 Text(engagementID.uuidString) 25 .font(.caption.monospaced()) 26 .textSelection(.enabled) 27 28 Button("New Engagement ID") { 29 engagementID = UUID() 30 localSignalJSON = "" 31 remoteSignalJSON = "" 32 appendEvent("New engagement ID") 33 } 34 .disabled(isBusy) 35 } 36 37 Section("Local Signal") { 38 debugTextEditor(text: $localSignalJSON, minHeight: 150) 39 40 Button("Create Offer") { 41 Task { await createOffer() } 42 } 43 .disabled(isBusy || host == nil) 44 45 Button("Copy Local Signal") { 46 UIPasteboard.general.string = localSignalJSON 47 } 48 .disabled(localSignalJSON.isEmpty) 49 } 50 51 Section("Remote Signal") { 52 debugTextEditor(text: $remoteSignalJSON, minHeight: 150) 53 54 Button("Paste Remote Signal") { 55 remoteSignalJSON = UIPasteboard.general.string ?? "" 56 } 57 58 Button("Accept Offer and Create Reply") { 59 Task { await acceptOfferAndReply() } 60 } 61 .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty) 62 63 Button("Accept Reply") { 64 Task { await acceptReply() } 65 } 66 .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty) 67 } 68 69 Section("Data Channel") { 70 TextField("Message", text: $outboundMessage) 71 .textInputAutocapitalization(.never) 72 .autocorrectionDisabled() 73 74 Button("Send Message") { 75 sendMessage() 76 } 77 .disabled(host == nil || outboundMessage.isEmpty) 78 79 Button("Teardown") { 80 host?.teardown(engagementID: engagementID) 81 appendEvent("Teardown requested") 82 } 83 .disabled(host == nil) 84 } 85 86 Section("Events") { 87 if events.isEmpty { 88 Text("No engagement events yet.") 89 .foregroundStyle(.secondary) 90 } else { 91 ForEach(events.indices.reversed(), id: \.self) { index in 92 Text(events[index]) 93 .font(.caption.monospaced()) 94 .textSelection(.enabled) 95 } 96 } 97 } 98 } 99 .navigationTitle("WebRTC Host Test") 100 .navigationBarTitleDisplayMode(.inline) 101 .onAppear { 102 host?.onEvent = { event in 103 handle(event) 104 } 105 } 106 .onDisappear { 107 host?.teardown(engagementID: engagementID) 108 } 109 } 110 111 @ViewBuilder 112 private func debugTextEditor(text: Binding<String>, minHeight: CGFloat) -> some View { 113 TextEditor(text: text) 114 .font(.caption.monospaced()) 115 .textInputAutocapitalization(.never) 116 .autocorrectionDisabled() 117 .frame(minHeight: minHeight) 118 } 119 120 private func createOffer() async { 121 guard let host else { return } 122 await run("create offer") { 123 let signal = try await host.createOffer(engagementID: engagementID) 124 localSignalJSON = try encode(signal) 125 } 126 } 127 128 private func acceptOfferAndReply() async { 129 guard let host else { return } 130 await run("accept offer") { 131 let offer = try decodeSignal(remoteSignalJSON) 132 let reply = try await host.acceptOfferAndReply( 133 engagementID: engagementID, 134 signal: offer 135 ) 136 localSignalJSON = try encode(reply) 137 } 138 } 139 140 private func acceptReply() async { 141 guard let host else { return } 142 await run("accept reply") { 143 let reply = try decodeSignal(remoteSignalJSON) 144 try await host.acceptReply(engagementID: engagementID, signal: reply) 145 } 146 } 147 148 private func sendMessage() { 149 guard let data = outboundMessage.data(using: .utf8) else { return } 150 do { 151 try host?.send(engagementID: engagementID, message: data) 152 appendEvent("Sent message: \(outboundMessage)") 153 } catch { 154 appendEvent("Send failed: \(error.localizedDescription)") 155 } 156 } 157 158 private func run(_ label: String, operation: () async throws -> Void) async { 159 guard !isBusy else { return } 160 isBusy = true 161 appendEvent("Starting \(label)") 162 defer { isBusy = false } 163 164 do { 165 try await operation() 166 appendEvent("Finished \(label)") 167 } catch { 168 appendEvent("\(label) failed: \(error.localizedDescription)") 169 } 170 } 171 172 private func handle(_ event: EngagementHost.Event) { 173 switch event { 174 case .signal(let id, let signal): 175 guard id == engagementID else { return } 176 if let json = try? encode(signal) { 177 localSignalJSON = json 178 } 179 appendEvent("Signal generated") 180 case .channelOpen(let id): 181 guard id == engagementID else { return } 182 appendEvent("Channel open") 183 case .channelMessage(let id, let message): 184 guard id == engagementID else { return } 185 let text = String(data: message, encoding: .utf8) 186 ?? message.base64EncodedString() 187 appendEvent("Received message: \(text)") 188 case .channelClose(let id): 189 guard id == engagementID else { return } 190 appendEvent("Channel closed") 191 case .error(let id, let message): 192 guard id == nil || id == engagementID else { return } 193 appendEvent("Error: \(message)") 194 } 195 } 196 197 private func encode(_ signal: EngagementHost.Signal) throws -> String { 198 let data = try encoder.encode(signal) 199 guard let string = String(data: data, encoding: .utf8) else { 200 throw EngagementDebugError.invalidUTF8 201 } 202 return string 203 } 204 205 private func decodeSignal(_ string: String) throws -> EngagementHost.Signal { 206 guard let data = string.data(using: .utf8) else { 207 throw EngagementDebugError.invalidUTF8 208 } 209 return try JSONDecoder().decode(EngagementHost.Signal.self, from: data) 210 } 211 212 private func appendEvent(_ message: String) { 213 let time = Self.eventTimeFormatter.string(from: Date()) 214 events.append("\(time) \(message)") 215 if events.count > 100 { 216 events.removeFirst(events.count - 100) 217 } 218 } 219 220 private static let eventTimeFormatter: DateFormatter = { 221 let formatter = DateFormatter() 222 formatter.dateStyle = .none 223 formatter.timeStyle = .medium 224 return formatter 225 }() 226 } 227 228 private enum EngagementDebugError: LocalizedError { 229 case invalidUTF8 230 231 var errorDescription: String? { 232 switch self { 233 case .invalidUTF8: 234 "The signal could not be encoded as UTF-8." 235 } 236 } 237 }