diff --git a/apps/helper/Sources/SimBLEHelperKit/InjectionEnv.swift b/apps/helper/Sources/SimBLEHelperKit/InjectionEnv.swift new file mode 100644 index 0000000..767e6e9 --- /dev/null +++ b/apps/helper/Sources/SimBLEHelperKit/InjectionEnv.swift @@ -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)) + } +} diff --git a/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift index 43d580b..2493c0a 100644 --- a/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift +++ b/apps/helper/Sources/SimBLEHelperKit/SimulatorArming.swift @@ -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)] { diff --git a/apps/helper/Sources/simble-menubar/HelperModel.swift b/apps/helper/Sources/simble-menubar/HelperModel.swift index 7023854..46e780e 100644 --- a/apps/helper/Sources/simble-menubar/HelperModel.swift +++ b/apps/helper/Sources/simble-menubar/HelperModel.swift @@ -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 @@ -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. diff --git a/apps/helper/Tests/SimBLEHelperKitTests/InjectionEnvTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/InjectionEnvTests.swift new file mode 100644 index 0000000..4791b15 --- /dev/null +++ b/apps/helper/Tests/SimBLEHelperKitTests/InjectionEnvTests.swift @@ -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), "") + } +} diff --git a/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift index d9e5a0b..7fe9a25 100644 --- a/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift +++ b/apps/helper/Tests/SimBLEHelperKitTests/SimulatorArmingTests.swift @@ -5,37 +5,41 @@ 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 { @@ -43,13 +47,18 @@ final class SimulatorArmingTests: XCTestCase { 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 @@ -73,16 +82,13 @@ 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"), @@ -90,16 +96,10 @@ final class SimulatorArmingTests: XCTestCase { 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() { @@ -107,31 +107,50 @@ final class SimulatorArmingTests: XCTestCase { (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")) } } diff --git a/scripts/fence-check.sh b/scripts/fence-check.sh index f11c66f..e6e9cfc 100755 --- a/scripts/fence-check.sh +++ b/scripts/fence-check.sh @@ -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 ;; diff --git a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift index 3d5b8dc..1f90158 100644 --- a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift +++ b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift @@ -85,14 +85,15 @@ final class CommandTests: XCTestCase { let result = SimBLECTL.handle(arguments: ["simblectl", "disarm"], arming: arming(runner)) XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(result.output, #"{"disarmed":["IOS-BOOT","WATCH-BOOT"]}"#) + // No slice is built here, so the shared insert list holds nothing of ours. Disarm clears only + // the namespaced port and token; the unset set excludes the shared insert variable, which a + // peer tool may own. for udid in ["IOS-BOOT", "WATCH-BOOT"] { let spawns = runner.envCalls.filter { $0.contains(udid) } - XCTAssertEqual(spawns.count, 3) - for spawn in spawns { - XCTAssertEqual(spawn.prefix(4), ["spawn", udid, "launchctl", "unsetenv"]) - } - XCTAssertTrue(spawns.contains { $0.last == "SIMBLE_PORT" }) - XCTAssertTrue(spawns.contains { $0.last == "SIMBLE_TOKEN" }) + let unset = Set(spawns + .filter { $0.count >= 5 && $0.prefix(4) == ["spawn", udid, "launchctl", "unsetenv"] } + .map { $0[4] }) + XCTAssertEqual(unset, ["SIMBLE_PORT", "SIMBLE_TOKEN"]) } }