diff --git a/.reuse/dep5 b/.reuse/dep5 index fe7008e..28052de 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -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 diff --git a/Makefile b/Makefile index 5deab94..6d8749b 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/apps/helper/Package.swift b/apps/helper/Package.swift index 9ac33a8..3145b2a 100644 --- a/apps/helper/Package.swift +++ b/apps/helper/Package.swift @@ -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"), @@ -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( diff --git a/apps/helper/Sources/SimBLEHelperKit/LoopbackListener.swift b/apps/helper/Sources/SimBLEHelperKit/LoopbackListener.swift index 5d533eb..63b468d 100644 --- a/apps/helper/Sources/SimBLEHelperKit/LoopbackListener.swift +++ b/apps/helper/Sources/SimBLEHelperKit/LoopbackListener.swift @@ -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? @@ -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 @@ -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.size)) setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &deadline, socklen_t(MemoryLayout.size)) let connection = Thread { [weak self] in @@ -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() } } diff --git a/apps/helper/Sources/SimBLEHelperKit/SocketIO.swift b/apps/helper/Sources/SimBLEHelperKit/SocketIO.swift index 40dc83c..def068b 100644 --- a/apps/helper/Sources/SimBLEHelperKit/SocketIO.swift +++ b/apps/helper/Sources/SimBLEHelperKit/SocketIO.swift @@ -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) } @@ -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)) diff --git a/apps/helper/Sources/simble-helper/Info.plist b/apps/helper/Sources/simble-helper/Info.plist new file mode 100644 index 0000000..92f15de --- /dev/null +++ b/apps/helper/Sources/simble-helper/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleIdentifier + dev.simble.helper + CFBundleName + SimBLE Helper + CFBundleExecutable + simble-helper + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + NSBluetoothAlwaysUsageDescription + SimBLE bridges the iOS and watchOS Simulators to this Mac's Bluetooth radio. + + diff --git a/apps/helper/Sources/simble-helper/main.swift b/apps/helper/Sources/simble-helper/main.swift index a0e7bb4..9be5f52 100644 --- a/apps/helper/Sources/simble-helper/main.swift +++ b/apps/helper/Sources/simble-helper/main.swift @@ -5,6 +5,7 @@ import CoreBluetooth import Foundation import SimBLEHelperKit import SimBLEHostCore +import SimBLEProtocol #if canImport(Darwin) import Darwin @@ -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), diff --git a/apps/helper/Sources/simble-menubar/App.swift b/apps/helper/Sources/simble-menubar/App.swift new file mode 100644 index 0000000..5114215 --- /dev/null +++ b/apps/helper/Sources/simble-menubar/App.swift @@ -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) + } +} diff --git a/apps/helper/Sources/simble-menubar/HelperModel.swift b/apps/helper/Sources/simble-menubar/HelperModel.swift new file mode 100644 index 0000000..7023854 --- /dev/null +++ b/apps/helper/Sources/simble-menubar/HelperModel.swift @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +import Observation +import SimBLEHelperKit +import SimBLEHostCore +import SimBLEProtocol + +/// The Bluetooth radio state the menubar reads, mapped from the central manager's raw +/// `CBManagerState`. Only `poweredOn` lets the bridge serve. +enum BluetoothState: Equatable { + case unknown + case unsupported + case unauthorized + case poweredOff + case poweredOn + + /// Map a raw `CBManagerState` to the menubar state; unmatched values read as unknown. + init(rawManagerState: UInt64) { + switch rawManagerState { + case 2: self = .unsupported + case 3: self = .unauthorized + case 4: self = .poweredOff + case Wire.managerStatePoweredOn: self = .poweredOn + default: self = .unknown + } + } +} + +/// A booted simulator and whether the bridge has armed it this session. +struct ArmedSimulator: Identifiable { + let id: String + let platform: String + var armed: Bool +} + +/// The menubar's whole state and the bridge lifecycle behind it: the on/off control, the +/// Bluetooth state, the bound port, and the armed simulators. The view binds to this; it owns +/// the CoreBluetooth managers, the `LoopbackListener`, and the `SimulatorArming` driver. +@available(macOS 14, *) +@MainActor +@Observable +final class HelperModel { + private(set) var bluetooth: BluetoothState = .unknown + private(set) var running = false + private(set) var port: UInt16 = 0 + private(set) var simulators: [ArmedSimulator] = [] + + private let central = CoreBluetoothCentral() + private let peripheral = CoreBluetoothPeripheral() + private let arming = SimulatorArming() + private var listener: LoopbackListener? + private var token: CapabilityToken? + private var stateTimer: Timer? + + init() { + // 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. + refreshState() + stateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshState() } + } + } + + /// The status-bar glyph: the bridge mark when serving, a slashed mark when off, and a + /// warning triangle when Bluetooth is unavailable. + var iconName: String { + guard bluetooth == .poweredOn else { return "exclamationmark.triangle" } + return running ? "dot.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash" + } + + /// A one-line Bluetooth/bridge status for the menubar, built as a plain String so a port + /// number is never number-grouped by a `LocalizedStringKey`. + var statusLine: String { + switch bluetooth { + case .poweredOn: + running ? "Bridge running on port " + String(port) : "Bluetooth ready" + case .unauthorized: + "Bluetooth not authorized" + case .unsupported: + "Bluetooth Low Energy not supported" + case .poweredOff: + "Bluetooth is off" + case .unknown: + "Starting Bluetooth" + } + } + + /// Whether the on/off control can act: only once the radio is authorized and on. + var canToggle: Bool { + bluetooth == .poweredOn + } + + func toggle() { + if running { + stop() + } else { + start() + } + } + + /// Read the central state and, once poweredOn, bring the bridge up. Refresh the armed + /// simulators while running so a sim booted after arming shows as not-yet-armed. + private func refreshState() { + bluetooth = BluetoothState(rawManagerState: central.managerState()) + if bluetooth == .poweredOn, !running { + start() + } + if running { + refreshSimulators() + } + } + + /// Mint a token, start the loopback listener, arm the booted simulators, and write the + /// discovery record. Mirrors the CLI bring-up; a no-op unless the radio is poweredOn and + /// the bridge is not already running. + func start() { + guard bluetooth == .poweredOn, !running else { return } + let token = CapabilityToken() + let listener = LoopbackListener( + router: RequestRouter( + service: CentralService(backend: central, peripheralSupported: true), + peripheralService: PeripheralService(backend: peripheral), + gate: AuthGate(session: token) + ) + ) + do { + let requested = ProcessInfo.processInfo.environment["SIMBLE_PORT"].flatMap { UInt16($0) } ?? 0 + try listener.start(port: requested) + } catch { + return + } + self.token = token + self.listener = listener + port = listener.port + running = true + arming.armBooted(port: listener.port, token: token.hex) + try? HelperState.write(port: listener.port, token: token.hex) + refreshSimulators() + } + + /// Disarm the simulators, remove the discovery record, and stop the listener. + func stop() { + arming.disarm() + HelperState.remove() + listener?.stop() + listener = nil + token = nil + running = false + port = 0 + refreshSimulators() + } + + /// Tear down on quit so a later app never injects against a dead bridge. + func shutdown() { + if running { stop() } + stateTimer?.invalidate() + stateTimer = nil + } + + /// Refresh the booted-simulator list. A simulator counts as armed when the bridge is + /// running and a slice is built for its platform. + private func refreshSimulators() { + let armable = running + simulators = arming.bootedSimulators().map { sim in + ArmedSimulator(id: sim.udid, platform: sim.platform.label, armed: armable) + } + } +} + +private extension SimPlatform { + /// A short human label for the menubar row. + var label: String { + switch self { + case .ios: "iOS" + case .watchos: "watchOS" + } + } +} diff --git a/apps/helper/Sources/simble-menubar/MenubarView.swift b/apps/helper/Sources/simble-menubar/MenubarView.swift new file mode 100644 index 0000000..6654f83 --- /dev/null +++ b/apps/helper/Sources/simble-menubar/MenubarView.swift @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import AppKit +import SwiftUI + +/// The MenuBarExtra popover: the on/off toggle, the Bluetooth/bridge status, the booted +/// simulators and whether each is armed, and the footer Quit. +@available(macOS 14, *) +struct MenubarView: View { + @Bindable var model: HelperModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + status + Divider() + simulators + Divider() + footer + } + .frame(width: 300) + } + + private var header: some View { + HStack { + Text(verbatim: "SimBLE").font(.headline) + Spacer() + Toggle("", isOn: Binding(get: { model.running }, set: { _ in model.toggle() })) + .toggleStyle(.switch) + .labelsHidden() + .disabled(!model.canToggle) + } + .padding(12) + } + + private var status: some View { + HStack(spacing: 8) { + Circle().fill(model.running ? Color.green : Color.secondary).frame(width: 8, height: 8) + // A plain String, not a LocalizedStringKey, so the port number is not group-formatted. + Text(verbatim: model.statusLine) + .font(.callout) + .foregroundStyle(model.running ? Color.primary : Color.secondary) + Spacer() + } + .padding(.horizontal, 12).padding(.vertical, 8) + } + + private var simulators: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Booted simulators").font(.caption).foregroundStyle(.secondary) + if model.simulators.isEmpty { + Text("No booted simulator.") + .font(.caption).foregroundStyle(.tertiary) + } else { + ForEach(model.simulators) { sim in + HStack(spacing: 10) { + Image(systemName: "iphone.gen3").foregroundStyle(.tint) + .frame(width: 22, height: 22) + Text(verbatim: sim.platform) + .font(.caption.weight(.medium)) + Spacer() + Text(sim.armed ? "Armed" : "Not armed") + .font(.caption2) + .foregroundStyle(sim.armed ? Color.green : Color.secondary) + } + } + } + } + .padding(12) + } + + private var footer: some View { + HStack(spacing: 0) { + Button("Quit") { model.shutdown(); NSApp.terminate(nil) } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderless) + .padding(.vertical, 8) + } +} diff --git a/apps/helper/Tests/SimBLEHelperKitTests/EventStreamKeepaliveTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/EventStreamKeepaliveTests.swift new file mode 100644 index 0000000..62f85e9 --- /dev/null +++ b/apps/helper/Tests/SimBLEHelperKitTests/EventStreamKeepaliveTests.swift @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +@testable import SimBLEHelperKit +import SimBLEHostCore +import SimBLEProtocol +import XCTest + +/// The helper's event channel must outlive an idle socket read deadline. A connection that +/// only receives events sends no requests, so its read sits at the frame boundary until the +/// deadline; reaching the deadline must not drop the connection or detach its event sink. The +/// listener takes a short deadline here so the idle gap is reached without a long wait. +final class EventStreamKeepaliveTests: XCTestCase { + private let serviceUUID = "180D" + private let charUUID = "2A37" + private let centralId = Data([0xAB, 0xCD]) + + func testEventStreamSurvivesAnIdleReadDeadline() throws { + let token = CapabilityToken() + let peripheral = FakePeripheralBackend() + let listener = LoopbackListener( + router: RequestRouter(service: CentralService(backend: FakeCentralBackend()), + peripheralService: PeripheralService(backend: peripheral), + gate: AuthGate(session: token)), + idleTimeoutSeconds: 0.3 + ) + try listener.start() + defer { listener.stop() } + let client = try LoopbackClient(port: listener.port) + + XCTAssertEqual(try client.send( + .startAdvertising(localName: "Sensor", serviceUUIDs: [serviceUUID]), token: token + ), .advertisingStarted) + + // Idle for several multiples of the read deadline before any event is emitted. + Thread.sleep(forTimeInterval: 1.0) + + peripheral.emit(.readRequest(requestId: 7, serviceUUID: serviceUUID, + characteristicUUID: charUUID, offset: 0, centralId: centralId)) + XCTAssertEqual(try client.receiveEvent(), .readRequest( + requestId: 7, serviceUUID: serviceUUID, characteristicUUID: charUUID, offset: 0, + centralId: centralId + )) + } +} diff --git a/examples/native/Peripheral/README.md b/examples/native/Peripheral/README.md deleted file mode 100644 index b7c023c..0000000 --- a/examples/native/Peripheral/README.md +++ /dev/null @@ -1,10 +0,0 @@ - - -# Peripheral - -The iOS peripheral example. `App.swift` is the whole app: publish one service with one readable and -notifiable characteristic, advertise a local name, serve reads and writes, and show advertising and -subscription state. Example code, not a CI gate. diff --git a/examples/native/README.md b/examples/native/README.md index f0fba20..b39ccde 100644 --- a/examples/native/README.md +++ b/examples/native/README.md @@ -5,14 +5,15 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # native examples -Three standalone CoreBluetooth apps. None import a SimBLE package; the interposer swizzles +Two standalone CoreBluetooth apps. Neither imports a SimBLE package; the interposer swizzles CoreBluetooth at runtime when the SimBLE helper arms the booted simulator, so the apps' calls reach the host Mac's radio. -- `Sources/` is an iOS central: scan, list peripherals by name and RSSI, connect to a tapped one, - discover services and characteristics, and read the first readable characteristic. -- `Peripheral/` is an iOS peripheral: publish one service with one readable and notifiable - characteristic, advertise a local name, serve reads and writes, and show subscription state. +- `Sources/` is the iOS app with two tabs: + - Central: scan, list peripherals by name and RSSI, connect to a tapped one, discover services and + characteristics, and read the first readable characteristic. + - Peripheral: publish one service with one readable and notifiable characteristic, advertise a + local name, serve reads and writes, and show subscription state. - `Watch/` is a watchOS central: scan, connect to the first peripheral, and read its first readable characteristic. @@ -28,8 +29,24 @@ Open `SimBLEExample.xcodeproj` in Xcode and run a scheme, or build from the comm ```sh xcodebuild -project SimBLEExample.xcodeproj -scheme SimBLEExample -sdk iphonesimulator build -xcodebuild -project SimBLEExample.xcodeproj -scheme SimBLEPeripheralExample -sdk iphonesimulator build xcodebuild -project SimBLEExample.xcodeproj -scheme SimBLEWatchExample -sdk watchsimulator build ``` Run them in a booted simulator armed by the SimBLE helper. Example code, not a CI gate. + +## Launch environment + +The iOS app reads optional environment variables at startup, so a headless `simctl launch` can drive +it without a tap. Set them with `SIMCTL_CHILD_` prefixes through `simctl`. + +- `SIMBLE_AUTOSCAN` set: the Central tab starts scanning when Bluetooth reaches powered on. +- `SIMBLE_AUTOADVERTISE` set: the Peripheral tab starts advertising when Bluetooth reaches powered on. +- `SIMBLE_TAB=peripheral`: open on the Peripheral tab (default Central). + +```sh +xcrun simctl launch --console-pty \ + --env SIMBLE_AUTOSCAN=1 \ + dev.simble.SimBLEExample +``` + +With none set, the Scan and Advertise buttons drive both roles by hand. diff --git a/examples/native/Sources/App.swift b/examples/native/Sources/App.swift index 6ff5df1..3e2c6c6 100644 --- a/examples/native/Sources/App.swift +++ b/examples/native/Sources/App.swift @@ -4,197 +4,64 @@ import CoreBluetooth import SwiftUI -/// The iOS central example: a CoreBluetooth central that scans, lists discovered peripherals, and on -/// a tap connects, discovers services and characteristics, and reads the first readable -/// characteristic. In the iOS Simulator armed by the SimBLE helper, the same calls reach the host -/// Mac's radio; on a device they drive the device radio. +/// The iOS example: one app with a Central tab and a Peripheral tab. The Central tab scans, lists +/// peripherals, connects, and reads a characteristic; the Peripheral tab publishes a service, +/// advertises, and serves reads, writes, and notifications. In the iOS Simulator armed by the SimBLE +/// helper, both reach the host Mac's radio; on a device they drive the device radio. +/// +/// Launch environment (read at startup, all optional): +/// SIMBLE_AUTOSCAN central starts scanning when it reaches poweredOn. +/// SIMBLE_AUTOADVERTISE peripheral starts advertising when it reaches poweredOn. +/// SIMBLE_TAB=peripheral open on the Peripheral tab (default Central). @main -struct SimBLECentralApp: App { +struct SimBLEExampleApp: App { + @State private var selection: Role = launchTab() + var body: some Scene { - WindowGroup { ScannerView() } + WindowGroup { + TabView(selection: $selection) { + CentralView(autoScan: launchFlag("SIMBLE_AUTOSCAN")) + .tabItem { Label("Central", systemImage: "antenna.radiowaves.left.and.right") } + .tag(Role.central) + PeripheralView(autoAdvertise: launchFlag("SIMBLE_AUTOADVERTISE")) + .tabItem { Label("Peripheral", systemImage: "dot.radiowaves.left.and.right") } + .tag(Role.peripheral) + } + } } } -struct ScannerView: View { - @State private var central = CentralScanner() +/// Which role tab is shown. +enum Role { + case central + case peripheral +} - var body: some View { - NavigationStack { - List { - Section(central.state) { - Button(central.scanning ? "Stop" : "Scan") { central.toggleScan() } - .disabled(!central.poweredOn) - } - Section("Peripherals") { - ForEach(central.found) { device in - Button { central.connect(device) } label: { - HStack { - Text(device.name) - Spacer() - Text("\(device.rssi) dBm") - .foregroundStyle(.secondary) - } - } - } - } - if !central.log.isEmpty { - Section("Log") { - ForEach(central.log) { line in - Text(line.text) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .navigationTitle("SimBLE Central") - } - } +/// The tab to open from SIMBLE_TAB; central unless it is "peripheral". +func launchTab() -> Role { + ProcessInfo.processInfo.environment["SIMBLE_TAB"] == "peripheral" ? .peripheral : .central } -/// One discovered peripheral, identified for the list. -struct Discovery: Identifiable { - let id: UUID - let name: String - let rssi: Int - let peripheral: CBPeripheral +/// Whether launch environment variable `name` is set (present, non-empty). +func launchFlag(_ name: String) -> Bool { + guard let value = ProcessInfo.processInfo.environment[name] else { return false } + return !value.isEmpty } -/// One log line, identified for the list. +/// One log line, identified for a list. Shared by both roles. struct LogLine: Identifiable { let id = UUID() let text: String } -/// A CoreBluetooth central: scan for any peripheral, list discoveries, and on connect discover -/// services and characteristics and read the first readable characteristic. -@MainActor -@Observable -final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate, - @preconcurrency CBPeripheralDelegate -{ - private(set) var state = "Starting" - private(set) var poweredOn = false - private(set) var scanning = false - private(set) var found: [Discovery] = [] - private(set) var log: [LogLine] = [] - - private var manager: CBCentralManager! - private var connected: CBPeripheral? - - override init() { - super.init() - manager = CBCentralManager(delegate: self, queue: .main) - } - - /// Start or stop scanning for any peripheral. - func toggleScan() { - if scanning { - manager.stopScan() - scanning = false - append("Stopped scanning") - } else if manager.state == .poweredOn { - found.removeAll() - manager.scanForPeripherals(withServices: nil) - scanning = true - append("Scanning") - } - } - - /// Connect to a tapped peripheral. - func connect(_ device: Discovery) { - manager.stopScan() - scanning = false - connected = device.peripheral - device.peripheral.delegate = self - manager.connect(device.peripheral) - append("Connecting to \(device.name)") - } - - private func append(_ text: String) { - log.insert(LogLine(text: text), at: 0) - FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) - } - - // MARK: CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - state = describe(central.state) - poweredOn = central.state == .poweredOn - append("State: \(state)") - } - - func centralManager( - _: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData _: [String: Any], - rssi RSSI: NSNumber - ) { - let name = peripheral.name ?? "Unknown" - if let index = found.firstIndex(where: { $0.id == peripheral.identifier }) { - found[index] = Discovery( - id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral) - } else { - found.append( - Discovery( - id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral)) - append("Found \(name) (\(RSSI) dBm)") - } - } - - func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { - append("Connected; discovering services") - peripheral.discoverServices(nil) - } - - func centralManager( - _: CBCentralManager, - didFailToConnect peripheral: CBPeripheral, - error _: Error? - ) { - append("Connect failed: \(peripheral.name ?? "device")") - connected = nil - } - - // MARK: CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { - for service in peripheral.services ?? [] { - peripheral.discoverCharacteristics(nil, for: service) - } - } - - func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error _: Error? - ) { - for characteristic in service.characteristics ?? [] - where characteristic.properties.contains(.read) - { - append("Reading \(characteristic.uuid)") - peripheral.readValue(for: characteristic) - return - } - } - - func peripheral( - _: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error _: Error? - ) { - let bytes = characteristic.value?.count ?? 0 - append("Read \(bytes) B from \(characteristic.uuid)") - } - - private func describe(_ state: CBManagerState) -> String { - switch state { - case .poweredOn: "Powered on" - case .poweredOff: "Powered off" - case .unauthorized: "Unauthorized" - case .unsupported: "Unsupported" - case .resetting: "Resetting" - default: "Unknown" - } +/// A human-readable name for a CoreBluetooth manager state. Shared by both roles. +func describe(_ state: CBManagerState) -> String { + switch state { + case .poweredOn: "Powered on" + case .poweredOff: "Powered off" + case .unauthorized: "Unauthorized" + case .unsupported: "Unsupported" + case .resetting: "Resetting" + default: "Unknown" } } diff --git a/examples/native/Sources/CentralView.swift b/examples/native/Sources/CentralView.swift new file mode 100644 index 0000000..c7ba7e4 --- /dev/null +++ b/examples/native/Sources/CentralView.swift @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +import SwiftUI + +struct CentralView: View { + /// Start scanning automatically once the central reaches poweredOn. + let autoScan: Bool + + @State private var central = CentralScanner() + + var body: some View { + NavigationStack { + List { + Section(central.state) { + Button(central.scanning ? "Stop" : "Scan") { central.toggleScan() } + .disabled(!central.poweredOn) + } + Section("Peripherals") { + ForEach(central.found) { device in + Button { central.connect(device) } label: { + HStack { + Text(device.name) + Spacer() + Text("\(device.rssi) dBm") + .foregroundStyle(.secondary) + } + } + } + } + if !central.log.isEmpty { + Section("Log") { + ForEach(central.log) { line in + Text(line.text) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("SimBLE Central") + } + .onAppear { central.autoScan = autoScan } + } +} + +/// One discovered peripheral, identified for the list. +struct Discovery: Identifiable { + let id: UUID + let name: String + let rssi: Int + let peripheral: CBPeripheral +} + +/// A CoreBluetooth central: scan for any peripheral, list discoveries, and on connect discover +/// services and characteristics and read the first readable characteristic. +@MainActor +@Observable +final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate, + @preconcurrency CBPeripheralDelegate +{ + private(set) var state = "Starting" + private(set) var poweredOn = false + private(set) var scanning = false + private(set) var found: [Discovery] = [] + private(set) var log: [LogLine] = [] + + /// When set, scanning starts on the first poweredOn state. + var autoScan = false + + private var manager: CBCentralManager! + private var connected: CBPeripheral? + + override init() { + super.init() + manager = CBCentralManager(delegate: self, queue: .main) + } + + /// Start or stop scanning for any peripheral. + func toggleScan() { + if scanning { + manager.stopScan() + scanning = false + append("Stopped scanning") + } else if manager.state == .poweredOn { + found.removeAll() + manager.scanForPeripherals(withServices: nil) + scanning = true + append("Scanning") + } + } + + /// Connect to a tapped peripheral. + func connect(_ device: Discovery) { + manager.stopScan() + scanning = false + connected = device.peripheral + device.peripheral.delegate = self + manager.connect(device.peripheral) + append("Connecting to \(device.name)") + } + + private func append(_ text: String) { + log.insert(LogLine(text: text), at: 0) + FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) + } + + // MARK: CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + state = describe(central.state) + poweredOn = central.state == .poweredOn + append("State: \(state)") + if poweredOn, autoScan, !scanning { toggleScan() } + } + + func centralManager( + _: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String + let name = advertisedName ?? peripheral.name ?? "Unknown" + if let index = found.firstIndex(where: { $0.id == peripheral.identifier }) { + found[index] = Discovery( + id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral + ) + } else { + found.append( + Discovery( + id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral + ) + ) + append("Found \(name) (\(RSSI) dBm)") + } + } + + func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { + append("Connected; discovering services") + peripheral.discoverServices(nil) + } + + func centralManager( + _: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + append( + "Connect failed for \(peripheral.name ?? "unnamed"): \(error?.localizedDescription ?? "no error")" + ) + connected = nil + } + + // MARK: CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { + for service in peripheral.services ?? [] { + peripheral.discoverCharacteristics(nil, for: service) + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error _: Error? + ) { + for characteristic in service.characteristics ?? [] + where characteristic.properties.contains(.read) + { + append("Reading \(characteristic.uuid)") + peripheral.readValue(for: characteristic) + return + } + } + + func peripheral( + _: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error _: Error? + ) { + let bytes = characteristic.value?.count ?? 0 + append("Read \(bytes) B from \(characteristic.uuid)") + } +} diff --git a/examples/native/Peripheral/App.swift b/examples/native/Sources/PeripheralView.swift similarity index 83% rename from examples/native/Peripheral/App.swift rename to examples/native/Sources/PeripheralView.swift index c25ce41..0aabad9 100644 --- a/examples/native/Peripheral/App.swift +++ b/examples/native/Sources/PeripheralView.swift @@ -4,18 +4,10 @@ import CoreBluetooth import SwiftUI -/// The iOS peripheral example: a CoreBluetooth peripheral that publishes one service with one -/// readable and notifiable characteristic, advertises a local name, answers read requests with the -/// current counter, and accepts writes. In the iOS Simulator armed by the SimBLE helper, the same -/// calls reach the host Mac's radio; on a device they drive the device radio. -@main -struct SimBLEPeripheralApp: App { - var body: some Scene { - WindowGroup { ServerView() } - } -} +struct PeripheralView: View { + /// Start advertising automatically once the peripheral reaches poweredOn. + let autoAdvertise: Bool -struct ServerView: View { @State private var server = PeripheralServer() var body: some View { @@ -45,15 +37,10 @@ struct ServerView: View { } .navigationTitle("SimBLE Peripheral") } + .onAppear { server.autoAdvertise = autoAdvertise } } } -/// One log line, identified for the list. -struct LogLine: Identifiable { - let id = UUID() - let text: String -} - /// A CoreBluetooth peripheral: publish one service with one readable and notifiable characteristic, /// advertise a local name, serve reads with the current counter, and accept writes. @MainActor @@ -70,6 +57,9 @@ final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDeleg private(set) var subscribers = 0 private(set) var log: [LogLine] = [] + /// When set, advertising starts on the first poweredOn state. + var autoAdvertise = false + private var manager: CBPeripheralManager! private var characteristic: CBMutableCharacteristic! @@ -123,7 +113,10 @@ final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDeleg state = describe(peripheral.state) poweredOn = peripheral.state == .poweredOn append("State: \(state)") - if peripheral.state == .poweredOn { publishService() } + if peripheral.state == .poweredOn { + publishService() + if autoAdvertise, !advertising { toggleAdvertise() } + } } func peripheralManager( @@ -179,15 +172,4 @@ final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDeleg subscribers = max(0, subscribers - 1) append("Central unsubscribed") } - - private func describe(_ state: CBManagerState) -> String { - switch state { - case .poweredOn: "Powered on" - case .poweredOff: "Powered off" - case .unauthorized: "Unauthorized" - case .unsupported: "Unsupported" - case .resetting: "Resetting" - default: "Unknown" - } - } } diff --git a/examples/native/Sources/README.md b/examples/native/Sources/README.md index 68b7374..60514c4 100644 --- a/examples/native/Sources/README.md +++ b/examples/native/Sources/README.md @@ -5,6 +5,13 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # Sources -The iOS central example. `App.swift` is the whole app: scan, list peripherals by name and RSSI, -connect to a tapped one, discover services and characteristics, and read the first readable -characteristic. Example code, not a CI gate. +The iOS example app with a Central tab and a Peripheral tab. + +- `App.swift`: the `@main` app, a `TabView` over both roles, the launch-environment reading + (`SIMBLE_AUTOSCAN`, `SIMBLE_AUTOADVERTISE`, `SIMBLE_TAB`), and the shared `LogLine` and `describe` + helpers. +- `CentralView.swift`: the Central tab and `CentralScanner` (scan, connect, discover, read). +- `PeripheralView.swift`: the Peripheral tab and `PeripheralServer` (publish, advertise, serve reads, + writes, and notifications). + +Example code, not a CI gate. diff --git a/examples/native/project.yml b/examples/native/project.yml index c70102a..1f79e32 100644 --- a/examples/native/project.yml +++ b/examples/native/project.yml @@ -4,8 +4,9 @@ # xcodegen spec for the SimBLE native examples. Generate the project with `xcodegen generate` # (run it from examples/native). A Simulator build needs no code signing. # -# Each target is a standalone CoreBluetooth app. None link the interposer; the SimBLE helper arms -# the booted simulator, so the app's CoreBluetooth calls reach the host radio. +# Each target is a standalone CoreBluetooth app. SimBLEExample carries both roles in two tabs; +# SimBLEWatchExample is the watchOS central. None link the interposer; the SimBLE helper arms the +# booted simulator, so the app's CoreBluetooth calls reach the host radio. name: SimBLEExample options: @@ -25,9 +26,9 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: dev.simble.SimBLEExample GENERATE_INFOPLIST_FILE: "YES" - INFOPLIST_KEY_CFBundleDisplayName: SimBLE Central + INFOPLIST_KEY_CFBundleDisplayName: SimBLE INFOPLIST_KEY_UILaunchScreen_Generation: "YES" - # CoreBluetooth needs the usage description to authorize the central. + # CoreBluetooth needs the usage description to authorize the central and the peripheral. INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription: "Scan for nearby Bluetooth peripherals." TARGETED_DEVICE_FAMILY: "1,2" MARKETING_VERSION: "1.0" @@ -35,25 +36,6 @@ targets: SWIFT_VERSION: "5.0" CODE_SIGNING_ALLOWED: "NO" - SimBLEPeripheralExample: - type: application - platform: iOS - sources: - - Peripheral - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: dev.simble.SimBLEPeripheralExample - GENERATE_INFOPLIST_FILE: "YES" - INFOPLIST_KEY_CFBundleDisplayName: SimBLE Peripheral - INFOPLIST_KEY_UILaunchScreen_Generation: "YES" - # CoreBluetooth needs the usage description to authorize the peripheral. - INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription: "Advertise a Bluetooth peripheral." - TARGETED_DEVICE_FAMILY: "1,2" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" - SWIFT_VERSION: "5.0" - CODE_SIGNING_ALLOWED: "NO" - SimBLEWatchExample: type: application platform: watchOS diff --git a/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift index 81984de..1ecc0e2 100644 --- a/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift +++ b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift @@ -62,8 +62,14 @@ public final class CoreBluetoothPeripheral: NSObject, PeripheralBackend, @unchec } let latch = Latch() setAddServiceWaiter(latch, for: serviceUUID) - lock.lock(); services[serviceUUID] = service; lock.unlock() - queue.async { self.manager.add(service) } + // Replace any service already registered under this UUID so a guest relaunch against the + // long-lived manager leaves no duplicate primary service in the GATT database. + lock.lock(); let previous = services[serviceUUID]; services[serviceUUID] = service; lock + .unlock() + queue.async { + if let previous { self.manager.remove(previous) } + self.manager.add(service) + } _ = try wait(latch) { self.clearAddServiceWaiter(serviceUUID) } } @@ -81,7 +87,12 @@ public final class CoreBluetoothPeripheral: NSObject, PeripheralBackend, @unchec } let latch = Latch() setAdvertisingWaiter(latch) - queue.async { self.manager.startAdvertising(data) } + // Restart so a repeat call replaces the live advertisement instead of failing with + // CBErrorAlreadyAdvertising. + queue.async { + if self.manager.isAdvertising { self.manager.stopAdvertising() } + self.manager.startAdvertising(data) + } _ = try wait(latch) { self.clearAdvertisingWaiter() } } @@ -124,10 +135,9 @@ public final class CoreBluetoothPeripheral: NSObject, PeripheralBackend, @unchec private func characteristic(_ uuid: String, serviceUUID: String) throws -> CBMutableCharacteristic { - let service = try service(serviceUUID) - let target = CBUUID(string: uuid) - guard let characteristic = (service.characteristics as? [CBMutableCharacteristic])? - .first(where: { $0.uuid == target }) + lock.lock(); let published = services; lock.unlock() + guard let characteristic = Self.resolveCharacteristic(uuid, serviceUUID: serviceUUID, + in: published) else { throw PeripheralBackendError(code: Self.unknownAttribute, message: "characteristic not published") @@ -135,6 +145,26 @@ public final class CoreBluetoothPeripheral: NSObject, PeripheralBackend, @unchec return characteristic } + /// The published characteristic with UUID `uuid`, searched in the service named by + /// `serviceUUID` first and then in every other published service. The fallback covers a + /// peripheral-created `CBMutableCharacteristic`, which carries no service back-reference. + static func resolveCharacteristic(_ uuid: String, serviceUUID: String, + in services: [String: CBMutableService]) + -> CBMutableCharacteristic? + { + let target = CBUUID(string: uuid) + let ordered = [services[serviceUUID]].compactMap { $0 } + + services.values.filter { $0.uuid.uuidString != serviceUUID } + for service in ordered { + if let characteristic = (service.characteristics as? [CBMutableCharacteristic])? + .first(where: { $0.uuid == target }) + { + return characteristic + } + } + return nil + } + private func subscribedCentrals(_ characteristic: CBMutableCharacteristic) -> [CBCentral] { characteristic.subscribedCentrals ?? [] } diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/CharacteristicResolverTests.swift b/packages/host-core/Tests/SimBLEHostCoreTests/CharacteristicResolverTests.swift new file mode 100644 index 0000000..dc1b296 --- /dev/null +++ b/packages/host-core/Tests/SimBLEHostCoreTests/CharacteristicResolverTests.swift @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +@testable import SimBLEHostCore +import XCTest + +/// A push to a peripheral-created `CBMutableCharacteristic` can arrive with no service named, +/// because that characteristic carries no service back-reference. The resolver must still find +/// the characteristic by scanning the published services, and return nil for an unknown one. +final class CharacteristicResolverTests: XCTestCase { + private let serviceUUID = "F000AA00-0451-4000-B000-000000000000" + private let charUUID = "F000AA01-0451-4000-B000-000000000000" + + private func published() -> [String: CBMutableService] { + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: charUUID), properties: [.read, .notify], value: nil, + permissions: [.readable] + ) + let service = CBMutableService(type: CBUUID(string: serviceUUID), primary: true) + service.characteristics = [characteristic] + return [serviceUUID: service] + } + + func testResolvesWhenServiceIsNamed() { + let found = CoreBluetoothPeripheral.resolveCharacteristic( + charUUID, serviceUUID: serviceUUID, in: published() + ) + XCTAssertEqual(found?.uuid, CBUUID(string: charUUID)) + } + + func testResolvesWhenServiceIsUnnamed() { + let found = CoreBluetoothPeripheral.resolveCharacteristic( + charUUID, serviceUUID: "", in: published() + ) + XCTAssertEqual(found?.uuid, CBUUID(string: charUUID)) + } + + func testReturnsNilForUnknownCharacteristic() { + XCTAssertNil(CoreBluetoothPeripheral.resolveCharacteristic( + "2A37", serviceUUID: "", in: published() + )) + } +} diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift b/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift index 6c4d2fe..745d32e 100644 --- a/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift +++ b/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift @@ -68,4 +68,21 @@ final class CoreBluetoothPeripheralTests: XCTestCase { } } } + + func testStartAdvertisingTwiceSucceeds() throws { + let peripheral = try poweredOnPeripheral() + XCTAssertNoThrow(try peripheral.startAdvertising(localName: "SimBLE", serviceUUIDs: nil)) + // A repeat advertise replaces the live one rather than failing as already-advertising. + XCTAssertNoThrow(try peripheral.startAdvertising(localName: "SimBLE", serviceUUIDs: nil)) + } + + func testReAddingTheSameServiceSucceeds() throws { + let peripheral = try poweredOnPeripheral() + let spec = CharacteristicSpec(uuid: charUUID, properties: 0x12, permissions: 0x01) + XCTAssertNoThrow(try peripheral.addService(serviceUUID: serviceUUID, isPrimary: true, + characteristics: [spec])) + // A re-add replaces the prior registration rather than leaving a duplicate service. + XCTAssertNoThrow(try peripheral.addService(serviceUUID: serviceUUID, isPrimary: true, + characteristics: [spec])) + } } diff --git a/packages/interpose/CMakeLists.txt b/packages/interpose/CMakeLists.txt index 9acb5b7..94fc67f 100644 --- a/packages/interpose/CMakeLists.txt +++ b/packages/interpose/CMakeLists.txt @@ -26,7 +26,7 @@ if(SIMBLE_SIM_SLICE) # The injectable dylib: the core plus the dyld load constructor. Each simulator platform gets # its own name; ios keeps the canonical simble-interpose, so the helper and scripts that load # it by that name need no change. - add_library(simble_interpose MODULE src/entry.c) + add_library(simble_interpose SHARED src/entry.c) target_include_directories(simble_interpose PRIVATE include) set_target_properties( simble_interpose diff --git a/packages/interpose/src/hooks/central_hooks.m b/packages/interpose/src/hooks/central_hooks.m index 3493250..a6a13e3 100644 --- a/packages/interpose/src/hooks/central_hooks.m +++ b/packages/interpose/src/hooks/central_hooks.m @@ -25,6 +25,7 @@ #import "simble_interpose.h" #import +#import #import #import @@ -616,6 +617,17 @@ - (void)simble_readRSSI { @end +// `state` is declared on the shared CBManager superclass, so swizzling it on CBCentralManager would +// also intercept CBPeripheralManager and route a peripheral manager into the central state path. +// This IMP calls the superclass state, installed on CBCentralManager so the swizzle stays there. +static CBManagerState simble_central_state_super(id self, SEL _cmd) { + (void)_cmd; + struct objc_super sup = {self, [CBCentralManager superclass]}; + CBManagerState (*send)(struct objc_super *, SEL) = + (CBManagerState(*)(struct objc_super *, SEL))objc_msgSendSuper; + return send(&sup, @selector(state)); +} + int simble_install_hooks(void) { pthread_mutex_lock(&g_install_lock); if (g_installed) { @@ -626,6 +638,7 @@ int simble_install_hooks(void) { Class managerClass = [CBCentralManager class]; failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:), @selector(simble_initWithDelegate:queue:)); + class_addMethod(managerClass, @selector(state), (IMP)simble_central_state_super, "q@:"); failures += simble_swizzle(managerClass, @selector(state), @selector(simble_state)); failures += simble_swizzle(managerClass, @selector(scanForPeripheralsWithServices:options:), @selector(simble_scanForPeripheralsWithServices:options:)); diff --git a/packages/interpose/src/hooks/peripheral_hooks.m b/packages/interpose/src/hooks/peripheral_hooks.m index 5ca7a9d..5c711c3 100644 --- a/packages/interpose/src/hooks/peripheral_hooks.m +++ b/packages/interpose/src/hooks/peripheral_hooks.m @@ -25,6 +25,7 @@ #import "simble_interpose.h" #import +#import // Dispatch a block on the manager's queue, the main queue when it gave none. static void dispatchOnPeripheralQueue(SimblePeripheralManagerEntry *entry, dispatch_block_t block) { @@ -295,6 +296,14 @@ - (BOOL)simble_updateValue:(NSData *)value return st == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED; } +// A managed peripheral manager reports poweredOn: the host serves only after its radio powers on, +// so a reachable bridge means the host peripheral is powered on. +- (CBManagerState)simble_peripheral_state { + if (simble_shadow_is_managed_peripheral_manager(self)) + return CBManagerStatePoweredOn; + return [self simble_peripheral_state]; +} + @end // --- Peripheral event delivery --- @@ -383,9 +392,21 @@ void simble_deliver_peripheral_event(const simble_event *event) { } } +// `state` is inherited from the shared CBManager superclass; this calls the superclass state, +// installed on CBPeripheralManager so the swizzle stays on the peripheral class. +static CBManagerState simble_peripheral_state_super(id self, SEL _cmd) { + (void)_cmd; + struct objc_super sup = {self, [CBPeripheralManager superclass]}; + CBManagerState (*send)(struct objc_super *, SEL) = + (CBManagerState(*)(struct objc_super *, SEL))objc_msgSendSuper; + return send(&sup, @selector(state)); +} + int simble_install_peripheral_hooks(void) { int failures = 0; Class managerClass = [CBPeripheralManager class]; + class_addMethod(managerClass, @selector(state), (IMP)simble_peripheral_state_super, "q@:"); + failures += simble_swizzle(managerClass, @selector(state), @selector(simble_peripheral_state)); failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:), @selector(simble_initWithDelegate:queue:)); failures += simble_swizzle(managerClass, @selector(initWithDelegate:queue:options:), diff --git a/packages/interpose/src/registry/shadow.m b/packages/interpose/src/registry/shadow.m index 20cad81..3e2074e 100644 --- a/packages/interpose/src/registry/shadow.m +++ b/packages/interpose/src/registry/shadow.m @@ -142,6 +142,9 @@ static Class makeShadowSubclass(Class base, const char *name) { // registry's attached child list. class_addMethod(g_peripheralShadowClass, sel_registerName("services"), (IMP)shadowChildren, "@@:"); + // The stand-in peripheral answers its identifier from the meta. + class_addMethod(g_peripheralShadowClass, sel_registerName("identifier"), (IMP)shadowIdentifier, + "@@:"); class_addMethod(g_serviceShadowClass, sel_registerName("characteristics"), (IMP)shadowChildren, "@@:"); // The stand-in central answers its identifier and maximumUpdateValueLength from the meta. @@ -269,6 +272,8 @@ BOOL simble_shadow_is_managed_manager(CBCentralManager *manager) { CBPeripheral *minted = mintInstance(g_peripheralShadowClass); SimbleShadowMeta *meta = [SimbleShadowMeta new]; meta.peripheralId = [NSData dataWithBytes:peripheralId length:peripheralLen]; + if (peripheralLen == 16) + meta.identifier = [[NSUUID alloc] initWithUUIDBytes:peripheralId]; meta.owner = manager; objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); g_peripherals[key] = minted; diff --git a/packages/interpose/tests/run-mechanism-central.sh b/packages/interpose/tests/run-mechanism-central.sh index c75dd4d..f6613da 100755 --- a/packages/interpose/tests/run-mechanism-central.sh +++ b/packages/interpose/tests/run-mechanism-central.sh @@ -211,9 +211,11 @@ echo "== confirm bridge ==" "$SIMBLECTL" status | grep -q '"running":true' || fail "simblectl status did not report the bridge running." echo "bridge running" -# Step 7: launch the guest and assert a discovery line. +# Step 7: launch the guest and assert a discovery line. SIMCTL_CHILD_SIMBLE_AUTOSCAN starts the +# central scanning on poweredOn, so this needs no tap. echo "== launch guest and observe ($BUNDLE_ID) ==" -xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ +SIMCTL_CHILD_SIMBLE_AUTOSCAN=1 \ + xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ >"$GUEST_LOG" 2>&1 & GUEST_PID=$! @@ -225,4 +227,4 @@ fi echo "--- guest console ---" >&2 grep '\[simble-example\]' "$GUEST_LOG" >&2 || true -fail "no discovery within ${TIMEOUT}s. A real BLE peripheral must be advertising in range, and the guest must be scanning (tap Scan in the booted Simulator, or pair it with the peripheral lane)." +fail "no discovery within ${TIMEOUT}s. A real BLE peripheral must be advertising in range, and the guest scans on launch (SIMBLE_AUTOSCAN), or pair it with the peripheral lane." diff --git a/packages/interpose/tests/run-mechanism-peripheral-ios.sh b/packages/interpose/tests/run-mechanism-peripheral-ios.sh index 9f9ac97..d8e02d6 100755 --- a/packages/interpose/tests/run-mechanism-peripheral-ios.sh +++ b/packages/interpose/tests/run-mechanism-peripheral-ios.sh @@ -19,7 +19,7 @@ # 1. Preconditions: Xcode, xcrun simctl, xcodegen. # 2. Build the iphonesimulator interposer slice, the helper, and simblectl. # 3. Pick or boot an iOS simulator. -# 4. Build and install the peripheral example into it. +# 4. Build and install the example into it. # 5. Start the helper; it arms the booted sim and writes its discovery record. Bail if Bluetooth # is not authorized for the helper. # 6. Confirm the bridge over simblectl status. @@ -35,8 +35,8 @@ cd "$REPO" TIMEOUT="${SIMBLE_TIMEOUT:-30}" BUILD_DIR="build-sim" -SCHEME="SimBLEPeripheralExample" -BUNDLE_ID="dev.simble.SimBLEPeripheralExample" +SCHEME="SimBLEExample" +BUNDLE_ID="dev.simble.SimBLEExample" SIM_SDK="iphonesimulator" DEST_PLATFORM="iOS Simulator" RUNTIME_TOKEN="iOS" @@ -149,7 +149,7 @@ fi xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true echo "using simulator $UDID" -# Step 4: build and install the peripheral example. +# Step 4: build and install the example. echo "== build and install example ($SCHEME) ==" ( cd examples/native && xcodegen generate >/dev/null ) || fail "xcodegen generate failed." DERIVED="$WORKDIR/DerivedData" @@ -193,9 +193,12 @@ echo "== confirm bridge ==" "$SIMBLECTL" status | grep -q '"running":true' || fail "simblectl status did not report the bridge running." echo "bridge running" -# Step 7: launch the guest and assert it engages advertising. +# Step 7: launch the guest and assert it engages advertising. SIMCTL_CHILD_SIMBLE_AUTOADVERTISE +# starts the peripheral advertising on poweredOn and SIMCTL_CHILD_SIMBLE_TAB opens the Peripheral +# tab, so this needs no tap. echo "== launch guest and observe ($BUNDLE_ID) ==" -xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ +SIMCTL_CHILD_SIMBLE_AUTOADVERTISE=1 SIMCTL_CHILD_SIMBLE_TAB=peripheral \ + xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ >"$GUEST_LOG" 2>&1 & GUEST_PID=$! @@ -208,4 +211,4 @@ fi echo "--- guest console ---" >&2 grep '\[simble-example\]' "$GUEST_LOG" >&2 || true -fail "advertising did not engage within ${TIMEOUT}s. The guest must start advertising (tap Advertise in the booted Simulator); check the helper has Bluetooth and the bridge is up." +fail "advertising did not engage within ${TIMEOUT}s. The guest advertises on launch (SIMBLE_AUTOADVERTISE on the Peripheral tab); check the helper has Bluetooth and the bridge is up." diff --git a/scripts/build-menubar-app.sh b/scripts/build-menubar-app.sh new file mode 100755 index 0000000..32c23e1 --- /dev/null +++ b/scripts/build-menubar-app.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Nirapod Labs +# +# Assemble the menubar helper into a SimBLE.app bundle and sign it. A bundle (not a bare +# executable) is what MenuBarExtra needs to render and what lets macOS attribute the +# Bluetooth grant to a stable identity. SIGN_ID defaults to ad-hoc; pass a keychain identity +# for a named local build. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +SIGN_ID="${SIGN_ID:--}" +APP="$REPO/dist/SimBLE.app" +# The single version source. CFBundleShortVersionString must be a dotted-numeric string, so a +# prerelease tag (1.0.0-beta) is trimmed to its numeric core (1.0.0) for the plist. +VERSION="$(cat "$REPO/VERSION" 2>/dev/null || echo 0.0.0)" +SHORT_VERSION="${VERSION%%-*}" + +echo "building simble-menubar (release)..." +( cd "$REPO/apps/helper" && xcrun swift build -c release --product simble-menubar ) || exit 1 +BIN="$REPO/apps/helper/.build/release/simble-menubar" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/simble-menubar" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifierdev.simble.menubar + CFBundleNameSimBLE + CFBundleDisplayNameSimBLE + CFBundleExecutablesimble-menubar + CFBundleInfoDictionaryVersion6.0 + CFBundlePackageTypeAPPL + CFBundleShortVersionString$SHORT_VERSION + CFBundleVersion1 + LSMinimumSystemVersion14.0 + LSUIElement + NSBluetoothAlwaysUsageDescriptionSimBLE bridges the iOS and watchOS Simulators to this Mac's Bluetooth radio. + + +PLIST + +# Ad-hoc sign the bundle so the Bluetooth grant attributes to a stable identity across runs. +# --deep covers any nested code; --force replaces an existing signature. +codesign --force --deep --sign "$SIGN_ID" "$APP" >/dev/null 2>&1 \ + || { echo "codesign failed for identity '$SIGN_ID'"; exit 1; } + +echo "built $APP (signed: $SIGN_ID)" +echo "run it: open \"$APP\""