Skip to main content

iOS Native BLE

In a native iOS app, WKWebView can host @ukeyfe/hardware-common-connect-sdk while CoreBluetooth handles BLE scanning, connection setup, writes, and notifications. The JavaScript SDK stays in the web layer, and native code bridges the BLE results back to it.

Read Underlying transport plug-in first to understand the plug-in interface, message frame format and BLE notification reassembly requirements.

This integration uses two native-side building blocks:

  • CoreBluetooth, provided by iOS, for BLE scanning, connection handling, writes, and notifications.
  • WKWebViewJavascriptBridge, installed through CocoaPods, to pass messages between JavaScript and native code.

Add the Bluetooth usage descriptions to Info.plist:

  • NSBluetoothAlwaysUsageDescription
  • NSBluetoothPeripheralUsageDescription for older iOS versions that may still read it.

UKey Wallet BLE uses this fixed UUID set:

  • Service UUID: primaryServiceUUIDString
  • Write characteristic: txCharacteristicUUIDString
  • Notify characteristic: rxCharacteristicUUIDString

Part 1: Pods & Web

Podfile (add bridge dependency):

platform :ios, '13.0'
use_frameworks!

target 'YourAppTarget' do
pod 'WKWebViewJavascriptBridge'
end

Build the sample web package:

# Start from the hardware-js-sdk repo root
cd packages/connect-examples/native-ios-example/web
yarn && yarn build # build output goes to web/web_dist/

When adding the built web assets to the app bundle, choose one of these arrangements:

  • Keep the web_dist directory tree, which is usually the smoother path:

    • Add the full web/web_dist/ directory to the Xcode project, for instance as a resource group named web/web_dist, and confirm it is part of "Copy Bundle Resources".
    • WKWebView should open web/web_dist/index.html.
  • Flatten the generated files into a resource folder:

    • Place the contents of web/web_dist/ at the app bundle root, or in another resource directory you choose.
    • The matching entry point is typically index.html.

No matter which method you choose, make sure the <script src="..."> path in the HTML is consistent with the actual resource location.

Part 2: States & Handlers

class UKeyBleBridgeViewController: UIViewController {
// Objects related to WebView and JSBridge
var embeddedWebView: WKWebView!
var jsBridge: WKWebViewJavascriptBridge!

// State tracked for the BLE connection flow
var centralManager: CBCentralManager!
var activePeripheral: CBPeripheral?
var txCharacteristic: CBCharacteristic?
var rxCharacteristic: CBCharacteristic?

// UKey Wallet BLE constants
let primaryServiceUUIDString = ["00000001", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")
let txCharacteristicUUIDString = ["00000002", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")
let rxCharacteristicUUIDString = ["00000003", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")

// Stored bridge callbacks, such as device enumeration handlers
var pendingEnumerateCallback: ((Any?) -> Void)?
}

Part 3: Load Bridge

The order of initialization is important. First create the WebView and bridge object, then register the native handler, and finally load the HTML. Otherwise the JS side might have started calling the plugin and the native handler is not ready yet.

  • Create WKWebView.
  • Create a bridge object.
  • Register handlers enumerate, connect, disconnect, send, monitorCharacteristic, etc.
  • Load the packaged web/web_dist/.
import CoreBluetooth
import WebKit
import WKWebViewJavascriptBridge

class UKeyBleBridgeViewController: UIViewController {
// Other state omitted

override func viewDidLoad() {
super.viewDidLoad()

centralManager = CBCentralManager(delegate: self, queue: .main)

embeddedWebView = WKWebView(frame: view.bounds)
view.addSubview(embeddedWebView)

jsBridge = WKWebViewJavascriptBridge(webView: embeddedWebView)
registerNativeBridgeHandlers()

// Load the packaged HTML with the first layout above
if let bundleHtmlPath = Bundle.main.path(forResource: "index", ofType: "html", inDirectory: "web/web_dist") {
embeddedWebView.load(URLRequest(url: URL(fileURLWithPath: bundleHtmlPath)))
}
}
}

Part 4: Bridge Ops

extension UKeyBleBridgeViewController {
func registerNativeBridgeHandlers() {
// enumerate: run a BLE scan and send [{ id, name }] back to JS
jsBridge.register(handlerName: "enumerate") { [weak self] _, bridgeCallback in
guard let self = self else { return }
self.pendingEnumerateCallback = bridgeCallback
self.centralManager.scanForPeripherals(
withServices: [CBUUID(string: self.primaryServiceUUIDString)], options: nil
)
// In production you would stop scanning quickly; see the demo for dedup and accumulation
}

// establish connection: open a link to the specified peripheral UUID
jsBridge.register(handlerName: "connect") { [weak self] requestPayload, bridgeCallback in
guard
let self = self,
let peripheralUUID = requestPayload?["uuid"] as? String,
let peripheralIdentifier = UUID(uuidString: peripheralUUID)
else {
bridgeCallback?(["success": false, "error": "Invalid UUID"])
return
}

// First see whether the system can return this peripheral from its known list
if let discoveredPeripheral = self.centralManager.retrievePeripherals(withIdentifiers: [peripheralIdentifier]).first {
self.activePeripheral = discoveredPeripheral
self.centralManager.connect(discoveredPeripheral, options: nil)
} else {
// Otherwise fall back to scanning and matching inside didDiscover
self.centralManager.scanForPeripherals(
withServices: [CBUUID(string: self.primaryServiceUUIDString)], options: nil
)
}
bridgeCallback?(["success": true])
}

// Handle a disconnect request coming from JS
jsBridge.register(handlerName: "disconnect") { [weak self] _, bridgeCallback in
if let connectedPeripheral = self?.activePeripheral {
self?.centralManager.cancelPeripheralConnection(connectedPeripheral)
}
bridgeCallback?(["success": true])
}

// send: write the hex payload into the device characteristic
jsBridge.register(handlerName: "send") { [weak self] requestPayload, bridgeCallback in
guard
let self = self,
let payloadHex = requestPayload?["data"] as? String,
let writableCharacteristic = self.txCharacteristic
else { bridgeCallback?(["success": false]); return }

var payloadBytes = [UInt8]()
var cursor = payloadHex.startIndex
while cursor < payloadHex.endIndex {
let nextCursor = payloadHex.index(cursor, offsetBy: 2)
if let byteValue = UInt8(payloadHex[cursor..<nextCursor], radix: 16) {
payloadBytes.append(byteValue)
}
cursor = nextCursor
}
self.activePeripheral?.writeValue(Data(payloadBytes), for: writableCharacteristic, type: .withoutResponse)
bridgeCallback?(["success": true])
}
}
}

Part 5: Scan & Notify

extension UKeyBleBridgeViewController: CBCentralManagerDelegate, CBPeripheralDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// Handle .poweredOn and the other states here; you can also try restoring cached devices by UUID
}

// Collect discovered devices and pass them back to JS
func centralManager(
_ central: CBCentralManager,
didDiscover discoveredPeripheral: CBPeripheral,
advertisementData: [String : Any],
rssi signalStrength: NSNumber
) {
let discoveredItem: [String: String] = [
"id": discoveredPeripheral.identifier.uuidString,
"name": discoveredPeripheral.name ?? ""
]
// Feed the discovered item into the JS callback saved during enumerate
pendingEnumerateCallback?([discoveredItem])
// In real apps, deduplicate results and stop once you hit timeout or enough devices
}

func centralManager(_ central: CBCentralManager, didConnect connectedPeripheral: CBPeripheral) {
connectedPeripheral.delegate = self
connectedPeripheral.discoverServices([CBUUID(string: primaryServiceUUIDString)])
}

func peripheral(_ connectedPeripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let primaryService = connectedPeripheral.services?.first else { return }
let txCharacteristicUUID = CBUUID(string: txCharacteristicUUIDString)
let rxCharacteristicUUID = CBUUID(string: rxCharacteristicUUIDString)
connectedPeripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: primaryService)
}

func peripheral(_ connectedPeripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
service.characteristics?.forEach { characteristic in
if characteristic.uuid == CBUUID(string: txCharacteristicUUIDString) { txCharacteristic = characteristic }
if characteristic.uuid == CBUUID(string: rxCharacteristicUUIDString) {
rxCharacteristic = characteristic
connectedPeripheral.setNotifyValue(true, for: characteristic)
}
}
}

func peripheral(_ connectedPeripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let characteristicData = characteristic.value else { return }
// Convert to hex first, then forward to JS; the web bundle will reassemble frames and parse receive()
let packetHexString = characteristicData.map { String(format: "%02x", $0) }.joined()
jsBridge.callHandler("monitorCharacteristic", data: packetHexString)
}
}

Part 6: JS Adapter

The demo web project (native-ios-example/web) initializes the SDK with env: 'lowlevel' and forwards low-level SDK calls through the native bridge. In most integrations, build that web package and include web/web_dist/ in the app resources.

If you are retrofitting your own adapter, keep the same pattern: initialize with env: 'lowlevel' and map enumerate, connect, disconnect, send, receive to native bridging capabilities.

Part 7: Native UI Bridge

If you want interactions such as PINs and confirmation popups to use native UI instead of displaying them in a WebView, you can additionally expose handlers. JS initiates a UI request, the native layer displays the interface, and then sends the user operation results back to JS.

// Reference: native bridge for PIN input (trimmed down)
jsBridge.register(handlerName: "requestPinInput") { [weak self] _, bridgeCallback in
// Present your own native PIN entry screen here.
// Return an empty string if JS should switch to device-side PIN entry,
// or return the converted PIN string (blind-keyboard sequence) for software entry.
// This path forces device-side entry:
bridgeCallback?("")
}

// Reference: native confirmation dialog
jsBridge.register(handlerName: "requestButtonConfirmation") { _, bridgeCallback in
// Show a native confirmation prompt and return "ok" or "cancel".
bridgeCallback?("ok")
}

jsBridge.register(handlerName: "closeUIWindow") { _, bridgeCallback in
// Dismiss any native overlay or modal that is still open.
bridgeCallback?("closed")
}

If you use native UI, the JS layer only forwards events to these bridge handlers; without native pop-ups, you can keep the whole flow in JS by listening to UI_EVENT and replying through ukeySdk.uiResponse. See event configuration for event types and response methods.

Part 8: Final Checks

CheckpointWhat to check
Bridge setupWire up every bridge handler before loading HTML.
Scan scopeFilter by service UUID and stop the scan once the window is long enough.
Session IDKeep the current connectId for the session, then read device_id back with getFeatures(connectId) after the first connection.
UI eventsLeave UI_EVENT subscribed so PIN, Passphrase, and confirmation prompts always have a responder.
Packaged assetsEnsure web/web_dist/ ships inside the app resources and the WebView path points to the real folder.