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
35 changes: 35 additions & 0 deletions apps/helper/Sources/SimBLEHelperKit/InjectionEnv.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Nirapod Labs

/// Composes the shared `DYLD_INSERT_LIBRARIES` simulator variable. Independent injection
/// tools share it instead of overwriting: each adds and removes only its own slice via
/// `composed`/`removed`. Tool-specific port and token vars stay namespaced (`SIMBLE_*`,
/// `SIMENCLAVE_*`) and are set and cleared directly.
public enum InjectionEnv {
/// Add `dylib` to a `DYLD_INSERT_LIBRARIES` value exactly once, preserving every other entry.
/// An entry already carrying `dylib`'s file name is replaced, so re-arming with a relocated
/// slice path never doubles our entry.
public static func composed(current: String?, adding dylib: String) -> String {
let name = fileName(dylib)
var entries = split(current).filter { fileName($0) != name }
entries.append(dylib)
return entries.joined(separator: ":")
}

/// Remove `dylib` from a `DYLD_INSERT_LIBRARIES` value, leaving every other tool's entry. Matches
/// on the file name, so teardown finds our slice even when its absolute path has since moved or
/// the file is gone and only its canonical name is known.
public static func removed(current: String?, removing dylib: String) -> String {
let name = fileName(dylib)
return split(current).filter { fileName($0) != name }.joined(separator: ":")
}

private static func split(_ value: String?) -> [String] {
(value ?? "").split(separator: ":").map(String.init).filter { !$0.isEmpty }
}

/// The last path component of an insert-list entry, the slice's file name.
private static func fileName(_ path: String) -> String {
String(path.split(separator: "/").last ?? Substring(path))
}
}
56 changes: 42 additions & 14 deletions apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,32 +126,60 @@ public struct SimulatorArming: Sendable {
}

/// 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.
/// listener's bound port; `token` is the session capability token in hex. Idempotent: a re-arm
/// writes only a variable that has drifted; safe to call on a timer.
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])
}
arm(udid: sim.udid, slice: slice, port: port, token: token)
}
}

private func arm(udid: String, slice: String, port: UInt16, token: String) {
// Set port and token before the insert path. The interposer is inert without both (entry.c);
// inserting the slice first would yield an injected app that cannot reach the bridge.
if simulatorEnv(udid, Self.portVariable) != String(port) {
_ = runner.run(["spawn", udid, "launchctl", "setenv", Self.portVariable, String(port)])
}
if simulatorEnv(udid, Self.tokenVariable) != token {
_ = runner.run(["spawn", udid, "launchctl", "setenv", Self.tokenVariable, token])
}
// DYLD_INSERT_LIBRARIES is shared; compose to keep a peer tool's entry (see InjectionEnv).
let current = simulatorEnv(udid, Self.injectVariable)
let composed = InjectionEnv.composed(current: current, adding: slice)
if composed != (current ?? "") {
_ = runner.run(["spawn", udid, "launchctl", "setenv", Self.injectVariable, composed])
}
}

/// Unset the injection env on every booted simulator. Unsetting an unset variable is harmless,
/// so no per-sim arm-state is tracked.
/// Clear our injection from every booted simulator. Removes only our slice from the shared DYLD
/// list, never blanket-unsets it; a peer tool's interposer survives. Our own port and token
/// are ours to unset.
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])
// Remove by the platform's canonical slice name, not a locator-resolved path: teardown must
// succeed even when the slice file has moved or been deleted since arming. Only write the
// shared list when it actually carries our entry, leaving a peer tool's alone.
if let current = simulatorEnv(sim.udid, Self.injectVariable) {
let remaining = InjectionEnv.removed(current: current, removing: sim.platform.sliceName)
if remaining.isEmpty {
_ = runner.run(["spawn", sim.udid, "launchctl", "unsetenv", Self.injectVariable])
} else if remaining != current {
_ = runner.run(["spawn", sim.udid, "launchctl", "setenv", Self.injectVariable, remaining])
}
}
_ = runner.run(["spawn", sim.udid, "launchctl", "unsetenv", Self.portVariable])
_ = runner.run(["spawn", sim.udid, "launchctl", "unsetenv", Self.tokenVariable])
}
}

/// Read one variable from a booted simulator's launchd environment, nil when unset.
private func simulatorEnv(_ udid: String, _ key: String) -> String? {
let output = runner.run(["spawn", udid, "launchctl", "getenv", key]).output
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

/// 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)] {
Expand Down
15 changes: 15 additions & 0 deletions apps/helper/Sources/simble-menubar/HelperModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class HelperModel {
private var listener: LoopbackListener?
private var token: CapabilityToken?
private var stateTimer: Timer?
private var rearmTick = 0

init() {
// Constructing the managers above starts the CoreBluetooth stack, which triggers the
Expand Down Expand Up @@ -110,9 +111,23 @@ final class HelperModel {
}
if running {
refreshSimulators()
rearm()
}
}

/// Re-arm booted simulators about every two seconds (every fourth 0.5s poll); a sim booted,
/// rebooted, or clobbered after bridge start recovers without a manual toggle. Idempotent; runs
/// off the main actor to keep the menubar responsive during the simctl spawns.
private func rearm() {
rearmTick += 1
guard rearmTick >= 4, let token else { return }
rearmTick = 0
let arming = self.arming
let port = self.port
let hex = token.hex
Task.detached { arming.armBooted(port: port, token: hex) }
}

/// 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.
Expand Down
42 changes: 42 additions & 0 deletions apps/helper/Tests/SimBLEHelperKitTests/InjectionEnvTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Nirapod Labs

@testable import SimBLEHelperKit
import XCTest

final class InjectionEnvTests: XCTestCase {
let mine = "/slices/simble-interpose.dylib"
let other = "/slices/simenclave-interpose.dylib"

func testComposedAddsToEmpty() {
XCTAssertEqual(InjectionEnv.composed(current: nil, adding: mine), mine)
XCTAssertEqual(InjectionEnv.composed(current: "", adding: mine), mine)
}

func testComposedKeepsAnotherToolsEntry() {
XCTAssertEqual(InjectionEnv.composed(current: other, adding: mine), "\(other):\(mine)")
}

func testComposedIsIdempotent() {
let once = InjectionEnv.composed(current: other, adding: mine)
XCTAssertEqual(InjectionEnv.composed(current: once, adding: mine), once)
}

func testComposedDeduplicates() {
XCTAssertEqual(InjectionEnv.composed(current: "\(mine):\(other):\(mine)", adding: mine),
"\(other):\(mine)")
}

func testRemovedDropsOnlyOwnEntry() {
XCTAssertEqual(InjectionEnv.removed(current: "\(other):\(mine)", removing: mine), other)
}

func testRemovedLeavesEmptyWhenSole() {
XCTAssertEqual(InjectionEnv.removed(current: mine, removing: mine), "")
}

func testRemovedToleratesMissing() {
XCTAssertEqual(InjectionEnv.removed(current: other, removing: mine), other)
XCTAssertEqual(InjectionEnv.removed(current: nil, removing: mine), "")
}
}
125 changes: 72 additions & 53 deletions apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,60 @@ 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.
/// The arming logic with a fake simctl runner that models each simulator's launchd environment,
/// and a fixed slice locator: composing the shared insert list to keep a peer tool's slice,
/// idempotent re-arm, and a teardown that removes only our own slice. No real simulator runs here.
final class SimulatorArmingTests: XCTestCase {
/// Records every simctl invocation and serves a canned `list -j devices` payload.
/// Records every simctl call and models each sim's launchd env; getenv reads back what setenv
/// wrote, and end state shows the compose/teardown result.
private final class FakeRunner: SimctlRunner, @unchecked Sendable {
let listJSON: String
private(set) var calls: [[String]] = []
private var env: [String: [String: String]] = [:]

init(listJSON: String) {
self.listJSON = listJSON
}
init(listJSON: String) { self.listJSON = listJSON }

func run(_ args: [String]) -> (status: Int32, output: String) {
calls.append(args)
if args.first == "list" { return (0, listJSON) }
guard args.count >= 5, args[0] == "spawn", args[2] == "launchctl" else { return (0, "") }
let udid = args[1], verb = args[3], key = args[4]
switch verb {
case "getenv": return (0, env[udid]?[key] ?? "")
case "setenv": if args.count > 5 { env[udid, default: [:]][key] = args[5] }
case "unsetenv": env[udid]?[key] = nil
default: break
}
return (0, "")
}

/// The setenv/unsetenv calls, dropping the `list` query.
var envCalls: [[String]] {
calls.filter { $0.first == "spawn" }
}
func value(_ udid: String, _ key: String) -> String? { env[udid]?[key] }
func seed(_ udid: String, _ key: String, _ value: String) { env[udid, default: [:]][key] = value }
var setenvCount: Int { calls.filter { $0.count >= 4 && $0[0] == "spawn" && $0[3] == "setenv" }.count }
}

/// 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]
}
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)!
return String(data: try! JSONSerialization.data(withJSONObject: ["devices": byRuntime]), 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"
private let iosSlice = "/slices/simble-interpose.dylib"
private let peerSlice = "/slices/simenclave-interpose.dylib"

private func oneIOS(_ udid: String = "IOS-BOOT") -> FakeRunner {
FakeRunner(listJSON: devices([(iosRuntime, udid, "Booted")]))
}

// MARK: runtime parsing

Expand All @@ -73,65 +82,75 @@ final class SimulatorArmingTests: XCTestCase {
(watchRuntime, "WATCH-BOOT", "Booted"),
(tvRuntime, "TV-BOOT", "Booted"),
]))
let arming = SimulatorArming(runner: runner, locator: FixedLocator(paths: [:]))
let booted = arming.bootedSimulators()
let booted = SimulatorArming(runner: runner, locator: FixedLocator(paths: [:])).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() {
func testArmSetsAllThreeKeysWithMatchingSlice() {
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"],
])
XCTAssertEqual(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"), "/s/ios.dylib")
XCTAssertEqual(runner.value("IOS-BOOT", "SIMBLE_PORT"), "51234")
XCTAssertEqual(runner.value("IOS-BOOT", "SIMBLE_TOKEN"), "deadbeef")
XCTAssertEqual(runner.value("WATCH-BOOT", "DYLD_INSERT_LIBRARIES"), "/s/watch.dylib")
}

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")
SimulatorArming(runner: runner, locator: FixedLocator(paths: [.ios: iosSlice]))
.armBooted(port: 9000, token: "ab")
XCTAssertNotNil(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"))
XCTAssertNil(runner.value("WATCH-BOOT", "DYLD_INSERT_LIBRARIES"),
"a platform with no slice is never armed")
}

func testArmPreservesAPeerToolsSlice() {
// The coexistence case: a peer (SimEnclave) armed first; our arm must keep its entry.
let runner = oneIOS()
runner.seed("IOS-BOOT", "DYLD_INSERT_LIBRARIES", peerSlice)
SimulatorArming(runner: runner, locator: FixedLocator(paths: [.ios: iosSlice]))
.armBooted(port: 7000, token: "tok")
XCTAssertEqual(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"), "\(peerSlice):\(iosSlice)")
}

XCTAssertTrue(runner.envCalls.contains { $0.contains("IOS-BOOT") })
XCTAssertFalse(runner.envCalls.contains { $0.contains("WATCH-BOOT") },
"a platform with no slice is never armed")
func testReArmIsIdempotent() {
let runner = oneIOS()
let arming = SimulatorArming(runner: runner, locator: FixedLocator(paths: [.ios: iosSlice]))
arming.armBooted(port: 7000, token: "tok")
let afterFirst = runner.setenvCount
arming.armBooted(port: 7000, token: "tok")
XCTAssertEqual(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"), iosSlice, "no duplicate slice")
XCTAssertEqual(runner.setenvCount, afterFirst, "a steady-state re-arm writes nothing")
}

// 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"],
])
}
func testDisarmRemovesOnlyOurSlice() {
let runner = oneIOS()
runner.seed("IOS-BOOT", "DYLD_INSERT_LIBRARIES", peerSlice)
let arming = SimulatorArming(runner: runner, locator: FixedLocator(paths: [.ios: iosSlice]))
arming.armBooted(port: 7000, token: "tok")
arming.disarm()
XCTAssertEqual(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"), peerSlice, "peer slice survives")
XCTAssertNil(runner.value("IOS-BOOT", "SIMBLE_PORT"))
XCTAssertNil(runner.value("IOS-BOOT", "SIMBLE_TOKEN"))
}

func testDisarmUnsetsInsertVarWhenOurSliceWasTheOnlyEntry() {
let runner = oneIOS()
let arming = SimulatorArming(runner: runner, locator: FixedLocator(paths: [.ios: iosSlice]))
arming.armBooted(port: 7000, token: "tok")
arming.disarm()
XCTAssertNil(runner.value("IOS-BOOT", "DYLD_INSERT_LIBRARIES"))
}
}
1 change: 1 addition & 0 deletions scripts/fence-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ allowed() {
scripts/fence-check.sh) return 0 ;;
scripts/fence-selftest.sh) return 0 ;;
apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift) return 0 ;;
apps/helper/Sources/SimBLEHelperKit/InjectionEnv.swift) return 0 ;;
apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift) return 0 ;;
.github/workflows/*) return 0 ;;
SECURITY.md | README.md | docs/* | docs/**/*) return 0 ;;
Expand Down
Loading
Loading