Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2026 Nirapod Labs
#
# Cut by a tag push (scripts/release.sh). Builds the helper .app and the CLI from the tagged source,
# gates them through the fence (static and bundle) and the Swift tests, packages a tag-pinned source
# tarball, an ad-hoc .app zip, and SHA256SUMS, and publishes a GitHub Release.
name: release

on:
push:
tags: ["v*"]

permissions:
contents: write # create the release

jobs:
release:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for the changelog and git archive

- name: Assert the tag matches VERSION
run: |
want="v$(cat VERSION)"
[ "$want" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME does not match VERSION ($want)"; exit 1; }

- name: Build the helper .app and the CLI (ad-hoc signed)
run: |
bash scripts/build-menubar-app.sh
( cd tools/simblectl && xcrun swift build -c release )
cp tools/simblectl/.build/release/simblectl dist/simblectl

- name: Fence gates (static, bundle, Swift tests)
run: |
bash scripts/fence-check.sh
bash scripts/fence-check.sh --helper dist/SimBLE.app
for p in packages/host-core packages/protocol/swift apps/helper tools/simblectl; do
echo "== swift test: $p =="
( cd "$p" && xcrun swift test ) || exit 1
done

- name: Package (tag-pinned source tarball, app zip, checksums)
id: package
run: |
ver="${GITHUB_REF_NAME#v}"
mkdir -p out
git archive --format=tar.gz --prefix="simble-$ver/" \
-o "out/simble-$ver.tar.gz" "$GITHUB_REF_NAME"
ditto -c -k --keepParent dist/SimBLE.app "out/SimBLE-$ver-macos.zip"
cat > out/CLEAR-QUARANTINE.txt <<'NOTE'
This .app is ad-hoc signed, not notarized. After unzipping, clear the download
quarantine so macOS will open it:

xattr -dr com.apple.quarantine SimBLE.app

Or build from source, which is never quarantined:

curl -fsSL https://raw.githubusercontent.com/nirapod-labs/simble/main/scripts/install.sh | sh
NOTE
( cd out && shasum -a 256 ./* > SHA256SUMS )
echo "ver=$ver" >> "$GITHUB_OUTPUT"

- name: Release notes from the commits since the last tag
run: |
prev="$(git tag --list 'v*' --sort=-v:refname | grep -vx "$GITHUB_REF_NAME" | head -1)"
{
echo "## SimBLE ${GITHUB_REF_NAME}"
echo
if [ -n "$prev" ]; then
git log --no-merges --pretty='- %s' "$prev..$GITHUB_REF_NAME"
else
git log --no-merges --pretty='- %s' "$GITHUB_REF_NAME"
fi
echo
echo "Install (builds from source):"
echo '```'
echo "curl -fsSL https://raw.githubusercontent.com/nirapod-labs/simble/main/scripts/install.sh | sh"
echo '```'
echo "The downloadable .app is ad-hoc signed; see CLEAR-QUARANTINE.txt to open it."
} > out/notes.md

- name: Publish the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
# Prerelease tags (vX.Y.Z-...) publish as a prerelease, not the latest release.
pre=""; case "$GITHUB_REF_NAME" in *-*) pre="--prerelease" ;; esac
gh release create "$GITHUB_REF_NAME" $pre --title "SimBLE $GITHUB_REF_NAME" \
--notes-file out/notes.md \
out/simble-*.tar.gz out/SimBLE-*.zip out/SHA256SUMS out/CLEAR-QUARANTINE.txt
134 changes: 67 additions & 67 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,102 +8,102 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs
</p>

<p align="center">
Real Bluetooth Low Energy for the iOS and watchOS Simulators.
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square"></a>
<img alt="Swift" src="https://img.shields.io/badge/Swift-F05138?style=flat-square&logo=swift&logoColor=white">
<img alt="C" src="https://img.shields.io/badge/C-A8B9CC?style=flat-square&logo=c&logoColor=white">
<img alt="Platforms: iOS and watchOS Simulators, macOS" src="https://img.shields.io/badge/Platforms-iOS_%C2%B7_watchOS_Sim_%C2%B7_macOS-lightgrey?style=flat-square&logo=apple&logoColor=white">
<a href="https://github.com/nirapod-labs/simble/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nirapod-labs/simble?style=flat-square&label=release&color=2563eb"></a>
</p>

# SimBLE

SimBLE is a developer tool for routing CoreBluetooth work from the iOS and
watchOS Simulators to the Mac's real Bluetooth Low Energy adapter. It follows
the same repository shape as SimEnclave: a host helper, an injected simulator
interposer, a shared wire protocol, examples, tests, and a JSON CLI for agents
and humans.
SimBLE gives the iOS and watchOS Simulators a real Bluetooth radio. It injects a small interposer into a simulated app, catches the CoreBluetooth calls, and routes them to your Mac's actual Bluetooth Low Energy adapter over a local channel. The app scans, connects, advertises, and serves GATT against real hardware, and the app itself imports nothing.

The project is maintained by athexweb3 under Nirapod Labs.
It exists because the iOS and watchOS Simulators have no Bluetooth radio. Anything that talks to a peripheral, a fitness sensor, a hardware wallet, a custom accessory, cannot run where you develop all day, so every change to a Bluetooth path forces you onto a physical device with a real peer in range. SimBLE bridges the Simulator to the Mac's radio so those paths run at your desk, behind a fence that keeps the bridge out of production.

## v1.0.0 target
## How it works

- iOS Simulator central/client BLE.
- iOS Simulator peripheral/server BLE.
- watchOS Simulator central/client BLE.
- Real BLE devices through the Mac Bluetooth adapter.
- Debug-only injection with a release fence.
Your Mac has a Bluetooth radio. A menubar helper drives it. When a simulated app calls into CoreBluetooth, an injected interposer, loaded only through a debug scheme environment variable, relays the operation to the helper over an authenticated loopback socket. The helper runs it on the Mac's radio and streams the results and events back. The private radio state stays on the Mac; pairing and bonding are excluded, so no link key crosses the wire.

watchOS peripheral/server mode is out of scope for v1.0.0 because Apple's
watchOS SDK marks `CBPeripheralManager` unavailable there.
```
simulated app ──CoreBluetooth──▶ interposer ──loopback──▶ helper ──▶ Mac Bluetooth radio
(hook) (CBOR+token) (scan, connect, GATT)
events, values ◀──────────────────────────────────────────────────┘
```

The app's code does not change. The same `CBCentralManager` and `CBPeripheralManager` calls that reach the radio on a device reach the Mac's radio through SimBLE in the Simulator. Both roles work: a central scans, connects, reads, writes, and subscribes; a peripheral publishes a service and serves reads, writes, and notifications. The watchOS peripheral role is out of scope, because Apple's watchOS SDK marks `CBPeripheralManager` unavailable there.

## It can't ship

The interposer is built for the Simulator only. Apple will not load a simulator binary on a real device, and injecting into a signed app is blocked there regardless, so it cannot follow your code into production. The CI checks that keep it that way are in [SECURITY.md](SECURITY.md).

## See it run

A console app lives under [`examples/native`](examples/native): a SwiftUI app with a Central tab, a Peripheral tab, and a shared History tab, plus a standalone watchOS central. It scans and connects as a central, advertises and serves as a peripheral, and lands every operation in one history, all against the Mac's radio through SimBLE.

## Install

```sh
curl -fsSL https://raw.githubusercontent.com/nirapod-labs/simble/main/scripts/install.sh | sh
```

It builds from source and installs the menu bar helper to `/Applications` and the `simblectl` CLI to `~/.local/bin`. Needs Xcode. To build a specific release, set `SIMBLE_REF=v1.2.3`.

## Using it

Open SimBLE (it lives in the menu bar). It arms every booted simulator with the slice that matches its platform, iOS or watchOS, so the next app you launch is injected automatically and your existing CoreBluetooth code runs against the Mac's radio with nothing else to wire. To pin a specific Xcode scheme instead, copy the scheme environment from the menu and paste it into the scheme; it carries the loader, the port, and the token. The CLI mirrors the helper for a person or an agent, with JSON output and honest exit codes: `simblectl status` confirms the helper is live, `simblectl scan` lists nearby peripherals, `simblectl sims` lists booted simulators, and `simblectl disarm` clears the injection environment.

## Architecture

Three deployables and one shared contract, each in its own directory:

- [`packages/protocol`](packages/protocol) is the wire: one spec (length-prefixed CBOR) and two codecs, Swift for the helper and C for the interposer, that stay byte-for-byte compatible.
- [`packages/host-core`](packages/host-core) drives the Mac's Bluetooth radio through CoreBluetooth, in both the central and peripheral roles. The host side.
- [`packages/interpose`](packages/interpose) is the injected dylib. It hooks CoreBluetooth in the simulated app, redirects the operations to the helper, and passes everything else through.
- [`apps/helper`](apps/helper) is the menubar app that drives the radio and answers requests over loopback. It arms booted simulators automatically.
- [`tools/simblectl`](tools/simblectl) is the CLI, with JSON output and honest exit codes so a person or an agent can drive it.

Why an interposer and not a registered provider? CoreBluetooth is reached in-process, not through a device the OS enumerates, so the only way in is to intercept the calls inside the guest process. Inline hooking is the default because it is independent of the symbol-binding format, and the hook backend sits behind a seam so no single library is load-bearing.

## Repository layout

```text
```
packages/
protocol/ wire spec plus Swift and C codecs
host-core/ macOS CoreBluetooth host service
interpose/ simulator dylibs that hook CoreBluetooth
protocol/ CBOR wire spec + Swift and C codecs
host-core/ Swift, drives the Mac Bluetooth radio
interpose/ the injected dylib (C), hooks CoreBluetooth
apps/
helper/ SimBLE menu bar helper
helper/ the menubar app, drives the radio, serves loopback
tools/
simblectl/ JSON CLI
simblectl/ the JSON CLI
examples/
native/ iOS and standalone watchOS sample apps
react-native/ Expo sample app
scripts/ fence, release, and install helpers
native/ SwiftUI console (iOS + watchOS)
scripts/ fence checks, mechanism proofs, build helpers
docs/ architecture and development notes
```

## Status

SimBLE gives the iOS and watchOS Simulators real Bluetooth Low Energy. It
injects a CoreBluetooth interposer into the guest app; the interposer relays
CoreBluetooth operations to a host helper that drives the Mac's radio over an
authenticated loopback channel. Both roles are implemented: the central (GATT
client) reaches real peripherals, and the peripheral (GATT server) publishes
services to real centrals. A JSON CLI (`simblectl`) drives and inspects the
bridge, and the example guest apps exercise each role.
## Developing

## Run it end to end
`make bootstrap` from a fresh clone, then `make build` and `make test`. The toolchain and every `make` target are in [docs/development.md](docs/development.md).

The in-simulator radio path runs on a real Mac with Bluetooth granted to the
helper and a real BLE peer present. It is operator-run, not a CI gate.

One command:
The Swift packages' tests need XCTest, so the `test` target runs them through the Xcode toolchain. To run one package directly:

```sh
make mechanism-ios
cd packages/host-core
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun swift test
```

That target builds the interposer slice, the helper, and `simblectl`; picks or
boots an iOS simulator; builds and installs the central example into it; starts
the helper, which arms the booted simulator and records its discovery file; and
confirms the bridge over `simblectl status`. It needs Xcode, Bluetooth granted
to the helper, and a BLE peripheral advertising in range. `make
mechanism-watchos` runs the same lane for a watchOS central; `make
mechanism-peripheral-ios` runs the iOS peripheral lane.

Manual path:
## Contributing

```sh
make build # build the C targets, interposer slice, and Swift packages
"$(cd apps/helper && swift build --show-bin-path)/simble-helper" # start the helper; grant it Bluetooth on first run
"$(cd tools/simblectl && swift build --show-bin-path)/simblectl" status # confirm the bridge is running
"$(cd tools/simblectl && swift build --show-bin-path)/simblectl" scan # list nearby peripherals
```
PR-driven. Branch off `main`, keep the change focused, open a pull request, and a maintainer reviews and merges. `main` is protected and rejects direct pushes. Conventional commits are enforced by commitlint, and the formatting and commit-message hooks run on commit.

The helper exits if Bluetooth is not authorized for it; grant it once
interactively, then retry. With the helper running and a simulator booted and
armed, run an example guest app (see
[examples/native](examples/native/README.md)) and its CoreBluetooth calls reach
the Mac's radio. `simblectl sims` lists booted simulators and `simblectl
disarm` clears the injection environment on every booted simulator.
## Security

## Development
SimBLE moves Bluetooth traffic only, on your own Mac's radio, in the Simulator, and never touches a real user's keys or funds. Pairing and bonding are excluded, so no link key crosses the wire. The threat model, the channel's authentication, and the fence are in [SECURITY.md](SECURITY.md). Found something security-relevant? Report it through GitHub's private vulnerability reporting.

```sh
make bootstrap
make build
make test
```
## Who builds it

See [docs/development.md](docs/development.md) for the toolchain and commands.
SimBLE is built by [Nirapod Labs](https://github.com/nirapod-labs). It came out of building Nirapod, a non-custodial wallet, where the paths worth exercising on every change reach a Bluetooth accessory the Simulator cannot talk to, so testing them meant reaching for a physical device every time. So we built the tool we wanted instead: a real Bluetooth radio in the Simulator, behind a fence that keeps it from following the code into production. It is useful to anyone whose iOS or watchOS app speaks Bluetooth Low Energy, which is why it is open source.

## License

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
1.0.0-beta
5 changes: 5 additions & 0 deletions apps/helper/Sources/SimBLEHelperKit/HelperState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public struct HelperState: Equatable, Sendable {
return HelperState(port: port, token: token)
}

/// The directory holding the state file, for a reveal-in-Finder action.
public static func directory() throws -> URL {
try fileURL().deletingLastPathComponent()
}

/// Delete the state file, ignoring an absent file.
public static func remove() {
guard let url = try? fileURL() else { return }
Expand Down
11 changes: 11 additions & 0 deletions apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@ public struct SimulatorArming: Sendable {
}
}

/// The debug-scheme environment for manual injection, one `KEY=value` per line: the iOS slice
/// insert path, the loopback port, and the capability token. Nil when no iOS slice is built.
public func schemeEnvironment(port: UInt16, token: String) -> String? {
guard let slice = locator.slicePath(for: .ios) else { return nil }
return [
"\(Self.injectVariable)=\(slice)",
"\(Self.portVariable)=\(port)",
"\(Self.tokenVariable)=\(token)",
].joined(separator: "\n")
}

/// Read one variable from a booted simulator's launchd environment, nil when unset.
private func simulatorEnv(_ udid: String, _ key: String) -> String? {
let output = runner.run(["spawn", udid, "launchctl", "getenv", key]).output
Expand Down
4 changes: 4 additions & 0 deletions apps/helper/Sources/simble-menubar/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,9 @@ struct SimBLEMenubarApp: App {
Image(systemName: model.iconName)
}
.menuBarExtraStyle(.window)

Settings {
SettingsView(model: model)
}
}
}
Loading
Loading