| name | baguette-ios-simulator |
| description | Headless iOS Simulator manager with host-side HID input injection, 60fps streaming, and device farm web UI for iOS 26 |
| triggers | ["control iOS simulator headlessly","inject touch input into simulator","stream simulator screen","manage simulator device farm","tap swipe gesture simulator programmatically","boot simulator without GUI","simulator web UI dashboard","simulate multi-finger pinch zoom on iOS"] |
Baguette iOS Simulator Manager
Skill by ara.so — Daily 2026 Skills collection.
Baguette is a Swift CLI tool that creates, boots, and shuts down iOS Simulator devices, streams their screens at 60fps, and injects taps/swipes/multi-finger gestures entirely headlessly — no Simulator.app GUI required. It also serves a self-contained web UI for single-device and multi-device (farm) control.
Requirements
- Apple Silicon Mac only
- macOS 15+
- Xcode 26 (links against private SimulatorKit/CoreSimulator frameworks)
Install
brew install tddworks/tap/baguette
Build from Source
git clone https://github.com/tddworks/baguette
cd baguette
make
swift test
Key CLI Commands
Device Management
baguette list
baguette boot --udid <UDID>
baguette shutdown --udid <UDID>
Screen Streaming
baguette stream --udid <UDID> --fps 60 --format mjpeg
baguette stream --udid <UDID> --fps 60 --format avcc
baguette stream --udid <UDID> --fps 30 --format mjpeg | ffplay -i -
One-Shot Gesture Input
Coordinates are in device points; --width/--height are the simulator screen size in points.
baguette tap --udid <UDID> --x 219 --y 478 --width 438 --height 954
baguette tap --udid <UDID> --x 219 --y 478 --width 438 --height 954 --duration 0.1
baguette swipe --udid <UDID> \
--startX 219 --startY 190 \
--endX 219 --endY 760 \
--width 438 --height 954
baguette pinch --udid <UDID> \
--cx 219 --cy 478 \
--startSpread 60 --endSpread 200 \
--width 438 --height 954
baguette pan --udid <UDID> \
--x1 175 --y1 478 \
--x2 263 --y2 478 \
--dx 0 --dy -100 \
--width 438 --height 954
baguette press --udid <UDID> --button home
baguette press --udid <UDID> --button lock
Streaming Gesture Input (stdin JSON)
For real-time or scripted gesture sequences, pipe newline-delimited JSON to baguette input:
baguette input --udid <UDID>
Each line gets an ack: {"ok":true} or {"ok":false,"error":"..."}.
Web UI Server
baguette serve
baguette serve --port 9000 --host 0.0.0.0
baguette serve --port 8421 --device-set /path/to/device-set
open http://localhost:8421/simulators
open http://localhost:8421/farm
DeviceKit Chrome/Bezel Data
baguette chrome layout --udid <UDID>
baguette chrome composite --udid <UDID> > screenshot.png
baguette chrome layout --device-name "iPhone 17 Pro"
baguette chrome composite --device-name "iPhone 17 Pro" > iphone17pro_bezel.png
Wire Protocol — Streaming Input via stdin
Send newline-delimited JSON to baguette input --udid <UDID>:
{"type":"tap", "x":219, "y":478, "width":438, "height":954, "duration":0.05}
{"type":"swipe", "startX":219, "startY":760, "endX":219, "endY":190, "width":438, "height":954, "duration":0.3}
{"type":"touch1-down", "x":219, "y":478, "width":438, "height":954}
{"type":"touch1-move", "x":225, "y":485, "width":438, "height":954}
{"type":"touch1-up", "x":225, "y":485, "width":438, "height":954}
{"type":"touch2-down", "x1":175, "y1":478, "x2":263, "y2":478, "width":438, "height":954}
{"type":"touch2-move", "x1":150, "y1":478, "x2":288, "y2":478, "width":438, "height":954}
{"type":"touch2-up", "x1":150, "y1":478, "x2":288, "y2":478, "width":438, "height":954}
{"type":"pinch", "cx":219, "cy":478, "startSpread":60, "endSpread":200, "width":438, "height":954}
{"type":"button", "button":"home"}
{"type":"button", "button":"lock"}
{"type":"scroll", "deltaX":0, "deltaY":-50}
WebSocket Protocol (for Web UI / Custom Clients)
Connect to ws://localhost:8421/simulators/<UDID>/stream?format=mjpeg (or avcc).
Server → Client (binary frames)
- MJPEG: raw JPEG bytes per message
- AVCC: 1-byte tag prefix:
0x01 — avcC description
0x02 — keyframe
0x03 — delta frame
0x04 — JPEG seed frame (renders before H.264 IDR)
Client → Server (text JSON)
{"type":"set_bitrate", "bps": 2000000}
{"type":"set_fps", "fps": 30}
{"type":"set_scale", "scale": 0.5}
{"type":"force_idr"}
{"type":"snapshot"}
Gesture input messages (same format as stdin wire protocol above) are also accepted over the WebSocket.
Web UI Routes
| Method | Path | Description |
|---|
GET | / | Redirects → /simulators |
GET | /simulators | Device list HTML |
GET | /simulators.json | {running: [...], available: [...]} |
GET | /simulators/:udid | Stream page HTML |
POST | /simulators/:udid/boot | Boot device |
POST | /simulators/:udid/shutdown | Shutdown device |
GET | /simulators/:udid/chrome.json | Bezel layout JSON |
GET | /simulators/:udid/bezel.png | Rasterized bezel PNG |
WS | /simulators/:udid/stream | Live stream + input |
GET | /farm | Multi-device farm HTML |
Code Examples
Scripting Gestures from Swift
import Foundation
func makeGestureScript() -> String {
let gestures: [[String: Any]] = [
["type": "tap", "x": 100, "y": 200, "width": 390, "height": 844, "duration": 0.05],
["type": "swipe", "startX": 195, "startY": 600,
"endX": 195, "endY": 200, "width": 390, "height": 844, "duration": 0.4],
["type": "pinch", "cx": 195, "cy": 422,
"startSpread": 50, "endSpread": 180, "width": 390, "height": 844],
["type": "button", "button": "home"]
]
return gestures.compactMap { dict -> String? in
guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return nil }
return String(data: data, encoding: .utf8)
}.joined(separator: "\n")
}
func runGestureScript(udid: String, script: String) async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/local/bin/baguette")
process.arguments = ["input", "--udid", udid]
let inputPipe = Pipe()
let outputPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
try process.run()
let inputData = (script + "\n").data(using: .utf8)!
inputPipe.fileHandleForWriting.write(inputData)
inputPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let acks = String(data: outputData, encoding: .utf8) ?? ""
print("Acks:\n\(acks)")
}
Listing and Booting Simulators
import Foundation
struct SimulatorInfo: Codable {
let running: [Device]
let available: [Device]
struct Device: Codable {
let udid: String
let name: String
let os: String
let state: String
}
}
func fetchSimulators(port: Int = 8421) async throws -> SimulatorInfo {
let url = URL(string: "http://localhost:\(port)/simulators.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(SimulatorInfo.self, from: data)
}
func bootSimulator(udid: String, port: Int = 8421) async throws {
let url = URL(string: "http://localhost:\(port)/simulators/\(udid)/boot")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (_, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
}
Task {
let sims = try await fetchSimulators()
print("Running: \(sims.running.map(\.name))")
if let first = sims.available.first {
try await bootSimulator(udid: first.udid)
print("Booted \(first.name)")
}
}
Connecting to a Live Stream WebSocket
import Foundation
func connectToSimulatorStream(udid: String, port: Int = 8421) {
let url = URL(string: "ws://localhost:\(port)/simulators/\(udid)/stream?format=mjpeg")!
let session = URLSession(configuration: .default)
let task = session.webSocketTask(with: url)
task.resume()
let setFps = URLSessionWebSocketTask.Message.string(
#"{"type":"set_fps","fps":15}"#
)
let setBitrate = URLSessionWebSocketTask.Message.string(
#"{"type":"set_bitrate","bps":500000}"#
)
task.send(setFps) { _ in }
task.send(setBitrate) { _ in }
func receiveFrame() {
task.receive { result in
switch result {
case .success(.data(let jpegData)):
print("Received frame: \(jpegData.count) bytes")
receiveFrame()
case .success(.string(let text)):
print("Control message: \(text)")
receiveFrame()
case .failure(let error):
print("Stream ended: \(error)")
}
}
}
receiveFrame()
}
Sending Gestures over WebSocket
func sendTapOverWebSocket(task: URLSessionWebSocketTask, x: Double, y: Double,
width: Double, height: Double) {
let gesture: [String: Any] = [
"type": "tap",
"x": x, "y": y,
"width": width, "height": height,
"duration": 0.05
]
guard let data = try? JSONSerialization.data(withJSONObject: gesture),
let json = String(data: data, encoding: .utf8) else { return }
task.send(.string(json)) { error in
if let error { print("Send error: \(error)") }
}
}
func sendPinchOverWebSocket(task: URLSessionWebSocketTask,
cx: Double, cy: Double,
startSpread: Double, endSpread: Double,
width: Double, height: Double) {
let gesture: [String: Any] = [
"type": "pinch",
"cx": cx, "cy": cy,
"startSpread": startSpread,
"endSpread": endSpread,
"width": width, "height": height
]
guard let data = try? JSONSerialization.data(withJSONObject: gesture),
let json = String(data: data, encoding: .utf8) else { return }
task.send(.string(json)) { _ in }
}
Shell Script: Full Automation Flow
#!/usr/bin/env bash
set -euo pipefail
baguette serve --port 8421 &
SERVER_PID=$!
sleep 1
UDID=$(baguette list | grep "iPhone 17 Pro" | head -1 | awk '{print $1}')
echo "Using simulator: $UDID"
baguette boot --udid "$UDID"
sleep 3
baguette tap --udid "$UDID" --x 195 --y 422 --width 390 --height 844
cat <<EOF | baguette input --udid "$UDID"
{"type":"tap","x":195,"y":200,"width":390,"height":844}
{"type":"swipe","startX":195,"startY":600,"endX":195,"endY":200,"width":390,"height":844,"duration":0.3}
{"type":"button","button":"home"}
EOF
baguette chrome composite --udid "$UDID" > screenshot.png
echo "Screenshot saved to screenshot.png"
baguette shutdown --udid "$UDID"
kill $SERVER_PID
Configuration
Environment Variables
| Variable | Description |
|---|
BAGUETTE_WEB_DIR | Override the served web root (e.g. point to Sources/Baguette/Resources/Web for live UI iteration without rebuilding) |
export BAGUETTE_WEB_DIR="$(pwd)/Sources/Baguette/Resources/Web"
baguette serve
Device Sets
baguette serve --device-set /path/to/my-device-set
baguette list
Common Patterns
CI/CD: Boot, Test, Shutdown
#!/usr/bin/env bash
UDID=$(xcrun simctl list devices available -j | \
python3 -c "import sys,json; devs=[d for v in json.load(sys.stdin)['devices'].values() for d in v if 'iPhone 17' in d['name'] and d['isAvailable']]; print(devs[0]['udid'])")
baguette boot --udid "$UDID"
xcodebuild test -scheme MyApp -destination "id=$UDID"
baguette shutdown --udid "$UDID"
Streaming to a File
baguette stream --udid <UDID> --fps 30 --format mjpeg \
| head -c $((10 * 30 * 50000)) > recording.mjpeg
baguette stream --udid <UDID> --fps 30 --format mjpeg \
| ffmpeg -f mjpeg -i - -t 10 -c:v libx264 output.mp4
Multi-Finger Gesture Sequence (Real-Time)
cat <<'EOF' | baguette input --udid <UDID>
{"type":"touch2-down","x1":160,"y1":600,"x2":230,"y2":600,"width":390,"height":844}
{"type":"touch2-move","x1":160,"y1":500,"x2":230,"y2":500,"width":390,"height":844}
{"type":"touch2-move","x1":160,"y1":400,"x2":230,"y2":400,"width":390,"height":844}
{"type":"touch2-move","x1":160,"y1":300,"x2":230,"y2":300,"width":390,"height":844}
{"type":"touch2-up","x1":160,"y1":300,"x2":230,"y2":300,"width":390,"height":844}
EOF
Troubleshooting
"Command not found: baguette"
export PATH="/opt/homebrew/bin:$PATH"
brew --prefix tddworks/tap/baguette
Simulator Won't Boot
xcode-select -p
sudo xcode-select -s /Applications/Xcode-26.0.app/Contents/Developer
baguette list
Stream Connects but No Frames
baguette boot --udid <UDID>
sleep 3
baguette stream --udid <UDID> --fps 30 --format mjpeg | xxd | head
Input Gestures Not Registering
- Coordinates must be in device points, not pixels. For a 3x display at 390pt wide, pixel width is 1170 — always use point values.
- Ensure
--width and --height match the simulator's actual screen size in points (check with baguette chrome layout --udid <UDID>).
- Only
home and lock buttons are functional on iOS 26 (press command).
Web UI Not Updating
export BAGUETTE_WEB_DIR="$(pwd)/Sources/Baguette/Resources/Web"
baguette serve
Port Already in Use
lsof -i :8421
baguette serve --port 9000
open http://localhost:9000/simulators
Build Failures (Source Build)
xcrun --sdk macosx --show-sdk-path
swift --version
make clean && make