crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }