Skip to content

tddworks/baguette

Repository files navigation

Baguette

Baguette

Bon appétit.

Headless iOS Simulator manager + host-side input injection for iOS 26.

CI Coverage Latest release License Swift 6.2 macOS 15+ Xcode 26

A single Swift CLI — baguette — plus a self-contained web UI that gives you full headless control of an iOS simulator without opening Xcode or Simulator.app. Boot devices, stream their screens at 60 fps, dispatch taps / swipes / multi-finger gestures / system gestures / keyboard / hardware buttons, tail the unified log, inspect the accessibility tree, take screenshots and recordings, and — as of 0.1.72 — pipe your Mac webcam into the simulator's camera APIs.

Demo

demo.mp4
device-farm-demo.mp4
Recording.at.2026-05-04.13.20.02.2.mov

The raw clip lives at assets/demo.mp4 — drag it into a GitHub web edit of this README to upload as a CDN-hosted video and replace the line above with the auto-generated URL.

  • Frame streaming — MJPEG or H.264 / AVCC over stdout or WebSocket. Runtime-tunable bitrate / fps / scale. In-browser recording (MP4) composites the bezel + screen + gesture overlays into one file.
  • Host-HID input — taps / swipes / streaming 1- and 2-finger gestures / pinch / pan / scroll / Mac keyboard / hardware buttons (home, lock, power, volume, action, plus Apple Watch's digital crown + side button) — all through SimulatorKit's private symbols with the iOS-26 calling conventions. The iOS-26 streaming-touch + edge-gesture path uses IOHIDDigitizerDispatch so home-indicator swipes, app-switcher drags, and Notification Center / Lock Screen pull-downs all fire the real iOS recognizers live. No dylib injection on this path; no DYLD_INSERT_LIBRARIES to manage.
  • Camera (new in 0.1.72) — pipe a Mac webcam directly into the iOS simulator's AVCaptureVideoPreviewLayer, AVCapturePhotoOutput, and UIImagePickerController. Pick a camera in the browser's Camera card, click Start, the iOS app sees real frames. One ObjC dylib (VirtualCamera.dylib, vendored from asc-pro/SimCam) loaded into every sim-launched app via DYLD_INSERT_LIBRARIES; baguette pumps BGRA frames through a shared-memory ring buffer. See docs/features/camera.md.
  • Device orientationbaguette orientation --udid <X> portrait rotates a booted simulator. Wire JSON + a one-click rotate button on the focus-mode toolbar. Fires a GSEventTypeDeviceOrientationChanged mach message at PurpleWorkspacePort, bypassing SimulatorKit's NSView path so the host stays headless.
  • Accessibility treebaguette describe-ui returns the on-screen AX tree as JSON (per-node role, label, value, identifier, frame in device points). Hit-test mode (--x --y) returns the topmost node under a coordinate. Powered by the private AccessibilityPlatformTranslation framework with a bridgeTokenDelegate we install ourselves.
  • Live unified-log streambaguette logs --udid <X> streams os_log output to stdout; WS /simulators/:udid/logs does the same to the browser's Logs panel. Predicate / bundle-id filters.
  • Standalone web UIbaguette serve opens http://localhost:8421/simulators with a list page, a focus-mode per-device view, a sidebar stream view, the Camera card, an Accessibility inspector overlay, a Logs panel, and in-browser recording. All wrapped by a small JS SDK (Resources/Web/baguette/) — const sim = await Baguette.use({…}); sim.mount(container); — that hangs each part (screen, buttons, keyboard, …) off one Simulator instance.
  • Device farmhttp://localhost:8421/farm renders every booted simulator in a wall / grid / list with filtering + sorting. Click a tile to focus it for full-quality streaming + input through the same pipeline as the CLI.
  • TDD non-negotiable, layered, mock-injected — bounded-context Domain / Infrastructure / App split; 460+ Swift Testing cases backed by auto-generated MockXxx fakes for every external port (Input, Screen, Accessibility, LogStream, Chromes, DeviceHost, Subprocess, CameraCapture, VideoCapture, CameraFrameSink, SimulatorInjection, Cameras). swift test requires no simulator at all.

Install

brew install tddworks/tap/baguette

Apple Silicon only. Requires Xcode 26 — baguette links against private SimulatorKit / CoreSimulator frameworks shipped with Xcode.

Quickstart

# Start the web UI
baguette serve

# Single-device dashboard — list, boot/shutdown, per-device stream pages
open http://localhost:8421/simulators

# Device farm — every booted simulator side-by-side, click to focus
open http://localhost:8421/farm

/simulators lists every simulator on the machine with Boot / Shutdown buttons; click any booted device to open its focus-mode page — full-window live stream, DeviceKit-sourced bezel, top toolbar with Camera / Accessibility / Logs / Home / Screenshot / App-switcher controls, and a sidebar-view jump button.

/farm is the multi-device control surface. See Device farm below.

Headless from the terminal works too:

baguette list
baguette boot --udid <UDID>
baguette tap --udid <UDID> --x 219 --y 478 --width 438 --height 954

Build from source

make           # release build via ./build.sh
swift test     # run the test suite

Hybrid build: SPM fetches dependencies (ArgumentParser, Mockable, Hummingbird, HummingbirdWebSocket); swiftc compiles everything with an Objective-C bridging header targeting arm64e-apple-macos26.0, linking CoreSimulator, SimulatorKit, IOSurface, VideoToolbox, CoreGraphics, ImageIO from Xcode's private frameworks.

CLI

baguette <command> [options]

  # Lifecycle
  list [--json]                              List devices (default + custom sets;
                                             --json emits {"running":[…],"available":[…]})
  boot     --udid <UDID>                     Boot headlessly
  shutdown --udid <UDID>                     Shutdown
  orientation --udid <UDID>                  Rotate the booted simulator
              <portrait|landscape-left|       (GSEvent over PurpleWorkspacePort —
               landscape-right|portrait-      no NSView, host stays headless)
               upside-down>

  # Frames + screenshots
  stream     --udid <UDID> [--fps 60] [--format mjpeg|avcc]
                                             Stream frames on stdout
  screenshot --udid <UDID> [--output <path>] [--quality 0.85] [--scale 1]
                                             One-shot JPEG (defaults to stdout)

  # Accessibility + logs
  describe-ui --udid <UDID> [--x <px> --y <px>] [--output <path>]
                                             Dump on-screen accessibility tree as
                                             JSON; frames in DEVICE POINTS so
                                             they pipe straight back into a tap.
  logs --udid <UDID> [--level info|debug|default]
                     [--style default|compact|json|ndjson|syslog]
                     [--predicate <NSPredicate>] [--bundle-id <id>]
                                             Stream os_log output. Levels are
                                             the three the iOS-runtime accepts.

  # Long-lived gesture pipe
  input --udid <UDID>                        Read newline-delimited JSON
                                             gestures from stdin

  # Web UI — single-device dashboard + multi-device farm + Camera card +
  # Accessibility inspector + Logs panel + in-browser recording.
  serve [--port 8421] [--host 127.0.0.1] [--device-set <path>]

  # DeviceKit chrome / bezel data
  chrome layout    --udid <UDID> | --device-name "iPhone 17 Pro"
  chrome composite --udid <UDID> | --device-name "iPhone 17 Pro"

  # One-shot gestures — same HID path as `input`, one gesture per
  # invocation. Coordinates are in DEVICE POINTS; `width` / `height`
  # are the simulator's screen size in points.
  tap        --udid … --x … --y … --width … --height … [--duration 0.05]
  double-tap --udid … --x … --y … --width … --height …
                                             [--interval 0.05] [--duration 0.08]
  swipe      --udid … --startX … --startY … --endX … --endY …
                                             --width … --height …
  pinch      --udid … --cx … --cy … --startSpread … --endSpread …
                                             --width … --height …
  pan        --udid … --x1 … --y1 … --x2 … --y2 … --dx … --dy …
                                             --width … --height …

  # Keyboard (single keystroke or typed string)
  key  --udid … --code <KeyA..Z|Digit0..9|Enter|Escape|Backspace|Tab|Space|
                       Arrow*|punctuation>
                       [--modifiers shift,control,option,command] [--duration 0.2]
  type --udid … --text "<US-ASCII string>"

  # Hardware + virtual buttons. Phone: home / lock / power / volume-up /
  # volume-down / action / app-switcher / swipe-to-app-switcher /
  # swipe-to-home / pull-down-to-lock-screen / pull-down-to-notification-center.
  # Watch: digital-crown / side-button / left-side-button.
  press --udid … --button <name> [--duration <sec>]

baguette serve — the web UI

baguette serve --port 8421
# [baguette] listening on http://127.0.0.1:8421/simulators

Open http://localhost:8421/simulators in any browser. You get the device list (RUNNING / AVAILABLE), Boot / Shutdown buttons, and a per-device focus-mode page at /simulators/<UDID> with live frames, gesture input, the DeviceKit-sourced bezel, a top toolbar (Camera / Accessibility / Logs / Home / Screenshot / App-switcher / Rotate), floating Camera + Accessibility control cards, and an in-browser MP4 recorder. A sidebar-view variant is reachable from the bottom-left toggle on the focus page.

The HTML is editable on disk — Sources/Baguette/Resources/Web/sim.html opens directly in any browser via file:// (preview mode), and points to its sibling .js files. Set BAGUETTE_WEB_DIR to override the served root for live-iteration without rebuilding.

Routes (single resource tree, no /api/ prefix)

Method Path Backed by
GET / 302 → /simulators
GET /simulators list HTML
GET /simulators.json list JSON {running, available}
GET /simulators/:udid focus-mode HTML (single-sim)
POST /simulators/:udid/boot simulator.boot()
POST /simulators/:udid/shutdown simulator.shutdown()
POST /simulators/:udid/orientation?value=… simulator.orientation().set(…)
GET /simulators/:udid/definition.json SDK bootstrap: identity + screen rect + bezel image URLs + per-button envelope/box/transform
GET /simulators/:udid/chrome.json DeviceKit bezel layout
GET /simulators/:udid/bezel.png rasterized bezel PNG
GET /simulators/:udid/screenshot.jpg one-shot JPEG (?quality=&scale=)
WS /simulators/:udid/stream?format=mjpeg|avcc live frames + control + input + describe_ui
WS /simulators/:udid/logs?level=&style=&predicate=&bundleId= live unified-log stream
WS /simulators/:udid/camera virtual camera: pick a Mac webcam, frames pumped into the simulator's AVFoundation stack via the bundled VirtualCamera.dylib
GET /farm device-farm HTML
GET /farm/:file farm UI asset (farm.css, farm-*.js, …)
GET /baguette/:file SDK module (transport.js, simulator.js, parts/<name>.js, gestures/<name>.js)
GET /<file>.{html,js,css} static UI asset

One bidirectional WebSocket per stream

The same WS carries everything for a viewing session:

  • Server → Browser — encoded binary frames (one per WS message).
    • MJPEG: raw JPEG bytes per frame.
    • AVCC: 1-byte tag + payload — 0x01 avcC description, 0x02 keyframe, 0x03 delta, 0x04 JPEG seed (renders before H.264 IDR lands).
  • Browser → Server — text JSON, one line per message:
    • Stream control: {"type":"set_bitrate","bps":N} / {"type":"set_fps","fps":N} / {"type":"set_scale","scale":N} / {"type":"force_idr"} / {"type":"snapshot"}.
    • Gesture input: same wire format as baguette input (see below).

No /event POST, no UDID-keyed registry — the WS handler closure owns the live stream + simulator handle for the duration.

Device farm

baguette serve
open http://localhost:8421/farm

A multi-device dashboard for the booted simulators on the host. Every device renders in a single page; the same WebSocket pipeline that powers /simulators/:udid drives every tile.

What it does

  • Three view modes — Grid (compact thumbnails), Wall (large tiles with bezels), and List (one-row-per-device with metadata). Toggle from the header.
  • Filter and sort — by device family, OS version, run state. The rail on the left holds filter state across view changes.
  • Click to focus — clicking any tile re-parents its <canvas> into a full-quality focused pane on the right. The thumbnail keeps streaming at low bitrate; only the focused tile pays for full-rate frames. No separate mirror video element — the same canvas appears in two places.
  • Input on the focused tile — gestures, hardware buttons (home / lock), and the pinch overlay all round-trip through SimInputBridgeGestureDispatcherIndigoHIDInput. Anything the CLI can drive, the focused tile can drive.
  • Bezels — each tile renders with its DeviceKit bezel by default, with a 9-slice composition fallback for devices without a packaged asset. Toggle to a raw (no-bezel) display mode from the tile menu.

What's served

/farm is a thin HTML shell at Resources/Web/farm/farm.html that loads five IIFE component scripts from /farm/<name>.js:

Script Job
farm-views.js Grid / Wall / List renderers (pure DOM)
farm-tile.js FarmTile — per-device thumbnail StreamSession
farm-focus.js FarmFocus — focused-device pane
farm-filter.js FarmFilter — filter state + sidebar wiring
farm-app.js FarmApp — orchestrator (boot, fetch, dispatch)

BAGUETTE_WEB_DIR overrides the served root, so you can iterate on the farm UI without rebuilding — point it at Sources/Baguette/Resources/Web on disk and reload the browser.

Wire protocol — baguette input

Newline-delimited JSON on stdin → {"ok":true} / {"ok":false,"error":…} on stdout, one ack per line.

{"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}

// 1-finger streaming (phase-driven). Optional `edge: "bottom"|"top"|
// "left"|"right"` flags the stream as a screen-edge system gesture —
// `bottom` engages iOS's home-indicator recognizer (live home /
// app-switcher preview), `top` engages the status-bar recognizer
// (live Lock Screen pull-down on the left, Notification Center on
// the right). Omit for ordinary interior touches.
{"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}

// 2-finger streaming (the primary pinch / pan path for real-time gestures)
{"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}

// Hardware + virtual buttons. Phone: home, lock, power, volume-up,
// volume-down, action, app-switcher, swipe-to-app-switcher,
// swipe-to-home, pull-down-to-lock-screen, pull-down-to-notification-center.
// Watch: digital-crown, side-button, left-side-button.
{"type":"button", "button":"home"}
{"type":"button", "button":"action", "duration":1.0}

// Keyboard. `code` is a W3C KeyboardEvent.code; modifiers are held
// for the keystroke. Or send a typed string in one envelope.
{"type":"key", "code":"KeyA", "modifiers":["shift"], "duration":0.2}
{"type":"type", "text":"hello"}

// Scroll
{"type":"scroll", "deltaX":0, "deltaY":-50}

// One-shot pinch (server interpolates 10 steps)
{"type":"pinch", "cx":219,"cy":478, "startSpread":60,"endSpread":240,
                 "width":438,"height":954, "duration":0.6}

// One-shot parallel pan of two fingers
{"type":"pan", "x1":175,"y1":478, "x2":263,"y2":478,
               "dx":0,"dy":200, "width":438,"height":954, "duration":0.5}

// On-screen accessibility tree — works over the same WS stream
{"type":"describe_ui"}
{"type":"describe_ui", "x":219, "y":478}

Camera control is its own WS at /simulators/:udid/camera:

{"type":"camera_list"}
{"type":"camera_start","deviceUID":"","fit":"fit","mirror":false}
{"type":"camera_stop"}
{"type":"camera_set_flags","fit":"fill","mirror":true}

Server pushes {"type":"camera_devices","devices":[…]} once on connect and again on camera_list, plus {"type":"camera_state", "phase":"idle|streaming","fps":29.97,"device":"…"} on every state change and once per second while streaming. Full wire reference: docs/features/camera.md.

Coordinate convention. All x / y / startX / endX / x1 / x2 are in device points — same units as width and height. The HID adapter normalises internally before handing them to the C function. A "tap at the centre of an iPhone 17 Pro Max" is x:219, y:478 (half of 438×954), not x:0.5, y:0.5. The browser UI multiplies its normalized coordinates by width / height before serialising.

Known limits

  • siri button — crashes backboardd via every known Indigo path; refused by the CLI.
  • key / type cover US-ASCII via W3C KeyboardEvent.code strings. IME / Pinyin / accented / emoji aren't on the host-HID path yet — fall back to xcrun simctl io <UDID> text "…" for those.
  • Single-finger streaming (touch1-*) routes correctly but UIPinchGestureRecognizer treats it as an interactive pan; prefer touch2-* for pinch / multi-finger.
  • The Camera feature streams one Mac webcam at a time per host — all sims write the same shared-memory ring buffer (/tmp/SimCam.bgra), so two concurrent camera sessions trample.

baguette stream — frame streaming

baguette stream --udid <UDID> --format avcc --fps 60 | ffplay -

Outputs length-prefixed binary frames on stdout. AVCC carries a 1-byte type prefix per chunk:

Prefix Meaning
0x01 avcC description — feed to VideoDecoder.configure
0x02 Keyframe (IDR) AVCC payload
0x03 Delta frame
0x04 JPEG seed — paints before H.264 IDR lands

Runtime control: while streaming, write one JSON line per command to stdin to retune without restarting.

{"type":"set_bitrate","bps":4000000}
{"type":"set_fps","fps":30}
{"type":"set_scale","scale":2}
{"type":"force_idr"}
{"type":"snapshot"}

baguette chrome — DeviceKit bezel data

baguette chrome layout --device-name "iPhone 17 Pro" | jq .
baguette chrome composite --device-name "iPhone 17 Pro" > iphone17pro.png

Reads Apple's own DeviceKit chrome bundles (/Library/Developer/DeviceKit/Chrome/) and emits the bezel layout JSON or rasterizes the composite PDF to PNG. The serve page uses this for every simulator family — no hand-curated bezel table to keep in sync.

Source layout

Bounded contexts mirror across Domain/ and Infrastructure/ so a feature lives in one place across both layers.

.
├── Makefile                          wraps build.sh
├── build.sh                          builds VirtualCamera.dylib first,
│                                     then swift build -c release
├── Package.swift                     SPM manifest
│
├── VirtualCamera/                    iOS-Simulator dylib (vendored from
│   ├── Sources/*.{h,m}               asc-pro/SimCam). Cross-compiled
│   ├── build.sh                      against iphonesimulator SDK,
│   ├── VirtualCamera.dylib           linker-signed adhoc, fat arm64 +
│   └── VENDORED_FROM.md              x86_64. Loaded into sim apps via
│                                     DYLD_INSERT_LIBRARIES.
│
├── Sources/Baguette/
│   ├── App/                          CLI dispatch + use-case orchestration
│   │   ├── RootCommand.swift
│   │   ├── GestureDispatcher.swift   JSON line → Gesture → Input
│   │   ├── ReconfigParser.swift      runtime stream-control parser
│   │   ├── DoubleTapDispatcher.swift double-tap CLI recipe
│   │   ├── Logger.swift
│   │   └── Commands/                 one file per CLI subcommand
│   │
│   ├── Domain/                       pure Swift, no Apple private APIs
│   │   ├── Common/                   Point / Size / Rect / Insets /
│   │   │                             HIDUsage / DeviceButton
│   │   ├── Simulator/                Simulator + Simulators aggregate +
│   │   │                             DeviceHost (the seam adapters depend on)
│   │   ├── Input/                    Input + Gesture + GestureRegistry +
│   │   │                             Tap / Swipe / Touch1 / Touch2 / Press /
│   │   │                             Scroll / Pinch / Pan / Key / TypeText /
│   │   │                             Keyboard / DeviceEdge / GesturePhase
│   │   ├── Screen/                   Screen (frame source)
│   │   ├── Stream/                   Stream + StreamConfig / StreamFormat
│   │   │                             + Envelope (MJPEG / AVCC framing)
│   │   ├── Chrome/                   Chromes aggregate + DeviceChrome /
│   │   │                             DeviceProfile (bezel layout)
│   │   ├── Accessibility/            AXNode + Accessibility (UI tree)
│   │   ├── Orientation/              Orientation + DeviceOrientation values
│   │   ├── Logs/                     LogFilter + LogStream + Subprocess
│   │   │                             collaborator
│   │   └── Camera/                   CameraDevice / CameraFrame / CameraFlags /
│   │                                 SharedFrameLayout / BGRAConverter /
│   │                                 CameraSession (orchestrator, @MainActor) /
│   │                                 CameraMessage (WS parser) /
│   │                                 VirtualCameraInstallPlan + @Mockable
│   │                                 Cameras / CameraCapture / CameraFrameSink /
│   │                                 SimulatorInjection / VideoCapture
│   │
│   ├── Infrastructure/               concrete @Mockable port impls (private-API
│   │                                 code lives ONLY here)
│   │   ├── Simulator/                CoreSimulators (CoreSimulator + SimulatorKit
│   │   │                             ObjC bridge); Simulators + DeviceHost
│   │   ├── Input/                    IndigoHIDInput — 9-arg
│   │   │                             IndigoHIDMessageForMouseNSEvent + button +
│   │   │                             HIDArbitrary + keyboard paths +
│   │   │                             IOHIDDigitizerDispatch for streaming
│   │   │                             touches and edge gestures (iOS 26 path)
│   │   ├── Screen/                   SimulatorKitScreen, ScreenSnapshot
│   │   ├── Stream/                   MJPEG / AVCC encoders, JPEG / H.264, Scaler,
│   │   │                             SeedFilter, Stdout / WebSocket sinks
│   │   ├── Chrome/                   LiveChromes + FileSystemChromeStore +
│   │   │                             PDFRasterizer
│   │   ├── Accessibility/            AXPTranslatorAccessibility (AXPTranslator +
│   │   │                             TokenDispatcher bridge)
│   │   ├── Orientation/              PurpleWorkspacePortOrientation (GSEvent)
│   │   ├── Logs/                     SimDeviceLogStream + HostSubprocess
│   │   ├── Camera/                   AVCameras + AVCameraCapture (orchestrator) +
│   │   │                             HostVideoCapture (integration-only
│   │   │                             AVCaptureSession plumbing) +
│   │   │                             SharedMemoryFrameSink (mmap'd ring buffer)
│   │   │                             + SimctlSimulatorInjection (Subprocess)
│   │   │                             + VirtualCameraInstaller (bundle →
│   │   │                             per-hash dir)
│   │   └── Server/                   Server (Hummingbird HTTP + WS) + WebRoot
│   │
│   └── Resources/Web/                static UI for `serve`
│       ├── sim.html                  list + stream + focus-mode entry
│       ├── sim-list.js               list page renderer
│       ├── sim-stream.html           sidebar-view markup
│       ├── sim-stream.js             sidebar-view orchestrator
│       ├── sim-native.html           focus-mode markup
│       ├── sim-native.js             focus-mode orchestrator
│       ├── sim-camera.js             Camera control card
│       ├── sim-logs.js               Logs panel
│       ├── sim-ax-inspector.js       Accessibility-tree overlay
│       ├── recorder.js               In-browser MP4 recorder
│       ├── frame-decoder.js          MJPEG / AVCC strategy
│       ├── stream-session.js         WebSocket + paint loop
│       ├── capture-gallery.js        screenshot fetch + thumbs
│       ├── baguette/                 JS SDK — Baguette.use({…}) entry,
│       │   ├── baguette.js           transport.js (the only wire-format
│       │   ├── transport.js          owner), simulator.js, parts/<name>.js
│       │   ├── simulator.js          (bezel, screen, button, keyboard),
│       │   ├── parts/                gestures/<name>.js
│       │   │   ├── bezel.js          (pinch-overlay, pointer-interpreter)
│       │   │   ├── screen.js
│       │   │   ├── button.js
│       │   │   └── keyboard.js
│       │   └── gestures/
│       │       ├── pinch-overlay.js
│       │       └── pointer-interpreter.js
│       ├── farm/                     multi-device dashboard
│       └── VirtualCamera/            VirtualCamera.dylib bundled as a
│                                     .copy resource; VirtualCameraInstaller
│                                     reads it from Bundle.module at runtime.
│
└── Tests/BaguetteTests/              mirrors Sources/ contexts
    ├── App/                          GestureDispatcher / ReconfigParser /
    │                                 DoubleTapDispatcher / Commands tests
    ├── Simulator/                    Simulator / Simulators / DeviceHost tests
    ├── Input/                        Gesture / GestureRegistry / Keyboard /
    │                                 IndigoHIDInput error-path tests
    ├── Stream/                       Envelope / StreamConfig / StreamFormat tests
    ├── Server/                       BezelRoutes / WebRootSubdir tests
    ├── Chrome/                       DeviceChrome / DeviceProfile / LiveChromes /
    │                                 CoreGraphicsPDFRasterizer / integration tests
    ├── Accessibility/                AXNode / Accessibility /
    │                                 AXPTranslatorAccessibility tests
    ├── Orientation/                  DeviceOrientation tests
    ├── Logs/                         LogFilter / LogStream / Subprocess
    │                                 orchestration tests
    └── Camera/                       CameraFlags / CameraDevice / CameraFrame /
                                      SharedFrameLayout / BGRAConverter /
                                      CameraSession / CameraMessage /
                                      AVCameraCapture / SimctlSimulatorInjection /
                                      SharedMemoryFrameSink /
                                      VirtualCameraInstaller tests

Testing

TDD is non-negotiable — every behaviour change to a Domain or Infrastructure type lands in a failing @Test under Tests/ first, then the smallest implementation that turns it green, then refactor. Read CLAUDE.md's "TDD is non-negotiable" pre-implementation gate before contributing — that's the project's primary rule and it overrides "the change is small" / "I'll add the test after".

460+ tests using Swift Testing (@Suite, @Test, #expect), not XCTest. Chicago-school state-based: every external boundary is an @Mockable protocol (Input, Screen, Accessibility, LogStream, Chromes, DeviceHost, Subprocess, Orientation, Cameras, CameraCapture, CameraFrameSink, SimulatorInjection, VideoCapture); tests substitute auto-generated MockXxx fakes and assert on returned values rather than recorded calls.

Adapters that talk to private SimulatorKit / CoreSimulator / AccessibilityPlatformTranslation symbols (IndigoHIDInput, AXPTranslatorAccessibility, SimDeviceLogStream, SimulatorKitScreen) take any DeviceHost rather than the concrete CoreSimulators aggregate, so their error-path branches — simulatorNotBooted, idempotent stop, host-deallocated, etc. — are unit-tested via MockDeviceHost without needing a real booted simulator. The successful private-API call path stays integration-only — manually smoke-tested through the CLI and serve UI against a booted iOS sim.

swift test                                              # all tests
swift test --filter Simulators                          # one suite
swift test --filter "GestureRegistry/parses tap"        # one test

The MOCKING compilation flag is set under .debug only, so release builds (via ./build.sh) carry no mock code.

Why this works on iOS 26.4 when older tools don't

Three calling-convention changes in iOS 26 / Xcode 26 broke every public simulator-control tool. Baguette navigates all three:

  1. IndigoHIDMessageForMouseNSEvent is now 9-argument. idb / AXe use the old 5-arg signature; those messages route to a pointer-service target that silently drops or crashes backboardd. We use the 9-arg signature from Xcode 26's preview-kit, which routes through digitizer target 0x32 — the target iOS 26 still honours.
  2. Streaming touches + edge gestures need a real IOHIDEvent. The Xcode 26 SDK ships an IndigoHIDMessageForMouseNSEvent that either misroutes to the home gesture or silently drops. Baguette builds an IOHIDEventCreateDigitizerEvent parent + IOHIDEventCreateDigitizerFingerEvent child, runs it through IndigoHIDMessageForTrackpadEventFromHIDEventRef, then patches the byte slots the wrapper leaves uninitialised (IndigoHIDTouchTarget + IndigoHIDEdge bitmask). That's the recipe behind the home-indicator swipe, app-switcher drag, and Lock-Screen / Notification-Center pull-downs.
  3. Camera substitution requires a per-app dylib. No SimulatorKit symbol fakes the camera, so the camera feature ships a small ObjC dylib (VirtualCamera.dylib) loaded into every sim-launched app via DYLD_INSERT_LIBRARIES. It hooks AVCaptureVideoPreviewLayer.setSession:, AVCapturePhotoOutput, and UIImagePickerController, then reads BGRA frames from a mmap'd buffer baguette fills with a Mac webcam. Per-hash install path dodges iOS 26's simulator dyld page-hash cache rejecting replaced dylibs.

The HID recipe is heavily commented in Sources/Baguette/Infrastructure/Input/IndigoHIDInput.swift. The camera pipeline lives in Sources/Baguette/Infrastructure/Camera/ and VirtualCamera/. The layered architecture is documented in docs/ARCHITECTURE.md.

License

Apache License 2.0 — see LICENSE.

About

Headless iOS Simulator manager/farm + host-side input injection for iOS 26 — taps, swipes, multi-finger gestures, and 60 fps streaming

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors