diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..bdd8501
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/README.md b/README.md
index 428d417..4dda317 100644
--- a/README.md
+++ b/README.md
@@ -8,102 +8,102 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs
- Real Bluetooth Low Energy for the iOS and watchOS Simulators.
+
+
+
+
+
# 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
diff --git a/VERSION b/VERSION
index 6e8bf73..896b9bc 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+1.0.0-beta
diff --git a/apps/helper/Sources/SimBLEHelperKit/HelperState.swift b/apps/helper/Sources/SimBLEHelperKit/HelperState.swift
index efb39b5..8f7cb7c 100644
--- a/apps/helper/Sources/SimBLEHelperKit/HelperState.swift
+++ b/apps/helper/Sources/SimBLEHelperKit/HelperState.swift
@@ -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 }
diff --git a/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
index 2493c0a..76325a2 100644
--- a/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
+++ b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
@@ -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
diff --git a/apps/helper/Sources/simble-menubar/App.swift b/apps/helper/Sources/simble-menubar/App.swift
index 5114215..9a5d011 100644
--- a/apps/helper/Sources/simble-menubar/App.swift
+++ b/apps/helper/Sources/simble-menubar/App.swift
@@ -36,5 +36,9 @@ struct SimBLEMenubarApp: App {
Image(systemName: model.iconName)
}
.menuBarExtraStyle(.window)
+
+ Settings {
+ SettingsView(model: model)
+ }
}
}
diff --git a/apps/helper/Sources/simble-menubar/HelperModel.swift b/apps/helper/Sources/simble-menubar/HelperModel.swift
index 46e780e..9fbfae6 100644
--- a/apps/helper/Sources/simble-menubar/HelperModel.swift
+++ b/apps/helper/Sources/simble-menubar/HelperModel.swift
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Nirapod Labs
+import AppKit
import Foundation
import Observation
+import ServiceManagement
import SimBLEHelperKit
import SimBLEHostCore
import SimBLEProtocol
@@ -47,6 +49,16 @@ final class HelperModel {
private(set) var port: UInt16 = 0
private(set) var simulators: [ArmedSimulator] = []
+ /// A pinned listener port, 0 for an automatic one. Editable in Settings; takes effect on the
+ /// next bridge start.
+ var fixedPort: Int = 0
+ /// When the bridge last started, for the Settings uptime readout.
+ private(set) var startedAt: Date?
+ /// Whether the helper is a macOS login item.
+ var launchAtLogin: Bool = false {
+ didSet { applyLaunchAtLogin() }
+ }
+
private let central = CoreBluetoothCentral()
private let peripheral = CoreBluetoothPeripheral()
private let arming = SimulatorArming()
@@ -56,6 +68,9 @@ final class HelperModel {
private var rearmTick = 0
init() {
+ // A property set inside init does not fire its didSet, so reading the current login-item
+ // status here never re-registers it.
+ launchAtLogin = SMAppService.mainApp.status == .enabled
// Constructing the managers above starts the CoreBluetooth stack, which triggers the
// macOS Bluetooth prompt on first run. Poll the central state off the main run loop so
// the menubar stays responsive while the radio reaches poweredOn; arm once it does.
@@ -89,6 +104,24 @@ final class HelperModel {
}
}
+ /// A short Bluetooth-state label for the Settings status tab.
+ var bluetoothLabel: String {
+ switch bluetooth {
+ case .poweredOn: "Ready"
+ case .unauthorized: "Not authorized"
+ case .unsupported: "Unsupported"
+ case .poweredOff: "Off"
+ case .unknown: "Starting"
+ }
+ }
+
+ /// A count of booted simulators and how many the bridge has armed.
+ var simulatorSummary: String {
+ if simulators.isEmpty { return "None booted" }
+ let armed = simulators.filter(\.armed).count
+ return "\(simulators.count) booted, \(armed) armed"
+ }
+
/// Whether the on/off control can act: only once the radio is authorized and on.
var canToggle: Bool {
bluetooth == .poweredOn
@@ -142,7 +175,9 @@ final class HelperModel {
)
)
do {
- let requested = ProcessInfo.processInfo.environment["SIMBLE_PORT"].flatMap { UInt16($0) } ?? 0
+ let pinned = fixedPort != 0 ? UInt16(exactly: fixedPort) : nil
+ let requested = pinned
+ ?? ProcessInfo.processInfo.environment["SIMBLE_PORT"].flatMap { UInt16($0) } ?? 0
try listener.start(port: requested)
} catch {
return
@@ -151,6 +186,7 @@ final class HelperModel {
self.listener = listener
port = listener.port
running = true
+ startedAt = Date()
arming.armBooted(port: listener.port, token: token.hex)
try? HelperState.write(port: listener.port, token: token.hex)
refreshSimulators()
@@ -165,6 +201,7 @@ final class HelperModel {
token = nil
running = false
port = 0
+ startedAt = nil
refreshSimulators()
}
@@ -183,6 +220,35 @@ final class HelperModel {
ArmedSimulator(id: sim.udid, platform: sim.platform.label, armed: armable)
}
}
+
+ /// The bundle's marketing version, "dev" outside a built `.app`.
+ var appVersion: String {
+ Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
+ }
+
+ /// The debug-scheme environment for manual injection, nil when the bridge is off or no iOS
+ /// slice is built. The arming driver owns the composition and the injection keys.
+ func schemeEnvironment() -> String? {
+ guard running, let token else { return nil }
+ return arming.schemeEnvironment(port: port, token: token.hex)
+ }
+
+ /// Reveal the helper's data directory in Finder.
+ func openDataDirectory() {
+ guard let url = try? HelperState.directory() else { return }
+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ NSWorkspace.shared.open(url)
+ }
+
+ /// Register or unregister the helper as a login item.
+ private func applyLaunchAtLogin() {
+ do {
+ if launchAtLogin { try SMAppService.mainApp.register() }
+ else { try SMAppService.mainApp.unregister() }
+ } catch {
+ // A `swift run` outside a registered `.app` cannot set a login item.
+ }
+ }
}
private extension SimPlatform {
diff --git a/apps/helper/Sources/simble-menubar/MenubarView.swift b/apps/helper/Sources/simble-menubar/MenubarView.swift
index 6654f83..82688c4 100644
--- a/apps/helper/Sources/simble-menubar/MenubarView.swift
+++ b/apps/helper/Sources/simble-menubar/MenubarView.swift
@@ -9,12 +9,15 @@ import SwiftUI
@available(macOS 14, *)
struct MenubarView: View {
@Bindable var model: HelperModel
+ @State private var copied = false
+ @Environment(\.openSettings) private var openSettings
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
status
+ copyButton
Divider()
simulators
Divider()
@@ -25,7 +28,7 @@ struct MenubarView: View {
private var header: some View {
HStack {
- Text(verbatim: "SimBLE").font(.headline)
+ SimBLEWordmark(size: 17)
Spacer()
Toggle("", isOn: Binding(get: { model.running }, set: { _ in model.toggle() }))
.toggleStyle(.switch)
@@ -47,6 +50,29 @@ struct MenubarView: View {
.padding(.horizontal, 12).padding(.vertical, 8)
}
+ private var copyButton: some View {
+ Button {
+ if let env = model.schemeEnvironment() {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(env, forType: .string)
+ withAnimation { copied = true }
+ }
+ } label: {
+ Label(copied ? "Copied" : "Copy scheme environment",
+ systemImage: copied ? "checkmark" : "doc.on.clipboard")
+ .frame(maxWidth: .infinity)
+ }
+ .controlSize(.large)
+ .disabled(!model.running)
+ .padding(.horizontal, 12).padding(.bottom, 10)
+ .task(id: copied) {
+ if copied {
+ try? await Task.sleep(for: .seconds(1.5))
+ withAnimation { copied = false }
+ }
+ }
+ }
+
private var simulators: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Booted simulators").font(.caption).foregroundStyle(.secondary)
@@ -56,7 +82,8 @@ struct MenubarView: View {
} else {
ForEach(model.simulators) { sim in
HStack(spacing: 10) {
- Image(systemName: "iphone.gen3").foregroundStyle(.tint)
+ Image(systemName: sim.platform == "watchOS" ? "applewatch" : "iphone.gen3")
+ .foregroundStyle(.tint)
.frame(width: 22, height: 22)
Text(verbatim: sim.platform)
.font(.caption.weight(.medium))
@@ -73,6 +100,9 @@ struct MenubarView: View {
private var footer: some View {
HStack(spacing: 0) {
+ Button("Settings…") { openSettings() }
+ .frame(maxWidth: .infinity)
+ Divider().frame(height: 16)
Button("Quit") { model.shutdown(); NSApp.terminate(nil) }
.frame(maxWidth: .infinity)
}
diff --git a/apps/helper/Sources/simble-menubar/SettingsView.swift b/apps/helper/Sources/simble-menubar/SettingsView.swift
new file mode 100644
index 0000000..2e530a0
--- /dev/null
+++ b/apps/helper/Sources/simble-menubar/SettingsView.swift
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2026 Nirapod Labs
+
+import SwiftUI
+
+/// The settings window, presented through the native `Settings` scene: a General tab for the
+/// pinned port and launch-at-login, and a Status tab for the bridge's live state and version.
+@available(macOS 14, *)
+struct SettingsView: View {
+ @Bindable var model: HelperModel
+
+ var body: some View {
+ TabView {
+ GeneralSettings(model: model)
+ .tabItem { Label("General", systemImage: "gearshape") }
+ StatusSettings(model: model)
+ .tabItem { Label("Status", systemImage: "info.circle") }
+ }
+ .frame(width: 440)
+ }
+}
+
+/// The two things a developer sets: launch at login and a pinned port.
+@available(macOS 14, *)
+private struct GeneralSettings: View {
+ @Bindable var model: HelperModel
+ @State private var portText = ""
+
+ var body: some View {
+ Form {
+ Section {
+ Toggle("Launch SimBLE at login", isOn: $model.launchAtLogin)
+ }
+
+ Section {
+ LabeledContent("Fixed port") {
+ HStack(spacing: 8) {
+ TextField("Auto", text: $portText)
+ .frame(width: 90)
+ .multilineTextAlignment(.trailing)
+ .onSubmit(applyPort)
+ Button("Apply", action: applyPort)
+ }
+ }
+ } footer: {
+ Text("Pin a port so the scheme environment stays the same every run. Leave blank for an "
+ + "automatic port. Takes effect on the next on/off toggle.")
+ }
+ }
+ .formStyle(.grouped)
+ .onAppear { portText = model.fixedPort == 0 ? "" : String(model.fixedPort) }
+ }
+
+ private func applyPort() {
+ model.fixedPort = Int(portText) ?? 0
+ portText = model.fixedPort == 0 ? "" : String(model.fixedPort)
+ }
+}
+
+/// The bridge's live state, version, and the data directory.
+@available(macOS 14, *)
+private struct StatusSettings: View {
+ @Bindable var model: HelperModel
+
+ var body: some View {
+ Form {
+ Section {
+ LabeledContent("Bridge") {
+ // Interpolate the port as a String, not the Int: a Text from a LocalizedStringKey
+ // number-groups an integer (it would read "65,176").
+ Text(model.running ? "Running on port \(String(model.port))" : "Stopped")
+ .foregroundStyle(model.running ? Color.green : Color.secondary)
+ }
+ if model.running, let started = model.startedAt {
+ LabeledContent("Uptime") {
+ TimelineView(.periodic(from: started, by: 1)) { context in
+ Text(Self.uptime(from: started, to: context.date)).monospacedDigit()
+ }
+ }
+ }
+ LabeledContent("Bluetooth", value: model.bluetoothLabel)
+ LabeledContent("Simulators", value: model.simulatorSummary)
+ LabeledContent("Version", value: model.appVersion)
+ }
+
+ Section {
+ Button("Open data directory") { model.openDataDirectory() }
+ } footer: {
+ Text("The port and token live in the data directory, written private to your user.")
+ }
+ }
+ .formStyle(.grouped)
+ }
+
+ /// Format elapsed time compactly: "1h 05m", "5m 23s", or "12s".
+ private static func uptime(from start: Date, to now: Date) -> String {
+ let total = max(0, Int(now.timeIntervalSince(start)))
+ let hours = total / 3600, minutes = (total % 3600) / 60, seconds = total % 60
+ if hours > 0 { return String(format: "%dh %02dm", hours, minutes) }
+ if minutes > 0 { return String(format: "%dm %02ds", minutes, seconds) }
+ return "\(seconds)s"
+ }
+}
diff --git a/apps/helper/Sources/simble-menubar/SimBLEWordmark.swift b/apps/helper/Sources/simble-menubar/SimBLEWordmark.swift
new file mode 100644
index 0000000..35b7088
--- /dev/null
+++ b/apps/helper/Sources/simble-menubar/SimBLEWordmark.swift
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2026 Nirapod Labs
+
+import SwiftUI
+
+/// The SimBLE wordmark as a system-font lockup: "Sim" in a muted medium weight and "BLE" in bold.
+/// A wordmark asset under /assets can replace it.
+@available(macOS 14, *)
+struct SimBLEWordmark: View {
+ var size: CGFloat = 17
+
+ var body: some View {
+ (Text("Sim").font(.system(size: size, weight: .medium)).foregroundStyle(.secondary)
+ + Text("BLE").font(.system(size: size, weight: .bold)).foregroundStyle(.primary))
+ .accessibilityLabel("SimBLE")
+ }
+}
diff --git a/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift
index 7fe9a25..e54fab2 100644
--- a/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift
+++ b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift
@@ -153,4 +153,17 @@ final class SimulatorArmingTests: XCTestCase {
arming.disarm()
XCTAssertNil(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"))
}
+
+ // MARK: scheme environment
+
+ func testSchemeEnvironmentComposesTheIOSSliceLines() {
+ let arming = SimulatorArming(runner: oneIOS(), locator: FixedLocator(paths: [.ios: iosSlice]))
+ XCTAssertEqual(arming.schemeEnvironment(port: 51234, token: "deadbeef"),
+ "DYLD_INSERT_LIBRARIES=\(iosSlice)\nSIMBLE_PORT=51234\nSIMBLE_TOKEN=deadbeef")
+ }
+
+ func testSchemeEnvironmentIsNilWithoutAnIOSSlice() {
+ let arming = SimulatorArming(runner: oneIOS(), locator: FixedLocator(paths: [:]))
+ XCTAssertNil(arming.schemeEnvironment(port: 1, token: "ab"))
+ }
}
diff --git a/docs/architecture/decisions/0002-peripheral-role.md b/docs/architecture/decisions/0002-peripheral-role.md
new file mode 100644
index 0000000..33d47f5
--- /dev/null
+++ b/docs/architecture/decisions/0002-peripheral-role.md
@@ -0,0 +1,61 @@
+
+
+# 2. SimBLE peripheral role
+
+Status: accepted
+
+## Context
+
+[ADR 0001](0001-simble-bridge-architecture.md) established the bridge for the central role: a
+simulated app acting as a GATT client, scanning and connecting to real peripherals. The other half
+of CoreBluetooth is the peripheral role, where the app is the server: it publishes a GATT service,
+advertises it, and answers reads, writes, and subscriptions from a central.
+
+That role is what an accessory's companion app, a peripheral simulator, or any app that advertises a
+service exercises, and the Simulator cannot run it any more than it can run the central role. Leaving
+it out would close half the gap the tool exists to remove.
+
+## Decision
+
+- **Implement the peripheral role symmetrically.** Hook `CBPeripheralManager` the way 0001 hooks
+ `CBCentralManager`, and relay add-service, advertise, respond, and update-value to the host, which
+ drives a real `CBPeripheralManager` on the Mac. A real external central discovers, connects, and
+ exchanges GATT with the simulated app.
+- **Reconstruct the peripheral delegate faithfully.** The interposer owns shadow
+ `CBMutableService` and `CBMutableCharacteristic` objects and rebuilds the manager's callbacks
+ (`didAdd`, `didStartAdvertising`, `didReceiveRead`, `didReceiveWrite`, `didSubscribeTo`,
+ `didUnsubscribeFrom`, `isReadyToUpdateSubscribers`) on the app's queue, preserving CoreBluetooth's
+ ordering.
+- **One protocol, both roles.** The peripheral op set and its events ride the same length-prefixed
+ CBOR channel from 0001; the host routes each command to the central or the peripheral service by
+ its op, and both services deliver events over the one event channel.
+- **watchOS peripheral is excluded.** Apple's watchOS SDK marks `CBPeripheralManager` unavailable,
+ so the watch lane stays central-only and the peripheral role targets iOS.
+
+## Consequences
+
+- A simulated iOS app can advertise and serve GATT to a real external central, verified over the air
+ against a real Android central.
+- The interposer and the host each carry two manager families, and the protocol carries the
+ peripheral op set in addition to the central one, so the codec parity surface is larger.
+- The host runs a real `CBPeripheralManager`; one macOS Bluetooth grant covers both roles.
+
+## Custody
+
+The peripheral role moves Bluetooth traffic only. Serving a characteristic transports the
+application's byte payloads, not key material; the app holds no recovery secret, signs nothing, and
+touches no funds. Pairing and bonding stay excluded, so no long-term link key is derived. Custody
+verdict: **PASS**.
+
+## Alternatives considered
+
+- **Central only, defer the peripheral role.** Leaves accessory and companion apps unable to test
+ their advertising and GATT-server path in the Simulator, which is the same iteration cost 0001 set
+ out to remove, now for the server side.
+- **Emulate a peripheral in software.** A faked peripheral no real central can connect to fails the
+ faithfulness constraint; kept only behind test interfaces for deterministic unit tests.
+- **A watchOS peripheral shim.** The SDK symbol is unavailable on watchOS, so there is no faithful
+ path; the role is excluded there rather than approximated.
diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md
index 37b6368..2a9a297 100644
--- a/docs/architecture/decisions/README.md
+++ b/docs/architecture/decisions/README.md
@@ -9,3 +9,4 @@ Each record captures one architectural decision: its context, the decision, and
Records are immutable once accepted; a later record supersedes an earlier one rather than editing it.
- [0001: SimBLE bridge architecture](0001-simble-bridge-architecture.md)
+- [0002: SimBLE peripheral role](0002-peripheral-role.md)
diff --git a/docs/architecture/simble-architecture.qd b/docs/architecture/simble-architecture.qd
index 5ebc570..29e09ae 100644
--- a/docs/architecture/simble-architecture.qd
+++ b/docs/architecture/simble-architecture.qd
@@ -22,11 +22,10 @@
protocol, the host bridge, the trust boundary, the custody verdict, and the safety fence that
makes the tool unable to follow code into production.
-.box {Status} type:{warning}
- This is the architecture specification for SimBLE v1.0.0. It describes the target design. The
- repository is at scaffold stage; for what is implemented today, read `SECURITY.md` and the
- package READMEs. Statements here describe how the tool is designed to behave, not a claim that
- every part is built.
+.box {Status} type:{note}
+ 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.
.tableofcontents maxdepth:{2}
diff --git a/package.json b/package.json
index 03d3a54..40507b8 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "simble",
"private": true,
- "version": "0.1.0",
+ "version": "1.0.0-beta",
"description": "Real Bluetooth Low Energy for the iOS and watchOS Simulators.",
"author": "athexweb3",
"license": "Apache-2.0",
diff --git a/scripts/install.sh b/scripts/install.sh
index fd28abb..8d71c2b 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,7 +1,56 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2026 Nirapod Labs
-
+#
+# curl -fsSL https://raw.githubusercontent.com/nirapod-labs/simble/main/scripts/install.sh | sh
+#
+# Build SimBLE from source on this Mac and install it: the menu bar helper to /Applications and the
+# simblectl CLI to ~/.local/bin. Building locally keeps the binaries off the Gatekeeper quarantine
+# path, and the ad-hoc signature is enough for a locally built tool.
+#
+# The source is cloned at a release tag (the latest release, or SIMBLE_REF=). The tag is
+# validated to be a version tag before use, so the clone follows a published release, not a branch.
set -euo pipefail
-echo "SimBLE install script is not implemented yet."
+REPO="nirapod-labs/simble"
+REF="${SIMBLE_REF:-}" # a tag like v1.0.0; default is the latest release
+APPS="/Applications"
+BIN="${HOME}/.local/bin"
+die() { echo "install: $1" >&2; exit 1; }
+
+command -v git >/dev/null || die "git is required"
+command -v xcrun >/dev/null || die "the Xcode command line tools are required: xcode-select --install"
+
+if [ -z "$REF" ]; then
+ # Resolve the release tag from VERSION on the default branch over raw.githubusercontent, the same
+ # host this script was fetched from, so the install does not depend on the rate-limited GitHub API
+ # and does not care whether the release is a prerelease.
+ ver="$(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/VERSION" 2>/dev/null | tr -d '[:space:]' || true)"
+ [ -n "$ver" ] || die "could not read the latest version; pass SIMBLE_REF= to build a specific tag"
+ REF="v$ver"
+fi
+
+# Only follow a version tag (vX.Y.Z, optionally -prerelease), so the install stays pinned to a
+# published release, not a moving branch.
+printf '%s' "$REF" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' \
+ || die "refusing to build '$REF': not a version tag (expected vX.Y.Z)"
+
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+echo "install: cloning $REPO at $REF"
+git clone --depth 1 --branch "$REF" "https://github.com/$REPO.git" "$WORK/src" >/dev/null 2>&1 \
+ || die "could not clone $REPO at $REF"
+cd "$WORK/src"
+
+echo "install: building from source (ad-hoc signed, never quarantined)"
+SIGN_ID="-" bash scripts/build-menubar-app.sh
+( cd tools/simblectl && xcrun swift build -c release )
+
+mkdir -p "$BIN"
+rm -rf "$APPS/SimBLE.app"
+cp -R dist/SimBLE.app "$APPS/SimBLE.app" \
+ || die "could not write to $APPS (try: sudo, or set APPS=\$HOME/Applications)"
+cp tools/simblectl/.build/release/simblectl "$BIN/simblectl"
+
+echo "install: installed $APPS/SimBLE.app and $BIN/simblectl"
+echo "install: open it with open \"$APPS/SimBLE.app\" (ensure $BIN is on PATH for the CLI)"
diff --git a/scripts/release.sh b/scripts/release.sh
index eaf8d1e..49cd9ba 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -1,7 +1,60 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2026 Nirapod Labs
-
+#
+# Cut a release: guard hard, bump VERSION and the two mirrors that cannot read it at build time,
+# commit, tag, push. The tag push is the only trigger; .github/workflows/release.yml does the
+# build, the fence gates, packaging, checksums, and the GitHub Release.
+#
+# Usage: scripts/release.sh (semver MAJOR.MINOR.PATCH, ahead of the last tag)
+#
+# This pushes the release commit to main, so run it as a maintainer whose push to the protected
+# branch is allowed. A release is a one-way door; every guard below refuses an ambiguous cut.
set -euo pipefail
-echo "SimBLE release script is not implemented yet."
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO"
+NEW="${1:-}"
+die() { echo "release: $1" >&2; exit 1; }
+
+# --- guards -----------------------------------------------------------------
+[ -n "$NEW" ] || die "usage: scripts/release.sh "
+printf '%s' "$NEW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' \
+ || die "version must be semver MAJOR.MINOR.PATCH[-PRERELEASE], got '$NEW'"
+[ "$(git branch --show-current)" = "main" ] \
+ || die "cut releases from main, not '$(git branch --show-current)'"
+git diff --quiet && git diff --cached --quiet || die "working tree is dirty; commit or stash first"
+git fetch --tags --quiet
+git rev-parse "v$NEW" >/dev/null 2>&1 && die "tag v$NEW already exists"
+LAST="$(git tag --list 'v*' --sort=-v:refname | head -1)"
+if [ -n "$LAST" ]; then
+ newest="$(printf 'v%s\n%s\n' "$NEW" "$LAST" | sort -V | tail -1)"
+ [ "$newest" = "v$NEW" ] || die "v$NEW is not ahead of the last tag $LAST"
+fi
+
+# CI must be green on this exact commit, so a release never ships a broken main.
+head="$(git rev-parse HEAD)"
+state="$(gh run list --branch main --limit 40 --json headSha,conclusion,status \
+ --jq "[.[] | select(.headSha==\"$head\" and .status==\"completed\")]
+ | if length==0 then \"none\" elif all(.conclusion==\"success\") then \"success\" else \"failed\" end" \
+ 2>/dev/null || echo "unknown")"
+[ "$state" = "success" ] || die "CI on HEAD is '$state', not a green completed run; wait for it first"
+
+# --- bump VERSION and the mirrors that cannot read it at build time ---------
+# The .app's CFBundleShortVersionString reads VERSION at build; the CLI constant and package.json
+# cannot, so rewrite them here.
+printf '%s\n' "$NEW" > VERSION
+CLI_VERSION_FILE="tools/simblectl/Sources/SimBLECTLKit/Version.swift"
+sed -i.bak -E "s/(simblectlVersion = \")[^\"]+(\")/\1$NEW\2/" "$CLI_VERSION_FILE"
+rm -f "$CLI_VERSION_FILE.bak"
+python3 -c "import json,sys; d=json.load(open('package.json')); d['version']=sys.argv[1]; \
+open('package.json','w').write(json.dumps(d, indent=2)+'\n')" "$NEW"
+
+# --- commit, push main, tag, push tag ---------------------------------------
+# Push main before tagging, so a rejected push (protected branch) leaves no dangling tag.
+git add VERSION package.json "$CLI_VERSION_FILE"
+git commit -m "chore(release): v$NEW"
+git push origin main
+git tag -a "v$NEW" -m "SimBLE v$NEW"
+git push origin "v$NEW"
+echo "release: pushed v$NEW; watch the release workflow build and publish it"
diff --git a/tools/simblectl/Sources/SimBLECTLKit/Command.swift b/tools/simblectl/Sources/SimBLECTLKit/Command.swift
index f68dde3..fb7ad79 100644
--- a/tools/simblectl/Sources/SimBLECTLKit/Command.swift
+++ b/tools/simblectl/Sources/SimBLECTLKit/Command.swift
@@ -96,7 +96,9 @@ public enum SimBLECTL {
) -> CommandResult {
switch arguments.dropFirst().first {
case "version":
- return CommandResult(exitCode: 0, output: #"{"protocolVersion":\#(SimBLEProtocol.version)}"#)
+ return CommandResult(
+ exitCode: 0,
+ output: #"{"version":"\#(simblectlVersion)","protocolVersion":\#(SimBLEProtocol.version)}"#)
case "sims":
return sims(arming)
case "disarm":
diff --git a/tools/simblectl/Sources/SimBLECTLKit/Version.swift b/tools/simblectl/Sources/SimBLECTLKit/Version.swift
new file mode 100644
index 0000000..0cd8041
--- /dev/null
+++ b/tools/simblectl/Sources/SimBLECTLKit/Version.swift
@@ -0,0 +1,6 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2026 Nirapod Labs
+
+/// The simblectl marketing version. `release.sh` rewrites this; the CLI cannot read the VERSION
+/// file at runtime, so the constant carries it.
+public let simblectlVersion = "1.0.0-beta"
diff --git a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift
index 1f90158..054ec82 100644
--- a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift
+++ b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift
@@ -52,7 +52,8 @@ final class CommandTests: XCTestCase {
}
func testVersionCommandReturnsJson() {
- XCTAssertEqual(SimBLECTL.handle(arguments: ["simblectl", "version"]).output, #"{"protocolVersion":1}"#)
+ XCTAssertEqual(SimBLECTL.handle(arguments: ["simblectl", "version"]).output,
+ #"{"version":"\#(simblectlVersion)","protocolVersion":1}"#)
}
func testSimsListsBootedIosAndWatchAndExcludesShutdown() {