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
13 changes: 11 additions & 2 deletions .github/workflows/ci-hardware.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@

name: ci-hardware

# Manual only. The mechanism lane drives the Mac's real radio, so it needs a self-hosted macOS
# runner with Bluetooth granted to the helper and a BLE peer in range. GitHub-hosted runners have
# neither, so this never runs on push.
on:
workflow_dispatch:

permissions:
contents: read

jobs:
ble-hardware:
runs-on: ubuntu-latest
# Requires a self-hosted macOS runner with Bluetooth and a BLE peer.
runs-on: [self-hosted, macOS]
steps:
- run: echo "Hardware BLE mechanism tests are not implemented yet."
- uses: actions/checkout@v4
- name: iOS central mechanism lane
run: make mechanism-ios
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ fence: ## Run static fence checks
docs: ## Generate C API docs and fail on warnings
doxygen Doxyfile

mechanism-ios: ## Placeholder target (not implemented)
@echo "mechanism-ios is not implemented"
mechanism-ios: ## Run the iOS central in-simulator lane (operator, needs Bluetooth and a BLE peer)
bash packages/interpose/tests/run-mechanism-central.sh ios

mechanism-watchos: ## Placeholder target (not implemented)
@echo "mechanism-watchos is not implemented"
mechanism-watchos: ## Run the watchOS central in-simulator lane (operator, needs Bluetooth and a BLE peer)
bash packages/interpose/tests/run-mechanism-central.sh watchos

mechanism-peripheral-ios: ## Placeholder target (not implemented)
@echo "mechanism-peripheral-ios is not implemented"
mechanism-peripheral-ios: ## Run the iOS peripheral in-simulator lane (operator, needs Bluetooth)
bash packages/interpose/tests/run-mechanism-peripheral-ios.sh

lint: ## biome, swiftlint, and clang-tidy
pnpm exec biome check .
Expand Down
1 change: 1 addition & 0 deletions examples/native/Peripheral/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDeleg

private func append(_ text: String) {
log.insert(LogLine(text: text), at: 0)
FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8))
}

// MARK: CBPeripheralManagerDelegate
Expand Down
2 changes: 2 additions & 0 deletions examples/native/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate,

private func append(_ text: String) {
log.insert(LogLine(text: text), at: 0)
FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8))
}

// MARK: CBCentralManagerDelegate
Expand All @@ -137,6 +138,7 @@ final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate,
found.append(
Discovery(
id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral))
append("Found \(name) (\(RSSI) dBm)")
}
}

Expand Down
1 change: 1 addition & 0 deletions examples/native/Watch/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ final class CentralConsole: NSObject, @preconcurrency CBCentralManagerDelegate,

private func append(_ text: String) {
log.insert(ConsoleLine(text: text), at: 0)
FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8))
}

// MARK: CBCentralManagerDelegate
Expand Down
1 change: 1 addition & 0 deletions packages/interpose/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ if(SIMBLE_SIM_SLICE)
PROPERTIES
PREFIX ""
OUTPUT_NAME "${SIMBLE_INTERPOSE_OUTPUT}"
SUFFIX ".dylib"
)
target_link_libraries(simble_interpose PRIVATE interpose_core ${SIMBLE_FRAMEWORKS})
else()
Expand Down
245 changes: 221 additions & 24 deletions packages/interpose/tests/run-mechanism-central.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,226 @@
# SPDX-FileCopyrightText: 2026 Nirapod Labs
#
# The in-simulator central lane: the interposer slice, injected into a guest app booted in the
# iOS Simulator, routes the guest's CoreBluetooth central calls to the helper, which drives the
# Mac's real radio and streams events back as the guest's delegate callbacks. This needs a booted
# simulator, the running helper, and a real BLE peripheral in range, so it runs on a self-hosted
# machine with Bluetooth, not in CI. It is not a CI gate.
# iOS or watchOS Simulator, routes the guest's CoreBluetooth central calls to the helper, which
# drives the Mac's real radio and streams events back as the guest's delegate callbacks. This needs
# a booted simulator, the running helper with Bluetooth authorized, and a real BLE peripheral in
# range, so it runs on a self-hosted machine with Bluetooth, not in CI. It is not a CI gate.
#
# How it runs, once the pieces exist:
# 1. Build the iOS interposer slice: make configure && cmake --build build-sim
# The slice is build-sim/bin/simble-interpose.so, named simble-interpose.
# 2. Build and start the helper, which prints {"ready":true,"port":<port>} on stdout and exposes
# the per-session capability token in lowercase hex through its session transport.
# 3. Boot a simulator and install a guest app that uses CoreBluetooth as a central.
# 4. Launch the guest with the slice injected and the helper pointed at, through the debug
# scheme that sets, behind the fence (debug-only, allowlisted): the dyld insert list pointing
# at the slice, SIMBLE_PORT at the helper port, and SIMBLE_TOKEN at the session token hex.
# 5. Drive the guest to scan, connect, discover, read, write, and subscribe; assert it sees the
# real peripheral's advertisements and characteristic values through its own delegate.
# Usage: run-mechanism-central.sh [ios|watchos]
# ios (default) builds the iphonesimulator slice and the SimBLEExample central.
# watchos builds the watchsimulator slice and the SimBLEWatchExample central.
#
# The radio-free half of this lane runs as host ctests (make test): the swizzle install and
# uninstall (hook_smoke), the shadow registry mint and fail-closed (shadow_registry), the
# passthrough invariant (passthrough), and the transport round-trip against an in-test loopback
# (client_roundtrip).
set -uo pipefail

echo "run-mechanism-central is the self-hosted in-simulator lane; it is not wired as a CI gate."
echo "See the header of this script for how it runs against a booted simulator and the helper."
exit 0
# Env:
# SIMBLE_DEVICE a simulator name or udid to use; else a booted one of the platform, else a
# default the script boots and shuts down on exit.
# SIMBLE_TIMEOUT per-wait timeout in seconds (default 30).
#
# Steps:
# 1. Preconditions: Xcode, xcrun simctl, xcodegen.
# 2. Build the interposer slice, the helper, and simblectl.
# 3. Pick or boot a simulator of the platform.
# 4. Build and install the central 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.
# 7. Launch the guest, capture its console, and assert a discovery line within the timeout.

set -euo pipefail

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$REPO"

PLATFORM="${1:-ios}"
TIMEOUT="${SIMBLE_TIMEOUT:-30}"

case "$PLATFORM" in
ios)
BUILD_DIR="build-sim"
SCHEME="SimBLEExample"
BUNDLE_ID="dev.simble.SimBLEExample"
SIM_SDK="iphonesimulator"
DEST_PLATFORM="iOS Simulator"
RUNTIME_TOKEN="iOS"
DEFAULT_DEVICE="iPhone 16"
;;
watchos)
BUILD_DIR="build-watchsim"
SCHEME="SimBLEWatchExample"
BUNDLE_ID="dev.simble.SimBLEWatchExample"
SIM_SDK="watchsimulator"
DEST_PLATFORM="watchOS Simulator"
RUNTIME_TOKEN="watchOS"
DEFAULT_DEVICE="Apple Watch Series 10 (46mm)"
;;
*)
echo "usage: run-mechanism-central.sh [ios|watchos]" >&2
exit 2
;;
esac

WORKDIR="$(mktemp -d)"
HELPER_LOG="$WORKDIR/helper.log"
GUEST_LOG="$WORKDIR/guest.log"
HELPER_PID=""
GUEST_PID=""
BOOTED_BY_SCRIPT=""
SIMBLECTL=""

# Kill the helper and the guest console, disarm the sim, clear the discovery record, and shut down
# the sim only when this script booted it. Runs on any exit.
cleanup() {
local code=$?
if [ -n "$GUEST_PID" ]; then kill "$GUEST_PID" 2>/dev/null || true; fi
if [ -n "${UDID:-}" ]; then
xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true
fi
if [ -n "$HELPER_PID" ]; then kill "$HELPER_PID" 2>/dev/null || true; fi
if [ -n "$SIMBLECTL" ]; then "$SIMBLECTL" disarm >/dev/null 2>&1 || true; fi
if [ -n "$BOOTED_BY_SCRIPT" ] && [ -n "${UDID:-}" ]; then
xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true
fi
rm -rf "$WORKDIR"
exit "$code"
}
trap cleanup EXIT INT TERM

fail() {
echo "FAIL: $1" >&2
exit 1
}

# Wait until grep -q "$2" matches file "$1", or until "$3" seconds pass. Returns 0 on a match,
# 1 on timeout.
wait_for_line() {
local file="$1" pattern="$2" timeout="$3" waited=0
while [ "$waited" -lt "$timeout" ]; do
if [ -f "$file" ] && grep -q "$pattern" "$file"; then return 0; fi
sleep 1
waited=$((waited + 1))
done
return 1
}

# Step 1: preconditions.
echo "== preconditions =="
xcode-select -p >/dev/null 2>&1 || fail "Xcode command-line tools not selected; run xcode-select --install or xcode-select -s."
command -v xcrun >/dev/null 2>&1 || fail "xcrun not found; install Xcode."
xcrun simctl help >/dev/null 2>&1 || fail "xcrun simctl unavailable; install Xcode and its simulators."
command -v xcodegen >/dev/null 2>&1 || fail "xcodegen not found; install it (brew install xcodegen)."

# Step 2: build the slice, the helper, and simblectl.
echo "== build interposer slice ($BUILD_DIR) =="
make configure
cmake --build "$BUILD_DIR" -j || fail "interposer slice build failed for $PLATFORM."

echo "== build helper =="
( cd apps/helper && xcrun swift build ) || fail "helper build failed."
HELPER_BIN="$(cd apps/helper && xcrun swift build --show-bin-path)/simble-helper"
[ -x "$HELPER_BIN" ] || fail "helper binary not found at $HELPER_BIN."

echo "== build simblectl =="
( cd tools/simblectl && xcrun swift build ) || fail "simblectl build failed."
SIMBLECTL="$(cd tools/simblectl && xcrun swift build --show-bin-path)/simblectl"
[ -x "$SIMBLECTL" ] || fail "simblectl binary not found at $SIMBLECTL."

# A device udid from `simctl list devices` plain text, restricted to runtime sections matching
# token $1. With a name/udid in $2, match that device in any state; without, the first Booted one.
# Output is the first match, or empty.
select_device() {
local token="$1" want="${2:-}"
xcrun simctl list devices 2>/dev/null | awk -v token="$token" -v want="$want" '
/^-- / { insection = (index($0, token) > 0); next }
!insection { next }
{
if (match($0, /\(([0-9A-Fa-f-]{36})\)/)) {
udid = substr($0, RSTART + 1, RLENGTH - 2)
} else { next }
booted = (index($0, "(Booted)") > 0)
name = $0
sub(/^[[:space:]]+/, "", name)
sub(/ \([0-9A-Fa-f-]{36}\).*/, "", name)
if (want == "") { if (booted) { print udid; exit } }
else if (name == want || udid == want) { print udid; exit }
}'
}

# Step 3: pick or boot a simulator of the platform.
echo "== select simulator =="
UDID=""
if [ -n "${SIMBLE_DEVICE:-}" ]; then
UDID="$(select_device "$RUNTIME_TOKEN" "$SIMBLE_DEVICE")"
[ -n "$UDID" ] || fail "SIMBLE_DEVICE '$SIMBLE_DEVICE' is not a $PLATFORM simulator."
else
UDID="$(select_device "$RUNTIME_TOKEN")"
fi

if [ -z "$UDID" ]; then
echo "no booted $PLATFORM simulator; booting $DEFAULT_DEVICE"
UDID="$(xcrun simctl create "simble-$PLATFORM" "$DEFAULT_DEVICE" 2>/dev/null || true)"
[ -n "$UDID" ] || fail "could not create a $PLATFORM simulator; check available device types (xcrun simctl list devicetypes) and runtimes."
xcrun simctl boot "$UDID" || fail "could not boot simulator $UDID."
BOOTED_BY_SCRIPT=1
fi

xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
echo "using simulator $UDID"

# Step 4: build and install the central example.
echo "== build and install example ($SCHEME) =="
( cd examples/native && xcodegen generate >/dev/null ) || fail "xcodegen generate failed."
DERIVED="$WORKDIR/DerivedData"
( cd examples/native && xcodebuild build \
-project SimBLEExample.xcodeproj -scheme "$SCHEME" \
-sdk "$SIM_SDK" -destination "platform=$DEST_PLATFORM,id=$UDID" \
-derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO >/dev/null ) \
|| fail "example build failed for $SCHEME."

APP_PATH="$(find "$DERIVED/Build/Products" -maxdepth 2 -name "$SCHEME.app" -print -quit)"
[ -n "$APP_PATH" ] || fail "built $SCHEME.app not found under $DERIVED."
bash scripts/fence-check.sh --bundle "$APP_PATH" || fail "fence rejected the built app bundle."
xcrun simctl install "$UDID" "$APP_PATH" || fail "simctl install failed."

# Step 5: start the helper; it arms the booted sim and writes its discovery record.
echo "== start helper =="
"$HELPER_BIN" >"$HELPER_LOG" 2>&1 &
HELPER_PID=$!

waited=0
while [ "$waited" -lt "$TIMEOUT" ]; do
if grep -q '"ready":true' "$HELPER_LOG"; then break; fi
if grep -q "not authorized" "$HELPER_LOG"; then
echo "--- helper output ---" >&2
cat "$HELPER_LOG" >&2
fail "Bluetooth is not authorized for the helper. Grant it once interactively: run apps/helper (or the bundled host app) and approve the Bluetooth prompt, then retry."
fi
if ! kill -0 "$HELPER_PID" 2>/dev/null; then
echo "--- helper output ---" >&2
cat "$HELPER_LOG" >&2
fail "helper exited before it became ready."
fi
sleep 1
waited=$((waited + 1))
done
grep -q '"ready":true' "$HELPER_LOG" || fail "helper did not report ready within ${TIMEOUT}s."
echo "helper ready"

# Step 6: confirm the bridge.
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.
echo "== launch guest and observe ($BUNDLE_ID) =="
xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \
>"$GUEST_LOG" 2>&1 &
GUEST_PID=$!

if wait_for_line "$GUEST_LOG" '\[simble-example\] Found ' "$TIMEOUT"; then
echo "PASS: the guest central saw a peripheral through the bridge."
grep '\[simble-example\]' "$GUEST_LOG" | tail -5
exit 0
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)."
Loading
Loading