A technical architecture reference, built by Nirapod Labs.
Abstract
The iOS and watchOS Simulators have no Bluetooth radio. SimBLE gives a simulated app real Bluetooth Low Energy by intercepting its CoreBluetooth calls and relaying the operations to the host Mac’s real Bluetooth adapter over an authenticated loopback channel. The simulated app scans, connects, reads, writes, subscribes, and advertises against live peripherals; the radio work happens on the host and the results flow back as the same delegate callbacks the app would receive on a device. This document specifies the architecture: the interception layer, the wire protocol, the host bridge, the trust boundary, the custody verdict, and the safety fence that makes the tool unable to follow code into production.
Status
This describes the SimBLE v1.0.0 architecture as built. The central and peripheral roles, the
wire protocol, the host bridge, the interposer, and the fence are implemented. SECURITY.md
states the threat model and the fence, and the package READMEs cover each component.
Table of Contents
The problem
CoreBluetooth is the one Apple API you cannot exercise in the Simulator. A simulated process has no
Bluetooth controller behind it, so CBCentralManager comes up in a powered-off, unsupported state:
no scan returns a peripheral, no connection completes, and CBPeripheralManager cannot advertise.
The workflow you most want to iterate on, the BLE central and peripheral paths, is exactly the one
the Simulator cannot run, and developers fall back to building onto a physical device for every
change that touches a characteristic.
Let
The design constraint is faithfulness: the app’s objects, delegate callbacks, ordering, and error codes must match a device, so the same source runs unchanged in both places. SimBLE relays the real adapter’s behavior and adds no semantics of its own.
System architecture
Three deployables and one shared contract span two address spaces: the simulated guest process and the host helper process. The interposer lives inside the guest; the helper owns the real Bluetooth adapter and serves requests over loopback. Unlike a synchronous request-reply tool, a BLE bridge is bidirectional and event-driven: commands flow guest to host, and events (discoveries, notifications, incoming central requests) flow host to guest and are delivered as delegate callbacks.
flowchart LR
subgraph SIM["Simulator process (guest)"]
APP["App code\nCBCentralManager / CBPeripheralManager"]
HOOK["Interposer (dylib)\nObjC runtime hooks"]
REG["Shadow registries\nperipherals, services, characteristics"]
DISP["Delegate dispatch\non the app's queue"]
CLI["Loopback client\nlength-prefixed CBOR"]
APP --> HOOK
HOOK --> REG
HOOK --> CLI
CLI --> DISP --> APP
end
subgraph HOST["macOS host process (helper)"]
SRV["Loopback server\ntoken-authenticated"]
ROUTE["Request router"]
CORE["host-core\nCoreBluetooth on macOS"]
HW["Real Bluetooth adapter\nlive peripherals"]
SRV --> ROUTE --> CORE --> HW
end
CLI ==>|"127.0.0.1 : port\nframe = len ‖ CBOR"| SRV
HW -. "events: discovered, value, request" .-> CORE
CORE -. event frame .-> CLIThe guest holds no radio and no link state of its own; it holds shadow objects that stand in for the
framework’s CBPeripheral, CBService, and CBCharacteristic. What crosses the boundary is the
operation, its identifiers, and its byte payloads, which is the same information a device app already
exchanges with the controller.
packages/interpose
The injected dylib. Hooks the CoreBluetooth classes and selectors, redirects Bluetooth
operations, synthesizes shadow objects, and dispatches events to the app’s delegate.
apps/helper (Swift)
The menu bar app. Owns the host adapter, authenticates the channel, arms simulators,
answers requests, and streams events.
packages/protocol
One spec, two byte-identical codecs: Swift for the helper, C for the interposer.
A fourth surface, tools/simblectl, is a JSON command-line client over the same protocol, so a
person or an agent can drive the adapter and read status without a UI.
Trust model and the channel
The channel is a TCP socket bound to the loopback address 127.0.0.1, authenticated by a
per-session capability token. Loopback binding means only processes on the same host can
connect; the token means only a process that can read the helper’s per-user state directory can
speak the protocol.
Let the token be a uniformly random
which is negligible for any feasible
Threat boundary
In scope: another local process probing the loopback port without the token; a malformed or
hostile frame from the guest; an attacker-influenced app name, icon, or peripheral name in a
HELLO or a discovery event.
Out of scope: an adversary already executing as the developer’s user, who by definition owns the
Bluetooth session and the host regardless of SimBLE.
Hostile input is handled by treating every framed message as untrusted in both directions: the codec rejects duplicate keys, non-canonical integers, oversized payloads, and trailing bytes; display strings are length-clamped and stripped of control characters; an icon is validated as a real PNG under a hard byte cap before the helper renders it; advertisement and characteristic payloads are bounded before they are surfaced.
Custody verdict
SimBLE moves Bluetooth traffic and nothing else. It does not generate, store, or transport private keys; it holds no recovery secret and is no recovery authority; it signs nothing and touches no funds. The verdict is PASS, and the design below is what bounds it.
Two facts bound the data that can cross the bridge. First, v1 excludes pairing and bonding, the BLE procedures that derive and persist a long-term link key, so no key material is ever relayed: the wire carries GATT operations and their byte payloads, which are application data the app already owns. Second, the helper is a relay, not a cryptographic party; it adds no key, no signature, and no canonicalization to anything it forwards. Whatever security an application layers on top of its characteristics is computed in the application, on both device and Simulator alike, and SimBLE neither sees into it nor depends on it.
The interposer
Bluetooth has no public seam for a registered virtual adapter: there is no supported API by which a third party hands CoreBluetooth a software controller. The only way in is to intercept the calls inside the guest process. CoreBluetooth is an Objective-C framework, so the interposer works at the Objective-C runtime: it replaces the implementations of the methods an app calls and the methods the framework would call back, so the redirect is independent of any private symbol.
For an original method
The hooked surface is the set of CoreBluetooth entry points a central or peripheral app touches:
| Hooked surface | Relayed operation | Passthrough when |
|---|---|---|
CBCentralManager scan / connect | SCAN_START/STOP, CONNECT/DISCONNECT on the host | not a SimBLE manager |
CBPeripheral discovery | DISCOVER_SERVICES / DISCOVER_CHARACTERISTICS | non-managed peripheral |
CBPeripheral read / write / notify | READ/WRITE_CHARACTERISTIC, SET_NOTIFY | non-managed peripheral |
CBPeripheralManager advertise / serve | ADD_SERVICE, START_ADVERTISING, RESPOND_*, UPDATE_VALUE | non-managed manager |
manager state | served from the bridged adapter state | non-managed manager |
Because the framework’s objects are opaque and cannot be constructed by an app, the interposer
synthesizes shadow objects: stand-in CBPeripheral, CBService, and CBCharacteristic
instances held in registries keyed by host handle, so later calls resolve without ambiguity and an
object the interposer did not mint is never claimed. Events returning from the host (a discovery, a
value update, an incoming read or write request) are translated into the matching delegate callback
and dispatched on the queue the app supplied to its manager, preserving CoreBluetooth’s ordering and
threading contract.
The wire protocol
The wire is length-prefixed CBOR. Each message is a 4-byte big-endian length followed by exactly
that many bytes of a canonical CBOR map. Reading is therefore deterministic: read 4, decode the
length
flowchart LR
P["length prefix\n4 bytes, big-endian"] --> B["CBOR map\nL bytes"]
B --> P2["length prefix\n4 bytes"] --> B2["CBOR map\n…"]For a payload of
bytes, with the per-message 4-byte cost the only framing overhead. The 32-bit prefix bounds a single
payload at
Maps are integer-keyed, not string-keyed, so the contract is compact and stable across the two
codecs. The protocol is bidirectional: command frames carry an operation such as SCAN_START or
WRITE_CHARACTERISTIC, and event frames carry an asynchronous result: CONNECTED or
CONNECT_FAILED for a connection attempt, DISCONNECTED for a drop, DISCOVERED for a scan hit, a
value update for a notification, or an incoming read or write request. Both codecs are written to the
same spec and checked against each other, so they are byte-identical oracles: a fuzz vector that one
accepts the other must accept, bit for bit.
Codec parity
The Swift codec (helper) and the C codec (interposer) are not merely compatible; they encode the same logical message to the same bytes. Parity is a test invariant.
The host bridge
The helper drives the Mac’s adapter through the same CoreBluetooth API a macOS app would use. In the central role it scans, connects, discovers services and characteristics, reads and writes, and subscribes to notifications, forwarding each result to the guest as an event. In the peripheral role it publishes services, advertises, answers read and write requests from a real central, and pushes characteristic updates to subscribers. The host owns all live Bluetooth state; the guest’s shadow objects are projections of it.
Connection is asynchronous and has no timeout, faithful to CoreBluetooth. A CONNECT command
returns at once and its outcome arrives later as a CONNECTED or CONNECT_FAILED event that becomes
didConnect or didFailToConnect on the app’s queue, never a blocking reply. A connection that
takes seconds, or never completes, therefore cannot stall the guest’s calling thread.
A single round of work has a request leg and, for asynchronous Bluetooth, one or more event legs. The helper authenticates the token once per session, decodes each command, performs the CoreBluetooth operation, and streams events back as they arrive from the controller.
The end-to-end paths
A central read and a peripheral request are the two shapes the whole protocol composes from.
sequenceDiagram
autonumber
participant App as App (guest)
participant Hook as Interposer
participant Helper as Helper (host)
participant Radio as Adapter + peripheral
App->>Hook: scanForPeripherals
Hook->>Helper: frame(SCAN_START)
Helper->>Radio: CoreBluetooth scan
Radio-->>Helper: didDiscover
Helper-->>Hook: event frame(DISCOVERED)
Hook-->>App: didDiscoverPeripheral (app's queue)
App->>Hook: connect(peripheral)
Hook->>Helper: frame(CONNECT)
Note over Hook,Helper: returns at once, no blocking reply
Helper->>Radio: CoreBluetooth connect
Radio-->>Helper: didConnect
Helper-->>Hook: event frame(CONNECTED)
Hook-->>App: didConnectPeripheral (app's queue)sequenceDiagram
autonumber
participant App as App (guest)
participant Hook as Interposer
participant Helper as Helper (host)
participant Central as Real central
App->>Hook: startAdvertising / addService
Hook->>Helper: frame(ADD_SERVICE / START_ADVERTISING)
Helper->>Central: advertise on the real adapter
Central-->>Helper: read / write request
Helper-->>Hook: event frame(READ_REQUEST / WRITE_REQUEST)
Hook-->>App: didReceiveRead / didReceiveWrite on the app's queue
App->>Hook: respond(result)
Hook->>Helper: frame(RESPOND_READ / RESPOND_WRITE)
Helper->>Central: CoreBluetooth respondThe work the bridge adds around each operation is constant; the variable cost is the radio, which is identical to a device because it is the device’s adapter doing it.
The fence
The fence is the property that makes SimBLE a tool that cannot ship. It is a structural fact plus a checked invariant.
Let
where DYLD_INSERT_LIBRARIES names the dylib in
Structural: it is simulator-only
Each dylib’s Mach-O platform is a Simulator platform (iOS-Simulator, watchOS-Simulator),
one slice each. dyld on a real device refuses a simulator-slice binary, so
Configural: it is debug-only
A consuming app wires DYLD_INSERT_LIBRARIES only in a Debug scheme. A release or archive
build sets it nowhere, so
For any production build
CI runs the fence on every change. It asserts the static naming and wiring rules: that any scheme
carrying the variable is Debug-only, that no Xcode project links the dylib into a build, and that the
variable appears only in a reviewed allowlist. The fence also runs a binary bundle check, that the
helper carries exactly the interposers it injects and each is a simulator slice, reading the Mach-O
build platform and failing closed on a missing slice or any device platform; the release workflow
runs it against the built .app before publishing.
flowchart TD
START([dyld asked to load the interposer]) --> Q1{Simulator-slice\nplatform?}
Q1 -- "no (device)" --> REJ1[/dyld refuses: wrong platform/]
Q1 -- yes --> Q2{DYLD_INSERT set\nby a Debug scheme?}
Q2 -- "no (release)" --> REJ2[/nothing to load/]
Q2 -- yes --> LOAD([interposer active in the Simulator])
REJ1 --> END([cannot reach production])
REJ2 --> ENDAutomatic arming
The helper arms a booted simulator by setting the injection variables in the simulator’s launchd
environment, choosing the interposer slice that matches the simulator’s platform, so any app launched
afterward inherits it with no per-project wiring. The three variables are the loader path, the helper
port, and the capability token: DYLD_INSERT_LIBRARIES, SIMBLE_PORT, SIMBLE_TOKEN.
The helper re-arms the booted simulators on a short interval, so a simulator booted, rebooted, or
cleared after the helper started is armed within seconds, not only at helper startup. Arming composes
the shared DYLD_INSERT_LIBRARIES list rather than overwriting it: the helper appends its own slice,
matched by file name, and on teardown removes only its own entry, so an independent injection tool’s
slice in the same list survives and the two coexist. For a single Xcode scheme instead of automatic
arming, the helper also exposes the same three variables as a copyable scheme environment to paste
into the scheme.
stateDiagram-v2
[*] --> Idle
Idle --> Armed: helper opens\nset DYLD_INSERT, PORT, TOKEN
Armed --> Armed: app launches → injected
Armed --> Armed: re-arm tick\nsim booted later
Armed --> Idle: helper quits\nremove own slice
Armed --> [*]: simulator shuts downAn app already running is not retroactively injected; injection happens when an app launches into an armed simulator.
Scope and exclusions
v1.0.0 targets real Bluetooth Low Energy devices. The iOS Simulator supports both the central and the peripheral role; the watchOS Simulator supports the central and client role. Fake Bluetooth exists only behind test interfaces, for deterministic unit tests; the product path is the real adapter.
The following are out of scope for v1.0.0, each for a concrete reason:
| Excluded | Reason |
|---|---|
| Pairing and bonding | They derive and persist a long-term link key; excluding them keeps the bridge a transport for application bytes and no key material (see Custody verdict). |
| ANCS | Apple Notification Center Service is a device-and-companion feature outside the central/peripheral GATT surface the tool bridges. |
| L2CAP channels | Connection-oriented L2CAP streams are a separate transport from the GATT operations v1 relays. |
| Background restoration fidelity | State restoration depends on the OS relaunch lifecycle, which the Simulator does not reproduce faithfully. |
| Production app linkage | Forbidden by the fence: no shipped app may link, bundle, or inject the interposer. |
| watchOS peripheral mode | The watchOS SDK marks CBPeripheralManager unavailable, so the peripheral role cannot be expressed there. |
| Virtual-device product mode | v1 is a developer bridge to a real adapter, not a synthetic device emulator. |
A cost model
A Bluetooth operation through SimBLE is the same radio operation as on a device plus a fixed software envelope. Decompose the wall-clock cost of one operation:
xychart-beta x-axis "Cost component" y-axis "Relative units" bar [2.0, 3.0, 4.0, 40.0, 3.0] line [2.0, 3.0, 4.0, 40.0, 3.0]
Reading the bars left to right: hook dispatch, request encode, loopback transit, the radio operation, and event decode. The fourth bar, the Bluetooth operation, dominates; the rest is the software envelope of relaying it.
Summary
SimBLE is a faithful redirect, not a reimplementation. It hooks the guest’s CoreBluetooth surface, frames each Bluetooth operation as length-prefixed CBOR, and relays it to the host adapter, where the radio work happens and the live peripheral state lives. Events return as the same delegate callbacks an app receives on a device, the codecs are byte-identical oracles, the bridge carries application bytes and no key material, and the interposer is a simulator-only binary wired in debug only, so it cannot follow the code into production. The result is the one development affordance the Simulator never had: real Bluetooth, where you write the code.
Colophon
SimBLE is built by Nirapod Labs. It came out of building Nirapod, a non-custodial wallet, where the device talks to its surroundings over Bluetooth and that path could not be exercised in the Simulator. It was built for that path and open-sourced.
Tip
Nirapod Labs · github.com/nirapod-labs/simble · Apache-2.0
This document is written in Quarkdown and compiles from
docs/architecture/simble-architecture.qd.