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
7 changes: 5 additions & 2 deletions apps/helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
172 changes: 172 additions & 0 deletions apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions apps/helper/Sources/simble-helper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
137 changes: 137 additions & 0 deletions apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift
Original file line number Diff line number Diff line change
@@ -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"],
])
}
}
}
6 changes: 4 additions & 2 deletions examples/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading