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
4 changes: 4 additions & 0 deletions .reuse/dep5
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Files: .commitlintrc.json .tool-versions .xcode-version LICENSE VERSION biome.js
Copyright: 2026 Nirapod Labs
License: Apache-2.0

Files: apps/helper/Sources/simble-helper/Info.plist
Copyright: 2026 Nirapod Labs
License: Apache-2.0

Files: docs/architecture/simble-architecture.qd
Copyright: 2026 Nirapod Labs
License: Apache-2.0
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ WATCH_TARGET := arm64-apple-watchos10.0-simulator
SWIFT_PKGS := packages/host-core packages/protocol/swift apps/helper tools/simblectl
C_FILES := $(shell find packages -type f \( -name '*.c' -o -name '*.h' \) 2>/dev/null)

.PHONY: help bootstrap configure build test test-portable fence docs lint format clean \
.PHONY: help bootstrap configure build app test test-portable fence docs lint format clean \
mechanism-ios mechanism-watchos mechanism-peripheral-ios

help: ## Show targets
Expand Down Expand Up @@ -42,6 +42,9 @@ build: configure ## Build C targets and Swift packages
@if [ -d build-watchsim ]; then cmake --build build-watchsim -j; fi
@for p in $(SWIFT_PKGS); do echo "== swift build: $$p =="; ( cd $$p && xcrun swift build ) || exit 1; done

app: ## Build the menubar SimBLE.app bundle into dist/ (ad-hoc signed)
bash scripts/build-menubar-app.sh

test: build ## Run C tests, Swift tests, and the fence
ctest --test-dir build --output-on-failure
@for p in $(SWIFT_PKGS); do echo "== swift test: $$p =="; ( cd $$p && xcrun swift test ) || exit 1; done
Expand Down
25 changes: 25 additions & 0 deletions apps/helper/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let package = Package(
products: [
.library(name: "SimBLEHelperKit", targets: ["SimBLEHelperKit"]),
.executable(name: "simble-helper", targets: ["simble-helper"]),
.executable(name: "simble-menubar", targets: ["simble-menubar"]),
],
dependencies: [
.package(path: "../../packages/host-core"),
Expand All @@ -28,6 +29,30 @@ let package = Package(
dependencies: [
"SimBLEHelperKit",
.product(name: "SimBLEHostCore", package: "host-core"),
.product(name: "SimBLEProtocol", package: "swift"),
],
// Embedded via the linker, not compiled or bundled as a resource.
exclude: ["Info.plist"],
// Embed the Info.plist (with NSBluetoothAlwaysUsageDescription) in __TEXT,__info_plist
// so macOS can authorize the helper's CoreBluetooth access. Path is package-relative.
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/simble-helper/Info.plist",
]),
]
),
// The menubar app. It runs the same loopback bridge as the CLI behind a SwiftUI
// MenuBarExtra. NSBluetoothAlwaysUsageDescription rides in the .app's Info.plist,
// written by scripts/build-menubar-app.sh, so this target needs no embedded plist.
.executableTarget(
name: "simble-menubar",
dependencies: [
"SimBLEHelperKit",
.product(name: "SimBLEHostCore", package: "host-core"),
.product(name: "SimBLEProtocol", package: "swift"),
]
),
.testTarget(
Expand Down
33 changes: 22 additions & 11 deletions apps/helper/Sources/SimBLEHelperKit/LoopbackListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import SimBLEProtocol
/// but virtualizes its filesystem, so a host socket file is not reachable from a
/// simulated app.
public final class LoopbackListener: @unchecked Sendable {
/// Idle deadline on an accepted connection's reads and writes, so a peer that connects
/// and stalls cannot park a serve thread forever.
private static let connectionIdleTimeout = timeval(tv_sec: 30, tv_usec: 0)

private let router: RequestRouter
/// Idle deadline on an accepted connection's socket reads and writes. A read deadline at a
/// frame boundary is tolerated so the event stream stays open; the write deadline still
/// bounds a stalled writer. Injectable so a test can force the deadline without waiting.
private let connectionIdleTimeout: timeval
private let lifecycleLock = NSLock()
private var listenFD: Int32 = -1
private var worker: Thread?
Expand All @@ -34,8 +34,12 @@ public final class LoopbackListener: @unchecked Sendable {
public private(set) var port: UInt16 = 0

/// Build the listener around the router that answers each request and supplies events.
public init(router: RequestRouter) {
/// `idleTimeoutSeconds` bounds each connection's socket reads and writes.
public init(router: RequestRouter, idleTimeoutSeconds: TimeInterval = 30) {
self.router = router
let whole = Int(idleTimeoutSeconds)
let micros = Int32((idleTimeoutSeconds - Double(whole)) * 1_000_000)
connectionIdleTimeout = timeval(tv_sec: whole, tv_usec: micros)
}

/// Bind, listen, and start accepting. Pass `0` to take an ephemeral port, then read the
Expand Down Expand Up @@ -108,7 +112,7 @@ public final class LoopbackListener: @unchecked Sendable {
if isRunning() { continue }
break
}
var deadline = Self.connectionIdleTimeout
var deadline = connectionIdleTimeout
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &deadline, socklen_t(MemoryLayout<timeval>.size))
setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &deadline, socklen_t(MemoryLayout<timeval>.size))
let connection = Thread { [weak self] in
Expand Down Expand Up @@ -151,16 +155,23 @@ public final class LoopbackListener: @unchecked Sendable {
close(fd)
}
while true {
let payload: Data
do {
let response = try router.respond(toPayload: readFrame(fd))
writeLock.lock()
do { try writeFrame(fd, Wire.encode(response)) }
catch { writeLock.unlock(); return }
writeLock.unlock()
payload = try readFrame(fd)
} catch SocketError.idleTimeout {
// Idle event stream: no request this interval. Keep the sink attached so a
// long-lived peripheral still receives subscribe, read, and write events. The
// send deadline still guards a stalled writer.
continue
} catch {
// Peer closed, or a malformed frame: drop this connection.
return
}
let response = router.respond(toPayload: payload)
writeLock.lock()
do { try writeFrame(fd, Wire.encode(response)) }
catch { writeLock.unlock(); return }
writeLock.unlock()
}
}

Expand Down
25 changes: 24 additions & 1 deletion apps/helper/Sources/SimBLEHelperKit/SocketIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import SimBLEProtocol
enum SocketError: Error, Equatable {
/// The peer closed mid-message; never surfaced as a partial read.
case closed
/// A read deadline elapsed at a frame boundary, nothing pending. Benign for an idle
/// event-stream connection that legitimately sends no requests.
case idleTimeout
/// An OS call failed; the message is the errno text.
case system(String)
}
Expand Down Expand Up @@ -53,10 +56,30 @@ func writeFull(_ fd: Int32, _ data: Data) throws {
/// Read one length-prefixed frame and return its CBOR payload. A length past the 1 MiB
/// cap is refused before any allocation.
func readFrame(_ fd: Int32) throws -> Data {
let length = try Framing.payloadLength(readFull(fd, 4))
let length = try Framing.payloadLength(readHeader(fd))
return try readFull(fd, length)
}

/// Read the 4-byte frame header. A read deadline with no header byte yet is `idleTimeout`,
/// not a failure, so an idle event-stream connection survives. A timeout mid-header is real.
func readHeader(_ fd: Int32) throws -> Data {
var buffer = Data(count: 4)
var read = 0
try buffer.withUnsafeMutableBytes { raw in
let base = raw.baseAddress!
while read < 4 {
let n = recv(fd, base + read, 4 - read, 0)
if n == 0 { throw SocketError.closed }
if n < 0 {
if read == 0, errno == EAGAIN || errno == EWOULDBLOCK { throw SocketError.idleTimeout }
throw SocketError.system(String(cString: strerror(errno)))
}
read += n
}
}
return buffer
}

/// Frame a CBOR payload and write it.
func writeFrame(_ fd: Int32, _ payload: Data) throws {
try writeFull(fd, Framing.frame(payload))
Expand Down
18 changes: 18 additions & 0 deletions apps/helper/Sources/simble-helper/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>dev.simble.helper</string>
<key>CFBundleName</key>
<string>SimBLE Helper</string>
<key>CFBundleExecutable</key>
<string>simble-helper</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SimBLE bridges the iOS and watchOS Simulators to this Mac's Bluetooth radio.</string>
</dict>
</plist>
62 changes: 51 additions & 11 deletions apps/helper/Sources/simble-helper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CoreBluetooth
import Foundation
import SimBLEHelperKit
import SimBLEHostCore
import SimBLEProtocol

#if canImport(Darwin)
import Darwin
Expand All @@ -13,24 +14,63 @@ import SimBLEHostCore
// The bridge helper: own the Mac's Bluetooth central and peripheral and answer GATT
// operations over an authenticated loopback channel. It mints a per-session
// capability token and gates every request on it, validated before the op is
// interpreted. No key material crosses the wire. Constructing a CBCentralManager
// without a granted authorization aborts, so the radio path runs only when
// authorization is allowedAlways (the bundled host); otherwise it prints status
// and exits, leaving the real radio to the bundled app.
// interpreted. No key material crosses the wire. Constructing the managers triggers
// the macOS authorization prompt on first run; the helper awaits poweredOn before
// arming and serving, and exits nonzero with a clear message on denial, an
// unsupported radio, a powered-off radio, or timeout.

let status = HelperStatus()
print("\(status.host.bridgeName) helper protocol v\(status.protocolVersion)")
// CBManagerState raw values.
let stateUnsupported: UInt64 = 2
let stateUnauthorized: UInt64 = 3
let statePoweredOff: UInt64 = 4

guard CBManager.authorization == .allowedAlways else {
FileHandle.standardError.write(Data(
"simble-helper: Bluetooth not authorized for this process; run from the bundled host\n".utf8
))
exit(0)
// Seconds to wait for the central to reach poweredOn; SIMBLE_BT_TIMEOUT overrides.
let authTimeout: TimeInterval = ProcessInfo.processInfo.environment["SIMBLE_BT_TIMEOUT"]
.flatMap { TimeInterval($0) } ?? 15

func printError(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}

let status = HelperStatus()
print("\(status.host.bridgeName) helper protocol v\(status.protocolVersion)")

let token = CapabilityToken()
let central = CoreBluetoothCentral()
let peripheral = CoreBluetoothPeripheral()

// Await the central reaching poweredOn (authorized, radio on). A terminal state
// (unsupported, unauthorized, poweredOff) bails early; otherwise poll to the deadline.
let deadline = Date().addingTimeInterval(authTimeout)
var state = central.managerState()
while state != Wire.managerStatePoweredOn, Date() < deadline {
switch state {
case stateUnsupported:
printError("simble-helper: Bluetooth Low Energy is not supported on this Mac")
exit(1)
case stateUnauthorized:
printError(
"simble-helper: Bluetooth not authorized; grant simble-helper in "
+ "System Settings > Privacy & Security > Bluetooth, then retry"
)
exit(1)
case statePoweredOff:
printError("simble-helper: Bluetooth is off; turn Bluetooth on, then retry")
exit(1)
default:
Thread.sleep(forTimeInterval: 0.1)
}
state = central.managerState()
}

guard state == Wire.managerStatePoweredOn else {
printError(
"simble-helper: timed out waiting for Bluetooth; the authorization prompt may be "
+ "pending, approve it and retry"
)
exit(1)
}

let listener = LoopbackListener(
router: RequestRouter(
service: CentralService(backend: central, peripheralSupported: true),
Expand Down
40 changes: 40 additions & 0 deletions apps/helper/Sources/simble-menubar/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Nirapod Labs

// The bridge helper as a menubar app. It runs the same loopback bridge as the CLI
// (SimBLEHelperKit + SimBLEHostCore) behind a SwiftUI MenuBarExtra: an on/off control,
// the bound port and Bluetooth state, and the booted simulators it has armed.
// Constructing the CoreBluetooth managers triggers the macOS Bluetooth prompt because the
// .app carries NSBluetoothAlwaysUsageDescription. It is an accessory app (no dock icon,
// set by LSUIElement in the bundle's Info.plist), signed ad-hoc.

import AppKit
import Foundation
import SwiftUI

/// The process entry point. MenuBarExtra and Observation need macOS 14; an older system
/// exits with a clear message rather than launching a non-functional bar item.
@main
enum Main {
static func main() {
guard #available(macOS 14, *) else {
FileHandle.standardError.write(Data("simble-menubar: requires macOS 14 or newer\n".utf8))
exit(1)
}
SimBLEMenubarApp.main()
}
}

@available(macOS 14, *)
struct SimBLEMenubarApp: App {
@State private var model = HelperModel()

var body: some Scene {
MenuBarExtra {
MenubarView(model: model)
} label: {
Image(systemName: model.iconName)
}
.menuBarExtraStyle(.window)
}
}
Loading
Loading