Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.go.gbgplc.com/llms.txt

Use this file to discover all available pages before exploring further.

This example demonstrates bidirectional messaging between the native iOS host and the web journey: sending events to the web journey and handling incoming requests with capability handlers.

What this example demonstrates

  • Sending events from native to web
  • Declaring capture support via typed capability slots
  • Registering custom capabilities for non-camera actions
  • Responding with success, error, and cancellation statuses
  • Observing all bridge traffic via BridgeHostDelegate

Complete source

import GBGBridge
import SwiftUI

// MARK: - App Entry Point

@main
struct TwoWayApp: App {
    var body: some Scene {
        WindowGroup {
            TwoWayJourneyView()
        }
    }
}

// MARK: - Main View

struct TwoWayJourneyView: View {
    @StateObject private var host = BridgeHost(hostVersion: "1.0.0")
    @StateObject private var logger = MessageLogger()

    private let journeyURL = URL(string: "https://journey.example.com")!

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Journey WebView
                BridgeWebView(url: journeyURL, host: host)

                // Message log (bottom panel)
                MessageLogView(messages: logger.log)
                    .frame(height: 200)
            }
            .navigationTitle("Two-Way Bridge")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Send Ping") {
                        sendPingEvent()
                    }
                }
            }
            .onAppear {
                setupBridge()
            }
        }
    }

    private func setupBridge() {
        // Declare document capture support via typed slot
        host.documentCapture.handler = { [weak host] request in
            guard let host else { return .cancelled(reason: "Host deallocated") }

            // Simulate a capture delay
            try? await Task.sleep(nanoseconds: 1_000_000_000)

            // Return a mock result
            let mockData = Data(repeating: 0xFF, count: 100)
            return .document(DocumentCaptureResult(
                imageData: mockData,
                width: 1920,
                height: 1080,
                mimeType: "image/jpeg"
            ))
        }

        // Register custom capability for device info
        host.registerCustomCapability("device.info", version: "1.0") { request, responder in
            let info: [String: JSONValue] = await MainActor.run {
                let device = UIDevice.current
                return [
                    "model": .string(device.model),
                    "systemName": .string(device.systemName),
                    "systemVersion": .string(device.systemVersion),
                    "name": .string(device.name),
                    "userInterfaceIdiom": .string(device.userInterfaceIdiom == .pad ? "pad" : "phone")
                ]
            }
            responder.respond(status: .success, data: info, error: nil)
        }

        // Set delegate for logging
        host.delegate = logger

        // Send initial event
        host.send(event: "host.ready", data: [
            "timestamp": .number(Date().timeIntervalSince1970 * 1000),
            "version": .string("1.0.0")
        ])
    }

    private func sendPingEvent() {
        host.send(event: "host.ping", data: [
            "timestamp": .number(Date().timeIntervalSince1970 * 1000)
        ])
    }
}

// MARK: - Message Logger (Delegate)

/// Observes all bridge traffic and maintains a log for the debug UI.
@MainActor
final class MessageLogger: ObservableObject, BridgeHostDelegate {
    struct LogEntry: Identifiable {
        let id = UUID()
        let timestamp: Date
        let direction: String
        let type: String
        let action: String
    }

    @Published var log: [LogEntry] = []

    func bridgeHost(_ host: BridgeHost, didReceive message: BridgeMessage) {
        log.append(LogEntry(
            timestamp: Date(),
            direction: "IN",
            type: message.type.rawValue,
            action: message.payload.action
        ))
    }

    func bridgeHost(_ host: BridgeHost, unhandledRequest message: BridgeMessage) {
        log.append(LogEntry(
            timestamp: Date(),
            direction: "IN",
            type: "UNHANDLED",
            action: message.payload.action
        ))

        // Respond with unsupported for unknown actions
        host.respond(
            to: message.correlationId,
            status: .unsupported,
            error: BridgeErrorPayload(
                code: "UNSUPPORTED",
                message: "Action '\(message.payload.action)' is not supported",
                recoverable: false
            )
        )
    }
}

// MARK: - Message Log View

struct MessageLogView: View {
    let messages: [MessageLogger.LogEntry]

    private let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss.SSS"
        return formatter
    }()

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack {
                Text("Bridge Log")
                    .font(.caption)
                    .fontWeight(.semibold)
                Spacer()
                Text("\(messages.count) messages")
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(Color(.systemGroupedBackground))

            if messages.isEmpty {
                Spacer()
                HStack {
                    Spacer()
                    Text("No messages yet")
                        .font(.caption)
                        .foregroundColor(.secondary)
                    Spacer()
                }
                Spacer()
            } else {
                ScrollViewReader { proxy in
                    List(messages) { entry in
                        HStack(spacing: 8) {
                            Text(dateFormatter.string(from: entry.timestamp))
                                .font(.system(.caption2, design: .monospaced))
                                .foregroundColor(.secondary)

                            Text(entry.direction)
                                .font(.system(.caption2, design: .monospaced))
                                .fontWeight(.bold)
                                .foregroundColor(entry.direction == "IN" ? .blue : .green)

                            Text(entry.type)
                                .font(.system(.caption2, design: .monospaced))
                                .foregroundColor(.orange)

                            Text(entry.action)
                                .font(.system(.caption2, design: .monospaced))
                                .lineLimit(1)
                        }
                        .listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12))
                    }
                    .listStyle(.plain)
                    .onChange(of: messages.count) { _ in
                        if let last = messages.last {
                            proxy.scrollTo(last.id, anchor: .bottom)
                        }
                    }
                }
            }
        }
        .background(Color(.systemBackground))
        .overlay(
            Rectangle()
                .frame(height: 1)
                .foregroundColor(Color(.separator)),
            alignment: .top
        )
    }
}

Code explanation

The sections below cover what each part of the app does, in roughly the order they’re exercised at runtime.

Outgoing events (native β†’ web)

// Send an event when the host is ready
host.send(event: "host.ready", data: [
    "timestamp": .number(Date().timeIntervalSince1970 * 1000),
    "version": .string("1.0.0")
])
The web journey receives this via window.GBGBridge.receive() and can use the timestamp and version information to initialize its state.

Incoming requests (web β†’ native)

When the web journey sends a camera.document.capture request, the typed slot handler runs:
Web Journey                    BridgeHost              documentCapture slot
     β”‚                              β”‚                         β”‚
     β”œβ”€β”€ request: camera.document  ─►│                        β”‚
     β”‚   .capture                   β”œβ”€β”€ handler closure ─────►│
     β”‚                              β”‚                         β”‚ (simulate capture)
     β”‚                              │◄── CaptureResult ────────
     β”‚                              β”‚    (auto-encoded)       β”‚
     │◄── response: success ─────────                         β”‚
     β”‚   { imageBase64, imageWidth, β”‚                         β”‚
     β”‚     imageHeight, mimeType }  β”‚                         β”‚
The CaptureResult is automatically encoded to the bridge protocol format β€” no manual JSONValue dictionary construction needed.

Unhandled Requests

The MessageLogger delegate catches requests with no registered handler and responds with .unsupported. This prevents the web journey from waiting indefinitely for a response.

Running This Example

  1. Add GBGBridge to your project via SPM.
  2. Create a new SwiftUI App target.
  3. Replace the default ContentView with the code above.
  4. Update journeyURL to point to your web journey.
  5. Build and run.

Next Steps