# WebSocket Bridge

**Source:** `apps/macos/Sources/SOPHIAClaw/Services/WebSocket/WebSocketService.swift` + `src/gateway/client.ts`  
**Category:** UI/UX Components - Communication  
**Purpose:** Bidirectional communication channel between macOS app and SophiaClaw Gateway

## Overview

The WebSocket Bridge provides real-time, bidirectional communication between the macOS native application and the SophiaClaw Gateway server. It implements the SophiaClaw WebSocket protocol with connection management, authentication, message framing, and automatic reconnection.

```
┌─────────────────────────────────────────────────────────────┐
│              macOS App (WebSocketService)                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  BridgeCoordinator                                   │   │
│  │  - Route messages to UI                              │   │
│  │  - Queue outgoing requests                           │   │
│  │  - Handle approvals                                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                          │                                  │
│  ┌───────────────────────▼───────────────────────────────┐ │
│  │  WebSocketService                                      │ │
│  │  - URLSessionWebSocketTask                           │ │
│  │  - Connect/request/response handling                  │ │
│  │  - Auto-reconnect with backoff                        │ │
│  └───────────────────────┬───────────────────────────────┘ │
└──────────────────────────┼──────────────────────────────────┘
                           │ ws://127.0.0.1:37521
                           │ wss://remote-host:37521
                           │
                           v
┌──────────────────────────┼──────────────────────────────────┐
│                 Gateway Server                              │
│                                                             │
│  ┌───────────────────────▼───────────────────────────────┐ │
│  │  WebSocketServer (ws library)                         │ │
│  │  - Authentication (device token / shared token)       │ │
│  │  - Message routing (chat/agent/events)                │ │
│  │  - Rate limiting, policy enforcement                  │ │
│  └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```

## Connection Lifecycle

### 1. Initial Connection

```swift
class WebSocketService: ObservableObject {
    private var webSocketTask: URLSessionWebSocketTask?
    private var isConnected = false

    func connect(
        to url: URL,
        deviceToken: String?,
        sharedToken: String?
    ) async throws {
        // Build auth headers
        var request = URLRequest(url: url)

        if let token = deviceToken ?? sharedToken {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        // Create WebSocket task
        let session = URLSession(configuration: .default)
        webSocketTask = session.webSocketTask(with: request)

        // Open connection
        try await webSocketTask?.open()
        isConnected = true

        // Start receive loop
        Task {
            await self.receiveLoop()
        }
    }
}
```

### 2. Connect Challenge-Response Protocol

**Protocol Flow:**

```
Client                                   Gateway
   │                                         │
   │────── WebSocket Handshake ─────────────▶│
   │         (HTTP Upgrade)                  │
   │                                         │
   │◄───── connect.challenge ────────────────│
   │        { nonce: "abc123" }              │
   │                                         │
   │────── connect request ─────────────────▶│
   │  {                                       │
   │    "minProtocol": 3,                    │
   │    "maxProtocol": 3,                    │
   │    "client": {                          │
   │      "id": "gateway_client",            │
   │      "version": "2026.2.17",            │
   │      "platform": "darwin"               │
   │    },                                    │
   │    "device": {                          │
   │      "id": "device-uuid",               │
   │      "publicKey": "base64url",          │
   │      "signature": "base64url",          │
   │      "nonce": "abc123",                 │
   │      "signedAt": 1234567890             │
   │    }                                     │
   │  }                                       │
   │                                         │
   │◄───── hello.ok ──────────────────────────│
   │  {                                       │
   │    "auth": {                            │
   │      "deviceToken": "new-token",        │
   │      "role": "operator",                │
   │      "scopes": ["operator.admin"]       │
   │    },                                    │
   │    "policy": {                          │
   │      "tickIntervalMs": 30000            │
   │    }                                     │
   │  }                                       │
   │                                         │
   │────── Regular events flow ────────────▶▶│
```

**Swift Implementation:**

```swift
func sendConnect(nonce: String) async throws {
    let device = buildDeviceAuthPayload(
        deviceId: deviceIdentity.deviceId,
        nonce: nonce,
        signedAtMs: Date().timeIntervalSince1970 * 1000
    )

    let params: ConnectParams = ConnectParams(
        minProtocol: PROTOCOL_VERSION,
        maxProtocol: PROTOCOL_VERSION,
        client: ClientInfo(
            id: "gateway_client",
            displayName: "SophiaClaw Mac",
            version: APP_VERSION,
            platform: "darwin"
        ),
        device: device
    )

    let helloOk: HelloOk = try await request("connect", params: params)

    // Store device token for future connections
    if let token = helloOk.auth?.deviceToken {
        keychain.save(token, for: "device_token")
    }

    // Start tick watchdog
    startTickWatch(intervalMs: helloOk.policy?.tickIntervalMs ?? 30000)
}
```

### 3. Reconnection with Exponential Backoff

```swift
private var reconnectDelay = 1.0
private let maxReconnectDelay = 30.0

func handleDisconnection(reason: String) {
    isConnected = false

    // Schedule reconnect with backoff
    DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay) { [weak self] in
        self?.reconnect()
    }
}

func reconnect() {
    // Double delay for next time (exponential backoff)
    reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay)

    // Attempt reconnection
    Task {
        try? await connect(to: gatewayUrl)
    }
}

func resetBackoffOnSuccess() {
    reconnectDelay = 1.0  // Reset to initial delay
}
```

**Backoff Sequence:**

```
Attempt 1: 1s delay
Attempt 2: 2s delay
Attempt 3: 4s delay
Attempt 4: 8s delay
Attempt 5: 16s delay
Attempt 6+: 30s delay (capped)
```

## Message Framing

### Request-Response Pattern

```swift
struct RequestFrame: Codable {
    let type = "req"
    let id: String          // UUID
    let method: String
    let params: Codable?
}

struct ResponseFrame: Codable {
    let type = "res"
    let id: String          // Matches request ID
    let ok: Bool
    let payload: Codable?
    let error: ErrorInfo?
}

// Request with promise-like pattern
func request<T: Codable>(
    _ method: String,
    params: Codable? = nil
) async throws -> T {
    let id = UUID().uuidString

    return try await withCheckedThrowingContinuation { continuation in
        // Store continuation for response
        pendingRequests[id] = continuation

        // Send request
        let frame = RequestFrame(id: id, method: method, params: params)
        send(frame)
    }
}

// Response handler
private func handleResponse(_ frame: ResponseFrame) {
    guard let continuation = pendingRequests.removeValue(forKey: frame.id) else {
        return
    }

    if frame.ok {
        continuation.resume(returning: frame.payload as! T)
    } else {
        continuation.resume(throwing: frame.error!)
    }
}
```

### Event Subscription Pattern

```swift
// Event frame (server-pushed)
struct EventFrame: Codable {
    let type = "evt"
    let event: String       // "chat.message", "agent.status"
    let payload: Codable
    let seq: Int?           // Sequence number for gap detection
}

// Event handler registration
typealias EventHandler = (EventFrame) -> Void
private var eventHandlers: [String: [EventHandler]] = [:]

func on(event: String, handler: @escaping EventHandler) {
    eventHandlers[event, default: []].append(handler)
}

// Broadcast event to所有 handlers
private func dispatchEvent(_ frame: EventFrame) {
    guard let handlers = eventHandlers[frame.event] else { return }

    for handler in handlers {
        handler(frame)
    }
}
```

## Receive Loop

### Continuous Message Processing

```swift
private func receiveLoop() async {
    while isConnected {
        do {
            let message = try await webSocketTask?.receive()

            switch message {
            case .string(let text):
                await handleTextMessage(text)

            case .data(let data):
                await handleBinaryMessage(data)

            case nil:
                // WebSocket closed
                await handleDisconnection(reason: "closed")
                return
            }
        } catch {
            await handleDisconnection(reason: error.localizedDescription)
            return
        }
    }
}

private func handleTextMessage(_ text: String) async {
    guard let data = text.data(using: .utf8) else { return }

    do {
        // Try parsing as event frame first
        if let event = try? JSONDecoder().decode(EventFrame.self, from: data) {
            await dispatchEvent(event)
            return
        }

        // Try parsing as response frame
        if let response = try? JSONDecoder().decode(ResponseFrame.self, from: data) {
            await handleResponse(response)
            return
        }

        logger.error("Unknown frame type")
    } catch {
        logger.error("Parse error: \(error)")
    }
}
```

## Sequence Number & Gap Detection

### Message Ordering Guarantee

```swift
private var lastSeq: Int? = nil

func handleEvent(_ frame: EventFrame) {
    guard let seq = frame.seq else { return }

    // Detect gaps (missed messages)
    if let lastSeq = lastSeq, seq > lastSeq + 1 {
        let gap = GapInfo(expected: lastSeq + 1, received: seq)
        onGap?(gap)

        // Request replay of missed messages
        Task {
            try? await request("events.replay", params: [
                "from": lastSeq + 1,
                "to": seq - 1
            ])
        }
    }

    lastSeq = seq
}
```

**Gap Recovery:**

```
Client receives: seq=1, seq=2, seq=5 (gap: 3,4 missing)
                 │
                 ├─ Detect gap: expected=3, received=5
                 │
                 └─ Request replay: events.replay { from: 3, to: 4 }
                                    │
                                    v
                          Gateway resends missing events
```

## Tick Watchdog

### Silent Stall Detection

```swift
private var lastTickDate: Date?
private var tickTimer: Timer?
private let tickIntervalMs: Int

func startTickWatch(intervalMs: Int) {
    self.tickIntervalMs = intervalMs

    // Check every interval (with safety margin)
    tickTimer = Timer.scheduledTimer(withTimeInterval: Double(intervalMs) / 1000.0) { [weak self] _ in
        self?.checkTickHealth()
    }
}

func checkTickHealth() {
    guard let lastTick = lastTickDate else { return }

    let gap = Date().timeIntervalSince(lastTick) * 1000  // ms

    // If no tick received in 2x interval, reconnect
    if gap > Double(tickIntervalMs) * 2 {
        logger.warning("Tick timeout - reconnecting")
        handleDisconnection(reason: "tick timeout")
    }
}

func onTickReceived() {
    lastTickDate = Date()
}
```

**Gateway Tick Event:**

```json
{
  "type": "evt",
  "event": "tick",
  "seq": 12345
}
```

## Authentication

### Device Token Authentication

```swift
struct DeviceAuthPayload: Codable {
    let id: String              // Device ID
    let publicKey: String       // Base64URL-encoded public key
    let signature: String       // Ed25519 signature
    let nonce: String           // Challenge nonce from gateway
    let signedAt: Int64         // Unix timestamp (ms)
}

func buildDeviceAuthPayload(
    deviceId: String,
    nonce: String,
    signedAtMs: Int64
) -> DeviceAuthPayload {
    // Build payload to sign
    let payload = """
    {\
      "deviceId": "\(deviceId)",\
      "nonce": "\(nonce)",\
      "signedAt": \(signedAtMs)\
    }\
    """

    // Sign with device private key
    let signature = try! signPayload(
        payload.data(using: .utf8)!,
        privateKey: deviceIdentity.privateKey
    )

    return DeviceAuthPayload(
        id: deviceId,
        publicKey: deviceIdentity.publicKeyBase64,
        signature: signature.base64EncodedString(),
        nonce: nonce,
        signedAt: signedAtMs
    )
}
```

### Token Persistence

```swift
// Save token on successful connect
func handleHelloOk(_ hello: HelloOk) {
    if let deviceToken = hello.auth?.deviceToken {
        // Store in keychain for future connections
        keychain.save(deviceToken, for: "device_token_\(deviceId)")
    }
}

// Load token on reconnect
func loadDeviceToken() -> String? {
    return keychain.load(for: "device_token_\(deviceId)")
}
```

## Error Handling

### Connection Error Categories

```swift
enum WebSocketError: LocalizedError {
    case connectionFailed(String)
    case authenticationFailed(String)
    case protocolMismatch(min: Int, max: Int, required: Int)
    case tickTimeout
    case messageSendFailed
    case serverClosed(code: Int, reason: String)

    var errorDescription: String? {
        switch self {
        case .connectionFailed(let detail):
            return "Failed to connect to gateway: \(detail)"
        case .authenticationFailed(let reason):
            return "Authentication failed: \(reason)"
        case .protocolMismatch(let min, let max, let required):
            return "Protocol version mismatch. Client: \(min)-\(max), Server: \(required)"
        case .tickTimeout:
            return "Gateway connection timed out"
        case .messageSendFailed:
            return "Failed to send message"
        case .serverClosed(let code, let reason):
            return "Gateway closed connection (\(code)): \(reason)"
        }
    }
}
```

### Close Code Handling

```swift
func handleClose(code: Int, reason: String) {
    switch code {
    case 1000:  // Normal closure
        logger.info("Gateway closed normally")

    case 1006:  // Abnormal closure (no close frame)
        logger.warning("Gateway connection lost")
        scheduleReconnect()

    case 1008:  // Policy violation
        logger.error("Gateway policy violation: \(reason)")

        // Don't reconnect on auth failures
        if reason.lowercased().contains("device token mismatch") {
            clearDeviceToken()
        }

    case 1012:  // Service restart
        logger.info("Gateway restarting - will reconnect")
        scheduleReconnect()

    case 4000:  // Tick timeout
        logger.warning("Tick watchdog triggered reconnect")
        scheduleReconnect()

    default:
        logger.error("Unknown close code: \(code)")
        scheduleReconnect()
    }
}
```

## Bridge Coordinator

### Message Routing

```swift
class BridgeCoordinator: ObservableObject {
    private let webSocketService: WebSocketService

    init(webSocketService: WebSocketService) {
        self.webSocketService = webSocketService

        // Subscribe to events
        webSocketService.on(event: "chat.message") { [weak self] event in
            self?.onChatMessage(event.payload)
        }

        webSocketService.on(event: "agent.status") { [weak self] event in
            self?.onAgentStatus(event.payload)
        }

        webSocketService.on(event: "approval.request") { [weak self] event in
            self?.onApprovalRequest(event.payload)
        }
    }

    private func onChatMessage(_ payload: ChatMessagePayload) {
        // Update conversation state
        DispatchQueue.main.async {
            self.conversationViewModel.appendMessage(payload.toMessage())
        }
    }

    private func onApprovalRequest(_ payload:_approvalRequestPayload) {
        // Show approval modal
        DispatchQueue.main.async {
            self.appState.addPendingApproval(payload.toApprovalRequest())
        }
    }
}
```

### Outgoing Request Queue

```swift
private var requestQueue: [RequestFrame] = []
private var isSending = false

func queueRequest(_ request: RequestFrame) {
    requestQueue.append(request)
    Task {
        await flushRequestQueue()
    }
}

private func flushRequestQueue() async {
    guard !isSending && !requestQueue.isEmpty else { return }
    isSending = true

    while let request = requestQueue.first {
        requestQueue.removeFirst()

        do {
            try await send(request)
        } catch {
            // Re-queue failed request
            requestQueue.insert(request, at: 0)
            break
        }
    }

    isSending = false
}
```

## Security

### TLS Configuration

```swift
func connect(to url: URL, tlsFingerprint: String?) async throws {
    guard url.scheme == "wss" || url.host == "127.0.0.1" else {
        throw WebSocketError.connectionFailed("Must use wss:// or loopback")
    }

    var request = URLRequest(url: url)

    // Optional TLS fingerprint pinning
    if let fingerprint = tlsFingerprint {
        // Configure URLSession for certificate pinning
        let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        pinnedFingerprint = fingerprint
    }

    webSocketTask = session.webSocketTask(with: request)
    try await webSocketTask?.open()
}

// URLSessionDelegate for certificate validation
extension WebSocketService: URLSessionDelegate {
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard let certificate = challenge.protectionSpace.serverTrust,
              let certData = SecCertificateCopyData(SecTrustGetCertificateAtIndex(certificate, 0)) as Data?
        else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Calculate SHA256 fingerprint
        let fingerprint = SHA256.hash(data: certData).map { String(format: "%02x", $0) }.joined()

        if fingerprint == pinnedFingerprint {
            completionHandler(.useCredential, URLCredential(trust: certificate))
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}
```

## Related Documentation

- [Gateway Protocol](/docs/reference/gateway-protocol.md)
- [Device Pairing](/docs/gateway/device-pairing.md)
- [macOS AppState](/docs/algorithms/ui-ux/macos-appstate.md)
