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.

A production-style integration demonstrating custom configuration, lifecycle management, error handling, capability negotiation, and graceful degradation for environment-sensitive features like NFC.

What this example demonstrates

  • Typed capability slots for document capture with CameraDetector permission state
  • Custom capability registration for NFC
  • Runtime capability detection with pre-launch validation
  • CaptureResult-based response encoding (no manual JSONValue dictionaries)
  • Lifecycle management (background/foreground events)
  • Delegate-based message observation and routing
  • Graceful degradation for unsupported capabilities

Complete source

The full app below is a single SwiftUI target that demonstrates pre-launch capability validation, typed slots for document capture, custom NFC capability registration, lifecycle event forwarding, and delegate-based message observation. Read through the section markers (// MARK: -) to navigate; each block is the production-style version of a pattern shown elsewhere in the docs.
import Combine
import CoreNFC
import GBGBridge
import SwiftUI

// MARK: - App Entry Point

@main
struct AdvancedApp: App {
    var body: some Scene {
        WindowGroup {
            JourneyLauncherView()
        }
    }
}

// MARK: - Journey Configuration

/// Centralized configuration for the identity verification journey.
struct JourneyConfig {
    let url: URL
    let requiredCapabilities: [String]
    let hostVersion: String

    static let passportVerification = JourneyConfig(
        url: URL(string: "https://journey.example.com/passport")!,
        requiredCapabilities: ["camera.document", "nfc.read"],
        hostVersion: "1.0.0"
    )

    static let documentOnly = JourneyConfig(
        url: URL(string: "https://journey.example.com/document")!,
        requiredCapabilities: ["camera.document"],
        hostVersion: "1.0.0"
    )
}

// MARK: - Journey Launcher (Pre-launch Validation)

struct JourneyLauncherView: View {
    @State private var selectedJourney: JourneyConfig = .passportVerification
    @State private var showJourney = false
    @State private var showCapabilityWarning = false

    private let deviceCapabilities = DeviceCapabilities.detect()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Text("Identity Verification")
                    .font(.title2)
                    .fontWeight(.semibold)

                // Journey selection
                VStack(spacing: 12) {
                    JourneyOptionButton(
                        title: "Passport Verification",
                        subtitle: "Document + NFC chip read",
                        requirements: ["camera.document", "nfc.read"],
                        deviceCapabilities: deviceCapabilities
                    ) {
                        selectedJourney = .passportVerification
                        attemptLaunch()
                    }

                    JourneyOptionButton(
                        title: "Document Verification",
                        subtitle: "Document capture only",
                        requirements: ["camera.document"],
                        deviceCapabilities: deviceCapabilities
                    ) {
                        selectedJourney = .documentOnly
                        attemptLaunch()
                    }
                }
                .padding(.horizontal)

                // Device capability summary
                DeviceCapabilitySummary(capabilities: deviceCapabilities)

                Spacer()
            }
            .padding(.top, 32)
            .navigationTitle("Verification")
            .navigationBarTitleDisplayMode(.inline)
            .fullScreenCover(isPresented: $showJourney) {
                JourneyContainerView(config: selectedJourney)
            }
            .alert("Capability Warning", isPresented: $showCapabilityWarning) {
                Button("Continue Anyway") {
                    showJourney = true
                }
                Button("Cancel", role: .cancel) {}
            } message: {
                let missing = missingCapabilities(for: selectedJourney)
                Text("This journey requires \(missing.joined(separator: ", ")) which \(missing.count == 1 ? "is" : "are") not available on this device. The journey may not complete successfully.")
            }
        }
    }

    private func attemptLaunch() {
        let missing = missingCapabilities(for: selectedJourney)
        if missing.isEmpty {
            showJourney = true
        } else {
            showCapabilityWarning = true
        }
    }

    private func missingCapabilities(for journey: JourneyConfig) -> [String] {
        journey.requiredCapabilities.filter { capability in
            deviceCapabilities.isSupported(capability) != true
        }
    }
}

// MARK: - Device Capabilities Detection

struct DeviceCapabilities {
    let nfcAvailable: Bool
    let cameraResult: CameraDetector.Result

    static func detect() -> DeviceCapabilities {
        DeviceCapabilities(
            nfcAvailable: NFCTagReaderSession.readingAvailable,
            cameraResult: CameraDetector.check()
        )
    }

    func isSupported(_ capability: String) -> Bool {
        switch capability {
        case "camera.document": return cameraResult.hardwareAvailable
        case "nfc.read": return nfcAvailable
        default: return false
        }
    }
}

// MARK: - Journey Container (Full Integration)

struct JourneyContainerView: View {
    let config: JourneyConfig

    @StateObject private var host: BridgeHost
    @StateObject private var coordinator = JourneyCoordinator()
    @Environment(\.dismiss) private var dismiss
    @State private var journeyState: JourneyState = .loading
    @State private var showDocumentCamera = false

    init(config: JourneyConfig) {
        self.config = config
        _host = StateObject(wrappedValue: BridgeHost(hostVersion: config.hostVersion))
    }

    var body: some View {
        NavigationStack {
            ZStack {
                // Journey WebView
                BridgeWebView(url: config.url, host: host)
                    .opacity(journeyState == .active ? 1 : 0)

                // Loading overlay
                if journeyState == .loading {
                    ProgressView("Loading verification...")
                }

                // Error overlay
                if case .error(let message) = journeyState {
                    JourneyErrorView(message: message) {
                        journeyState = .loading
                        // WebView will reload via URL change mechanism
                    }
                }

                // Completion overlay
                if case .completed(let result) = journeyState {
                    JourneyCompleteView(result: result) {
                        dismiss()
                    }
                }
            }
            .navigationTitle("Verification")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button("Cancel") {
                        host.send(event: "journey.cancel", data: [
                            "reason": .string("user_dismissed")
                        ])
                        dismiss()
                    }
                }
            }
            .onAppear { setupBridge() }
            .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
                host.send(event: "host.background", data: [:])
            }
            .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
                host.send(event: "host.foreground", data: [:])
            }
        }
    }

    private func setupBridge() {
        // Set delegate
        host.delegate = coordinator
        coordinator.onStateChange = { [weak coordinator] newState in
            Task { @MainActor in
                journeyState = newState
            }
        }

        let capabilities = DeviceCapabilities.detect()

        // Set up typed slot for document capture
        if capabilities.cameraResult.hardwareAvailable {
            host.documentCapture.handler = { [weak host] request in
                guard let host else { return .cancelled(reason: "Host deallocated") }
                return await host.documentCapture.awaitCompletion()
            }
            host.documentCapture.permissionState = capabilities.cameraResult.permissionState
        }

        // Register NFC as a custom capability
        if capabilities.nfcAvailable {
            host.registerCustomCapability("nfc.read", version: "1.0") { request, responder in
                guard NFCTagReaderSession.readingAvailable else {
                    responder.respond(
                        status: .unsupported,
                        data: nil,
                        error: BridgeErrorPayload(
                            code: "NFC_NOT_AVAILABLE",
                            message: "NFC reading is not available on this device.",
                            recoverable: false
                        )
                    )
                    return
                }

                // In a real app, start NFC reader session and read the chip
                responder.respond(
                    status: .success,
                    data: [
                        "chipRead": .bool(true),
                        "dataGroups": .array([.string("DG1"), .string("DG2")])
                    ],
                    error: nil
                )
            }
        }

        // Mark as active after a brief delay (allows WebView to initialize)
        Task {
            try? await Task.sleep(nanoseconds: 500_000_000)
            await MainActor.run {
                if journeyState == .loading {
                    journeyState = .active
                }
            }
        }
    }
}

// MARK: - Journey State

enum JourneyState: Equatable {
    case loading
    case active
    case completed(JourneyResult)
    case error(String)

    static func == (lhs: JourneyState, rhs: JourneyState) -> Bool {
        switch (lhs, rhs) {
        case (.loading, .loading), (.active, .active): return true
        case (.completed(let a), .completed(let b)): return a.status == b.status
        case (.error(let a), .error(let b)): return a == b
        default: return false
        }
    }
}

struct JourneyResult {
    let status: String
    let referenceId: String?
}

// MARK: - Journey Coordinator (Delegate)

@MainActor
final class JourneyCoordinator: ObservableObject, BridgeHostDelegate {
    var onStateChange: ((JourneyState) -> Void)?

    func bridgeHost(_ host: BridgeHost, didReceive message: BridgeMessage) {
        // Monitor for journey lifecycle events
        switch message.payload.action {
        case "journey.started":
            onStateChange?(.active)

        case "journey.complete":
            let status = extractString(from: message.payload.data, key: "status") ?? "unknown"
            let refId = extractString(from: message.payload.data, key: "referenceId")
            onStateChange?(.completed(JourneyResult(status: status, referenceId: refId)))

        case "journey.error":
            let errorMsg = extractString(from: message.payload.data, key: "message") ?? "An error occurred"
            onStateChange?(.error(errorMsg))

        default:
            break
        }
    }

    func bridgeHost(_ host: BridgeHost, unhandledRequest message: BridgeMessage) {
        // Respond to unknown requests with unsupported
        host.respond(
            to: message.correlationId,
            status: .unsupported,
            error: BridgeErrorPayload(
                code: "UNSUPPORTED_ACTION",
                message: "Action '\(message.payload.action)' is not supported by this host",
                recoverable: false
            )
        )
    }

    private func extractString(from data: [String: JSONValue]?, key: String) -> String? {
        guard let value = data?[key], case .string(let str) = value else { return nil }
        return str
    }
}

// MARK: - Supporting Views

struct JourneyOptionButton: View {
    let title: String
    let subtitle: String
    let requirements: [String]
    let deviceCapabilities: DeviceCapabilities
    let action: () -> Void

    private var allRequirementsMet: Bool {
        requirements.allSatisfy { deviceCapabilities.isSupported($0) }
    }

    var body: some View {
        Button(action: action) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    Text(title)
                        .font(.headline)
                        .foregroundColor(.primary)
                    Text(subtitle)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                Spacer()
                if allRequirementsMet {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundColor(.green)
                } else {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundColor(.orange)
                }
            }
            .padding()
            .background(Color(.secondarySystemGroupedBackground))
            .cornerRadius(12)
        }
    }
}

struct DeviceCapabilitySummary: View {
    let capabilities: DeviceCapabilities

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Device Capabilities")
                .font(.caption)
                .fontWeight(.semibold)
                .foregroundColor(.secondary)

            HStack(spacing: 16) {
                CapabilityBadge(name: "Camera", available: capabilities.cameraAvailable)
                CapabilityBadge(name: "NFC", available: capabilities.nfcAvailable)
            }
        }
        .padding(.horizontal)
    }
}

struct CapabilityBadge: View {
    let name: String
    let available: Bool

    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle.fill")
                .foregroundColor(available ? .green : .red)
                .font(.caption)
            Text(name)
                .font(.caption)
                .foregroundColor(.primary)
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 4)
        .background(
            Capsule()
                .fill(available ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
        )
    }
}

struct JourneyErrorView: View {
    let message: String
    let onRetry: () -> Void

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "exclamationmark.triangle")
                .font(.system(size: 48))
                .foregroundColor(.orange)
            Text("Verification Error")
                .font(.title3)
                .fontWeight(.semibold)
            Text(message)
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)
            Button("Retry") {
                onRetry()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

struct JourneyCompleteView: View {
    let result: JourneyResult
    let onDismiss: () -> Void

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: result.status == "success" ? "checkmark.circle.fill" : "xmark.circle.fill")
                .font(.system(size: 64))
                .foregroundColor(result.status == "success" ? .green : .red)
            Text(result.status == "success" ? "Verification Complete" : "Verification Failed")
                .font(.title3)
                .fontWeight(.semibold)
            if let refId = result.referenceId {
                Text("Reference: \(refId)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            Button("Done") {
                onDismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

// Note: CameraDetector (from GBGBridge) handles AVFoundation imports internally

Architecture overview

Key patterns demonstrated

Pre-launch capability validation

Before starting the journey, the launcher checks whether all required capabilities are available:
let missing = journey.requiredCapabilities.filter { capability in
    deviceCapabilities.isSupported(capability) != true
}

if missing.isEmpty {
    showJourney = true
} else {
    showCapabilityWarning = true  // Let user choose to continue or cancel
}
This prevents users from starting a journey that will fail partway through due to missing hardware.

Lifecycle events

The app sends events when entering background and returning to foreground. The web journey can use these to pause/resume timers, save state, or handle session timeouts.
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
    host.send(event: "host.background", data: [:])
}

Permission state with CameraDetector

CameraDetector.check() detects hardware and permission status. The result is assigned to the typed slot’s permissionState, which is automatically included in capability query responses:
let capabilities = DeviceCapabilities.detect()
host.documentCapture.permissionState = capabilities.cameraResult.permissionState
The web journey can now check permissionState before attempting capture and prompt the user to grant access if needed.

Typed Slots vs Custom Capabilities

This example demonstrates both patterns side by side:
  • Document capture uses a typed slot (host.documentCapture) — the SDK handles result encoding and busy rejection automatically.
  • NFC uses registerCustomCapability() — the handler receives a BridgeResponder and builds the response manually.

Graceful Degradation

NFC is detected at runtime. If not available, the launcher shows a warning, and the custom capability handler responds with .unsupported if somehow called.

Running this example

To run the example end-to-end, follow these setup steps:
  1. Add GBGBridge via SPM.
  2. Add to Info.plist:
    <key>NSCameraUsageDescription</key>
    <string>Camera is used for document capture during identity verification.</string>
    <key>NSNFCReaderUsageDescription</key>
    <string>NFC is used to read the chip in your identity document.</string>
    
  3. For NFC: Add the Near Field Communication Tag Reading capability.
  4. Update journeyURL to your web journey endpoint.
  5. Build and run.

Next steps