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 guide explains how GBGBridge handles capabilities — the native features that a host application can provide to web journeys. It covers typed capability slots (recommended), custom capability registration, the legacy configuration approach, capability negotiation, permission state, and graceful degradation patterns.

What is a Capability?

A capability is a native device feature that the host app can provide to the web journey. Examples include:
  • camera.document: Document photography
  • camera.selfie: Facial capture for liveness checks
  • nfc.read: Reading NFC chips on identity documents
Capabilities are identified by string keys using dot-separated namespaces.

Three Ways to Declare Capabilities

GBGBridge provides three approaches, listed from most to least recommended:
ApproachBest ForInit Method
Typed slotsDocument/selfie captureinit(hostVersion:)
Custom capabilityNFC, biometrics, or any non-camera capabilityinit(hostVersion:) + registerCustomCapability()
Configuration-basedFull manual controlinit(configuration:) + register(handler:)
All three can be used together. Typed slots take precedence over custom capabilities with the same ID. Typed slots are the recommended way to declare capture capabilities. Setting a handler on a slot simultaneously declares support and provides the implementation.

Basic Setup

let host = BridgeHost(hostVersion: "1.0.0")

// Declare document capture support
host.documentCapture.handler = { [weak host] request in
    guard let host else { return .cancelled(reason: "Host deallocated") }
    return await host.documentCapture.awaitCompletion()
}

// Declare selfie capture support
host.selfieCapture.handler = { [weak host] request in
    guard let host else { return .cancelled(reason: "Host deallocated") }
    return await host.selfieCapture.awaitCompletion()
}

Available Slots

PropertyCapability IDAction ID
host.documentCapturecamera.documentcamera.document.capture
host.selfieCapturecamera.selfiecamera.selfie.capture

Handler-as-Declaration

With typed slots, there is no separate “declaration” step. A slot’s isSupported property is computed as handler != nil && isEnabled. When the web journey sends a capability.query request, only slots with handlers appear as supported.
// Not supported — no handler set
host.documentCapture.isSupported  // false

// Supported — handler is set
host.documentCapture.handler = { request in ... }
host.documentCapture.isSupported  // true

// Temporarily disabled — handler set but isEnabled is false
host.documentCapture.isEnabled = false
host.documentCapture.isSupported  // false

Returning Results

Typed slot handlers return CaptureResult values. The SDK encodes them into the bridge protocol format automatically — no manual JSONValue dictionary construction needed.
// Document capture success
return .document(DocumentCaptureResult(
    imageData: imageData,
    width: 1920,
    height: 1080,
    mimeType: "image/jpeg"
))

// Selfie capture success
return .selfie(SelfieCaptureResult(
    previewImageData: previewData,
    width: 640,
    height: 480,
    encryptedBlob: encryptedData,
    unencryptedBlob: unencryptedData
))

// User cancelled
return .cancelled(reason: "User dismissed camera")

// Failure
return .failed(
    code: "CAMERA_DENIED",
    message: "Camera permission was denied",
    recoverable: true
)

SwiftUI Integration with awaitCompletion()

The awaitCompletion() / complete() pattern bridges async handlers with SwiftUI’s declarative presentation:
struct JourneyView: View {
    @StateObject private var host = BridgeHost(hostVersion: "1.0.0")
    @State private var showDocumentCamera = false

    var body: some View {
        BridgeWebView(url: journeyURL, host: host)
            .onChange(of: host.documentCapture.activeRequest?.correlationId) { _ in
                showDocumentCamera = host.documentCapture.activeRequest != nil
            }
            .fullScreenCover(isPresented: $showDocumentCamera, onDismiss: {
                // If dismissed without completing, cancel the request
                if host.documentCapture.activeRequest != nil {
                    host.documentCapture.complete(.cancelled(reason: "Dismissed"))
                }
            }) {
                DocumentCameraView { imageData, width, height in
                    host.documentCapture.complete(.document(
                        DocumentCaptureResult(
                            imageData: imageData,
                            width: width,
                            height: height
                        )
                    ))
                }
            }
            .onAppear { setupHandlers() }
    }

    private func setupHandlers() {
        host.documentCapture.handler = { [weak host] request in
            guard let host else { return .cancelled(reason: "Host deallocated") }
            return await host.documentCapture.awaitCompletion()
        }
    }
}
The flow is:
  1. Web journey sends camera.document.capture request.
  2. Handler runs, sets activeRequest, calls awaitCompletion() — suspends.
  3. SwiftUI detects activeRequest change, presents camera fullScreenCover.
  4. Camera completes, view calls host.documentCapture.complete(.document(...)).
  5. Handler resumes with the result, SDK encodes and sends the response.
  6. activeRequest resets to nil, cover dismisses.

Busy Rejection

If a request arrives while another is already active on the same slot, the SDK automatically responds with an error:
{
  "status": "error",
  "error": {
    "code": "BUSY",
    "message": "A camera.document capture request is already in progress",
    "recoverable": true
  }
}

Permission State

Each typed slot has a permissionState property. Populate it using CameraDetector so the web journey can check permissions before attempting capture:
let camera = CameraDetector.check()
host.documentCapture.permissionState = camera.permissionState
host.selfieCapture.permissionState = camera.permissionState
This information appears in the capability.query response:
{
  "camera.document": {
    "supported": true,
    "version": "1.0",
    "permissionState": "granted"
  }
}

Enable/Disable at Runtime

Toggle isEnabled to temporarily disable a slot without removing the handler:
// Disable selfie capture for this journey
host.selfieCapture.isEnabled = false

// Re-enable later
host.selfieCapture.isEnabled = true
When disabled, the slot reports isSupported = false in capability queries.

Custom Capability Registration

For capabilities that don’t have a typed slot, for example, NFC and biometrics, use registerCustomCapability():
let host = BridgeHost(hostVersion: "1.0.0")

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
    }

    do {
        let chipData = try await readNFCChip()
        responder.respond(
            status: .success,
            data: [
                "mrz": .string(chipData.mrz),
                "photo": .string(chipData.photo.base64EncodedString())
            ],
            error: nil
        )
    } catch {
        responder.respond(
            status: .error,
            data: nil,
            error: BridgeErrorPayload(
                code: "NFC_ERROR",
                message: error.localizedDescription,
                recoverable: true
            )
        )
    }
}
Custom capabilities automatically appear in capability.query responses as supported. If a typed slot exists for the same ID, the typed slot takes precedence.

Configuration-Based Approach (Legacy)

The configuration-based approach gives you full manual control. Use it when you need explicit capability dictionaries with constraints.
let configuration = BridgeConfiguration(
    hostVersion: "1.0.0",
    capabilities: [
        "camera.document": BridgeCapabilityInfo(
            supported: true,
            version: "1.0",
            constraints: [
                "maxResolution": .number(4096),
                "formats": .array([.string("jpeg"), .string("png")])
            ],
            permissionState: "granted"
        ),
        "nfc.read": BridgeCapabilityInfo(
            supported: true,
            version: "1.0"
        )
    ]
)

let host = BridgeHost(configuration: configuration)
host.register(handler: DocumentCaptureHandler())
host.register(handler: NFCReadHandler())
With this approach, you implement BridgeCapabilityHandler for each action:
public final class NFCReadHandler: BridgeCapabilityHandler, @unchecked Sendable {
    public let action = "nfc.read"

    public func handle(request: BridgeMessage, responder: BridgeResponder) async {
        // Handle the request and call responder.respond(...)
    }
}

Handler Lifecycle

  1. Registration — Call host.register(handler:) before the web journey sends requests.
  2. Request routing — When a matching request arrives, handle(request:responder:) is called in an async task.
  3. Response — Call responder.respond(...) exactly once with the result.
  4. Unregistration — Call host.unregister(action:) to remove a handler.

Capability Negotiation

How It Works

The web journey sends a capability.query request. GBGBridge’s built-in CapabilityQueryHandler responds automatically. When using init(hostVersion:), the response is built dynamically from typed slots and custom capabilities. When using init(configuration:), it uses the static BridgeConfiguration dictionary.

Query Response

The web journey receives:
{
  "environment": "ios",
  "hostVersion": "1.0.0",
  "capabilities": {
    "camera.document": { "supported": true, "version": "1.0", "permissionState": "granted" },
    "camera.selfie": { "supported": true, "version": "1.0", "permissionState": "notDetermined" },
    "nfc.read": { "supported": true, "version": "1.0" }
  }
}
The permissionState field appears when the capability provides permission metadata (typed slots with permissionState set, or BridgeCapabilityInfo with permissionState non-nil).

Environment-Specific Behavior

The Problem

Not all environments support the same capabilities:
CapabilityiOS NativeWeb (iframe)Android Native
Camera captureYesLimitedYes
NFC chip readYes (iPhone 7+)NoYes (varies)
Face ID / Touch IDYesNoFingerprint/Face Unlock
When a web journey includes an NFC step but the host doesn’t support NFC, the journey needs to know before reaching that step.

Detecting Environment from the Web Journey

The capability.query response includes an environment field ("ios", "android", or "web" for iframe hosts). The web journey uses both the environment and the capability flags to make routing decisions.

Runtime Hardware Detection on iOS

Use CameraDetector for camera hardware and permission detection:
let camera = CameraDetector.check()
// camera.hardwareAvailable — whether camera hardware exists
// camera.permissionState — .granted, .denied, .notDetermined, .restricted
For NFC, check at initialization time:
import CoreNFC

let nfcSupported = NFCTagReaderSession.readingAvailable

if nfcSupported {
    host.registerCustomCapability("nfc.read", version: "1.0") { request, responder in
        // Handle NFC
    }
}

Graceful Degradation Patterns

When a capability isn’t available, your integration has two main routes: fall back to a web-based equivalent, or check upfront and prevent the user from starting a journey that won’t complete. The patterns below show both.

Pattern 1: Fall Back to Web or Skip

The web journey checks capabilities and adapts its flow. If a web-based fallback exists for the capability, the journey uses it. If there is no web equivalent, the step is skipped entirely.
Journey: Document Capture -> NFC Read -> Face Match -> Result

If camera.document is unsupported (web fallback exists):
Journey: Document Capture (web) -> NFC Read -> Face Match -> Result

If NFC is unsupported (no web fallback):
Journey: Document Capture -> Face Match -> Result (NFC skipped)
The host app doesn’t need to do anything special — the web journey handles fallback and routing decisions based on the capability query response.

Pattern 2: Check Permissions Before Starting

With permission state in the capability query, the web journey can detect permission issues and prompt the user:
If camera.document.permissionState == "denied":
    Show "Please enable camera access in Settings" before starting capture

Pattern 3: Respond with Unsupported Status

If the web journey sends a request for a capability the host doesn’t support, typed slots automatically respond with .unsupported when no handler is set. For custom capabilities, respond explicitly:
responder.respond(
    status: .unsupported,
    data: nil,
    error: BridgeErrorPayload(
        code: "CAPABILITY_UNAVAILABLE",
        message: "NFC is not available on this device",
        recoverable: false
    )
)

Dynamic Capability Updates

With typed slots, capability state is inherently dynamic:
  • Set or clear handler to change support status.
  • Toggle isEnabled to temporarily disable a slot.
  • Update permissionState when permissions change (e.g., after returning from Settings).
The web journey should re-query capabilities after significant state changes (e.g., after the app returns from background) to pick up any changes.

Next Steps