From 2720f2ec4f2af527971bda6192704e5ec7b81064 Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Sat, 27 Jun 2026 19:23:22 +0600 Subject: [PATCH] feat(helper): arm booted simulators Arm each booted simulator with the platform-matching interposer slice, port, and token via simctl launchctl setenv; disarm on exit. Behind an injected simctl runner for tests. --- apps/helper/README.md | 7 +- .../SimBLEHelperKit/SimulatorArming.swift | 172 ++++++++++++++++++ apps/helper/Sources/simble-helper/main.swift | 16 ++ .../SimulatorArmingTests.swift | 137 ++++++++++++++ examples/native/README.md | 6 +- examples/native/Watch/App.swift | 159 ++++++++++++++++ examples/native/Watch/README.md | 7 +- examples/native/project.yml | 30 ++- scripts/fence-check.sh | 2 + 9 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift create mode 100644 apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift create mode 100644 examples/native/Watch/App.swift diff --git a/apps/helper/README.md b/apps/helper/README.md index 8e24845..dc3eefc 100644 --- a/apps/helper/README.md +++ b/apps/helper/README.md @@ -8,5 +8,8 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs The macOS process that owns the CoreBluetooth bridge and arms booted simulators with the matching interposer slice. -This scaffold ships a SwiftPM command-line helper. The menu bar app, loopback -listener, token file, and simulator arming are not implemented yet. +The SwiftPM command-line helper mints a per-session capability token, starts the +loopback listener, and arms every booted simulator whose platform has a built +interposer slice (`SimulatorArming`): it sets the slice insert path, the listener +port, and the token in the simulator's `launchd` environment via +`simctl spawn ... launchctl setenv`. It disarms on exit. diff --git a/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift new file mode 100644 index 0000000..43d580b --- /dev/null +++ b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation + +/// A simulator platform the helper can arm, each mapping to one interposer slice built against +/// that platform's simulator SDK. The ios and watchos slices are separate files because both are +/// arm64 and cannot share one fat binary. A booted simulator whose platform has no built slice is +/// left alone. +public enum SimPlatform: Equatable, Sendable { + case ios + case watchos + + /// The interposer slice file name. The ios slice keeps the canonical name; every other + /// platform carries a platform-suffixed name. + public var sliceName: String { + switch self { + case .ios: "simble-interpose.dylib" + case .watchos: "simble-interpose-watchos.dylib" + } + } + + /// The dev-checkout build directory `make build` writes this platform's slice into. + public var devBuildSubpath: String { + switch self { + case .ios: "build-sim/bin" + case .watchos: "build-watchsim/bin" + } + } + + /// Map a simctl runtime identifier to a platform with a slice, or nil for one without. The + /// identifier carries the platform token before the version + /// (com.apple.CoreSimulator.SimRuntime.iOS-26-5, ...watchOS-11-0). + public init?(runtimeIdentifier: String) { + if runtimeIdentifier.contains(".iOS-") { + self = .ios + } else if runtimeIdentifier.contains(".watchOS-") { + self = .watchos + } else { + return nil + } + } +} + +/// The simctl invocation seam. A fake runner can record commands without a real simulator. +public protocol SimctlRunner: Sendable { + /// Run `xcrun simctl` with `args` and return the exit status and combined output. + func run(_ args: [String]) -> (status: Int32, output: String) +} + +/// Runs `xcrun simctl` as a child process. +public struct ProcessSimctlRunner: SimctlRunner { + public init() {} + + public func run(_ args: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl"] + args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { try process.run() } catch { return (-1, "") } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(data: data, encoding: .utf8) ?? "") + } +} + +/// Locates a platform's interposer slice on disk. +public protocol SliceLocator: Sendable { + /// The absolute path to `platform`'s slice, or nil when no slice is built for it. + func slicePath(for platform: SimPlatform) -> String? +} + +/// Resolves a slice path from the shipped bundle Resources, then the dev-checkout build tree. +public struct DefaultSliceLocator: SliceLocator { + private let bundleResourceDirectory: String? + private let executablePath: String + + /// Build a locator. `bundleResourceDirectory` is the shipped `.app` Resources directory; + /// `executablePath` is the running binary, the walk-up anchor for the dev build tree. + public init( + bundleResourceDirectory: String? = Bundle.main.resourceURL?.path, + executablePath: String = CommandLine.arguments.first ?? "" + ) { + self.bundleResourceDirectory = bundleResourceDirectory + self.executablePath = executablePath + } + + public func slicePath(for platform: SimPlatform) -> String? { + let name = platform.sliceName + if let resources = bundleResourceDirectory { + let bundled = URL(fileURLWithPath: resources).appendingPathComponent(name) + if FileManager.default.fileExists(atPath: bundled.path) { return bundled.path } + } + var dir = URL(fileURLWithPath: executablePath) + .resolvingSymlinksInPath().deletingLastPathComponent() + for _ in 0 ..< 10 { + let candidate = dir.appendingPathComponent("\(platform.devBuildSubpath)/\(name)") + if FileManager.default.fileExists(atPath: candidate.path) { return candidate.path } + dir = dir.deletingLastPathComponent() + } + return nil + } +} + +/// Arms and disarms booted simulators; an app the simulator launches inherits the interposer. +/// Arming sets the slice insert path, the loopback port, and the capability token in the +/// simulator's `launchd` environment; disarming unsets them. +public struct SimulatorArming: Sendable { + /// The injection environment keys arming sets and disarming unsets. + static let injectVariable = "DYLD_INSERT_LIBRARIES" + static let portVariable = "SIMBLE_PORT" + static let tokenVariable = "SIMBLE_TOKEN" + + private let runner: SimctlRunner + private let locator: SliceLocator + + /// Build over the simctl seam and the slice locator. + public init( + runner: SimctlRunner = ProcessSimctlRunner(), + locator: SliceLocator = DefaultSliceLocator() + ) { + self.runner = runner + self.locator = locator + } + + /// Arm every booted simulator whose platform has a built slice. `port` is the loopback + /// listener's bound port; `token` is the session capability token in hex. + public func armBooted(port: UInt16, token: String) { + for sim in bootedSimulators() { + guard let slice = locator.slicePath(for: sim.platform) else { continue } + let env = [ + (Self.injectVariable, slice), + (Self.portVariable, String(port)), + (Self.tokenVariable, token), + ] + for (key, value) in env { + _ = runner.run(["spawn", sim.udid, "launchctl", "setenv", key, value]) + } + } + } + + /// Unset the injection env on every booted simulator. Unsetting an unset variable is harmless, + /// so no per-sim arm-state is tracked. + public func disarm() { + let keys = [Self.injectVariable, Self.portVariable, Self.tokenVariable] + for sim in bootedSimulators() { + for key in keys { + _ = runner.run(["spawn", sim.udid, "launchctl", "unsetenv", key]) + } + } + } + + /// Every booted simulator paired with the platform its runtime maps to, parsed from + /// `simctl list -j devices`. The device map is keyed by runtime identifier. + public func bootedSimulators() -> [(udid: String, platform: SimPlatform)] { + let output = runner.run(["list", "-j", "devices"]).output + guard let data = output.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let devices = root["devices"] as? [String: [[String: Any]]] else { return [] } + var result: [(udid: String, platform: SimPlatform)] = [] + for (runtimeID, deviceList) in devices { + guard let platform = SimPlatform(runtimeIdentifier: runtimeID) else { continue } + for device in deviceList where (device["state"] as? String) == "Booted" { + guard let udid = device["udid"] as? String else { continue } + result.append((udid: udid, platform: platform)) + } + } + return result + } +} diff --git a/apps/helper/Sources/simble-helper/main.swift b/apps/helper/Sources/simble-helper/main.swift index 8eeeed8..626feff 100644 --- a/apps/helper/Sources/simble-helper/main.swift +++ b/apps/helper/Sources/simble-helper/main.swift @@ -42,6 +42,22 @@ do { exit(1) } +// SIGKILL skips disarm; the next arm overwrites the stale env. + +let arming = SimulatorArming() +arming.armBooted(port: listener.port, token: token.hex) +atexit { SimulatorArming().disarm() } +let signalSources: [DispatchSourceSignal] = [SIGINT, SIGTERM].map { number in + signal(number, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: number, queue: .main) + source.setEventHandler { + arming.disarm() + exit(0) + } + source.resume() + return source +} + print("{\"ready\":true,\"port\":\(listener.port)}") fflush(stdout) diff --git a/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift new file mode 100644 index 0000000..d9e5a0b --- /dev/null +++ b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +@testable import SimBLEHelperKit +import XCTest + +/// The arming logic with a fake simctl runner and a fixed slice locator: the exact spawn commands +/// per platform, the skip of a no-slice platform, and the full disarm on every booted sim. No real +/// simulator runs here. +final class SimulatorArmingTests: XCTestCase { + /// Records every simctl invocation and serves a canned `list -j devices` payload. + private final class FakeRunner: SimctlRunner, @unchecked Sendable { + let listJSON: String + private(set) var calls: [[String]] = [] + + init(listJSON: String) { + self.listJSON = listJSON + } + + func run(_ args: [String]) -> (status: Int32, output: String) { + calls.append(args) + if args.first == "list" { return (0, listJSON) } + return (0, "") + } + + /// The setenv/unsetenv calls, dropping the `list` query. + var envCalls: [[String]] { + calls.filter { $0.first == "spawn" } + } + } + + /// Reports a slice for the platforms it was given and nil otherwise, with no disk access. + private struct FixedLocator: SliceLocator { + let paths: [SimPlatform: String] + func slicePath(for platform: SimPlatform) -> String? { + paths[platform] + } + } + + private func devices(_ entries: [(runtime: String, udid: String, state: String)]) -> String { + var byRuntime: [String: [[String: String]]] = [:] + for entry in entries { + byRuntime[entry.runtime, default: []].append(["udid": entry.udid, "state": entry.state]) + } + let root = ["devices": byRuntime] + return String(data: try! JSONSerialization.data(withJSONObject: root), encoding: .utf8)! + } + + private let iosRuntime = "com.apple.CoreSimulator.SimRuntime.iOS-26-5" + private let watchRuntime = "com.apple.CoreSimulator.SimRuntime.watchOS-11-0" + private let tvRuntime = "com.apple.CoreSimulator.SimRuntime.tvOS-18-0" + + // MARK: runtime parsing + + func testRuntimeIdentifierMapsToPlatform() { + XCTAssertEqual(SimPlatform(runtimeIdentifier: iosRuntime), .ios) + XCTAssertEqual(SimPlatform(runtimeIdentifier: watchRuntime), .watchos) + XCTAssertNil(SimPlatform(runtimeIdentifier: tvRuntime)) + } + + func testSliceNamesAreCanonical() { + XCTAssertEqual(SimPlatform.ios.sliceName, "simble-interpose.dylib") + XCTAssertEqual(SimPlatform.watchos.sliceName, "simble-interpose-watchos.dylib") + } + + // MARK: booted-sim parsing + + func testBootedSimulatorsDropsShutdownAndNoSlicePlatforms() { + let runner = FakeRunner(listJSON: devices([ + (iosRuntime, "IOS-BOOT", "Booted"), + (iosRuntime, "IOS-OFF", "Shutdown"), + (watchRuntime, "WATCH-BOOT", "Booted"), + (tvRuntime, "TV-BOOT", "Booted"), + ])) + let arming = SimulatorArming(runner: runner, locator: FixedLocator(paths: [:])) + let booted = arming.bootedSimulators() + XCTAssertEqual(Set(booted.map(\.udid)), ["IOS-BOOT", "WATCH-BOOT"]) + XCTAssertEqual(Dictionary(uniqueKeysWithValues: booted.map { ($0.udid, $0.platform) }), + ["IOS-BOOT": .ios, "WATCH-BOOT": .watchos]) + } + + // MARK: arm + + func testArmSetsThreeKeysPerPlatformWithMatchingSlice() { + let runner = FakeRunner(listJSON: devices([ + (iosRuntime, "IOS-BOOT", "Booted"), + (watchRuntime, "WATCH-BOOT", "Booted"), + ])) + let locator = FixedLocator(paths: [.ios: "/s/ios.dylib", .watchos: "/s/watch.dylib"]) + SimulatorArming(runner: runner, locator: locator).armBooted(port: 51234, token: "deadbeef") + + XCTAssertEqual(runner.envCalls.filter { $0.contains("IOS-BOOT") }, [ + ["spawn", "IOS-BOOT", "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", "/s/ios.dylib"], + ["spawn", "IOS-BOOT", "launchctl", "setenv", "SIMBLE_PORT", "51234"], + ["spawn", "IOS-BOOT", "launchctl", "setenv", "SIMBLE_TOKEN", "deadbeef"], + ]) + XCTAssertEqual(runner.envCalls.filter { $0.contains("WATCH-BOOT") }, [ + ["spawn", "WATCH-BOOT", "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", "/s/watch.dylib"], + ["spawn", "WATCH-BOOT", "launchctl", "setenv", "SIMBLE_PORT", "51234"], + ["spawn", "WATCH-BOOT", "launchctl", "setenv", "SIMBLE_TOKEN", "deadbeef"], + ]) + } + + func testArmSkipsBootedSimWithNoBuiltSlice() { + let runner = FakeRunner(listJSON: devices([ + (iosRuntime, "IOS-BOOT", "Booted"), + (watchRuntime, "WATCH-BOOT", "Booted"), + ])) + // Only the ios slice is built; the booted watch sim must be left alone. + let locator = FixedLocator(paths: [.ios: "/slices/ios.dylib"]) + SimulatorArming(runner: runner, locator: locator).armBooted(port: 9000, token: "ab") + + XCTAssertTrue(runner.envCalls.contains { $0.contains("IOS-BOOT") }) + XCTAssertFalse(runner.envCalls.contains { $0.contains("WATCH-BOOT") }, + "a platform with no slice is never armed") + } + + // MARK: disarm + + func testDisarmUnsetsAllThreeKeysOnEveryBootedSim() { + let runner = FakeRunner(listJSON: devices([ + (iosRuntime, "IOS-BOOT", "Booted"), + (watchRuntime, "WATCH-BOOT", "Booted"), + ])) + // No slices at all: disarm still clears every booted sim regardless of platform. + SimulatorArming(runner: runner, locator: FixedLocator(paths: [:])).disarm() + + for udid in ["IOS-BOOT", "WATCH-BOOT"] { + XCTAssertEqual(runner.envCalls.filter { $0.contains(udid) }, [ + ["spawn", udid, "launchctl", "unsetenv", "DYLD_INSERT_LIBRARIES"], + ["spawn", udid, "launchctl", "unsetenv", "SIMBLE_PORT"], + ["spawn", udid, "launchctl", "unsetenv", "SIMBLE_TOKEN"], + ]) + } + } +} diff --git a/examples/native/README.md b/examples/native/README.md index 7352dea..e1269d6 100644 --- a/examples/native/README.md +++ b/examples/native/README.md @@ -5,5 +5,7 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # native example -Placeholder. The native iOS and standalone watchOS CoreBluetooth examples are -not implemented yet. +Generate the Xcode project with `xcodegen generate` (run from this directory). + +`Watch/` is a standalone watchOS CoreBluetooth central (scan, connect, read). The +iOS example source is still a placeholder. Example code, not a CI gate. diff --git a/examples/native/Watch/App.swift b/examples/native/Watch/App.swift new file mode 100644 index 0000000..1a8ee23 --- /dev/null +++ b/examples/native/Watch/App.swift @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +import SwiftUI + +/// The watchOS example: a CoreBluetooth central that scans, connects to the first peripheral, and +/// reads its first readable characteristic. On an Apple Watch it drives the device radio; in the +/// watchOS Simulator armed by the SimBLE helper, the same calls reach the host Mac's radio. +@main +struct SimBLEWatchApp: App { + var body: some Scene { + WindowGroup { ConsoleView() } + } +} + +struct ConsoleView: View { + @State private var central = CentralConsole() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(central.state) + .font(.headline) + Button(central.scanning ? "Stop" : "Scan") { central.toggleScan() } + .buttonStyle(.borderedProminent) + ForEach(central.log) { line in + Text(line.text) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } +} + +/// One scrollback line, identified for the list. +struct ConsoleLine: Identifiable { + let id = UUID() + let text: String +} + +/// A minimal CoreBluetooth central: scan, connect to the first peripheral seen, discover its +/// services and characteristics, and read the first readable characteristic. Every step appends a +/// line to the console. +@MainActor +@Observable +final class CentralConsole: NSObject, @preconcurrency CBCentralManagerDelegate, + @preconcurrency CBPeripheralDelegate +{ + private(set) var state = "Starting" + private(set) var scanning = false + private(set) var log: [ConsoleLine] = [] + + 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 { + manager.scanForPeripherals(withServices: nil) + scanning = true + append("Scanning") + } + } + + private func append(_ text: String) { + log.insert(ConsoleLine(text: text), at: 0) + } + + // MARK: CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + state = describe(central.state) + append("State: \(state)") + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData _: [String: Any], + rssi RSSI: NSNumber + ) { + guard connected == nil else { return } + append("Found \(peripheral.name ?? "device") (\(RSSI) dBm)") + central.stopScan() + scanning = false + connected = peripheral + peripheral.delegate = self + central.connect(peripheral) + } + + 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" + } + } +} diff --git a/examples/native/Watch/README.md b/examples/native/Watch/README.md index c5b04f7..f91c93d 100644 --- a/examples/native/Watch/README.md +++ b/examples/native/Watch/README.md @@ -5,4 +5,9 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # Watch -watchOS example source placeholder. +A standalone watchOS CoreBluetooth central: scan, connect to the first peripheral, +and read its first readable characteristic. `App.swift` is the whole app. + +Run it in the watchOS Simulator. Start the SimBLE helper first so it arms the booted +watch sim; the app's CoreBluetooth calls then reach the host Mac's radio. On an Apple +Watch the same code drives the device radio. Example code, not a CI gate. diff --git a/examples/native/project.yml b/examples/native/project.yml index fe028d2..529f5f2 100644 --- a/examples/native/project.yml +++ b/examples/native/project.yml @@ -1,10 +1,38 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 Nirapod Labs +# xcodegen spec for the SimBLE native example. Generate the project with `xcodegen generate` +# (run it from examples/native). A Simulator build needs no code signing. +# +# The watchOS target is a standalone CoreBluetooth central. It does not link the interposer; the +# SimBLE helper arms the booted simulator, so the app's CoreBluetooth calls reach the host radio. + name: SimBLEExample options: bundleIdPrefix: dev.simble deploymentTarget: iOS: "17.0" watchOS: "10.0" -targets: {} + createIntermediateGroups: true + +targets: + SimBLEWatchExample: + type: application + platform: watchOS + sources: + - Watch + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.simble.SimBLEWatchExample + GENERATE_INFOPLIST_FILE: "YES" + INFOPLIST_KEY_CFBundleDisplayName: SimBLE Watch + # Standalone single-target watch app, no iOS companion; the sim installs and runs it alone. + INFOPLIST_KEY_WKApplication: "YES" + INFOPLIST_KEY_WKWatchOnly: "YES" + # CoreBluetooth on watchOS needs the usage description to authorize the central. + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription: "Scan for nearby Bluetooth peripherals." + TARGETED_DEVICE_FAMILY: "4" + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.0" + CODE_SIGNING_ALLOWED: "NO" diff --git a/scripts/fence-check.sh b/scripts/fence-check.sh index 469f977..f11c66f 100755 --- a/scripts/fence-check.sh +++ b/scripts/fence-check.sh @@ -63,6 +63,8 @@ allowed() { case "$1" in scripts/fence-check.sh) return 0 ;; scripts/fence-selftest.sh) return 0 ;; + apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift) return 0 ;; + apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift) return 0 ;; .github/workflows/*) return 0 ;; SECURITY.md | README.md | docs/* | docs/**/*) return 0 ;; *.xcscheme | *.xcconfig) return 0 ;;