Pipe a camera source into an iOS app's AVCaptureVideoPreviewLayer,
AVCapturePhotoOutput, and UIImagePickerController running inside
the simulator. The source can be a live Mac webcam (FaceTime HD,
USB, Continuity Camera), a still image, or a looping video —
the app sees it as if it were a real iOS camera — barcode scanners
scan, profile-photo uploads work, viewfinders fill — without opening
Xcode, without installing a separate menu-bar app. Point a video of a
barcode at a scanner, or a fixed headshot at a profile-photo picker,
entirely from the browser.
Two halves cooperate:
- Mac side (this repo, Swift): a
CameraSessionorchestrator driven from the browser's camera panel. It selects one of three frame producers byCameraSource—AVCameraCapture(webcam, off anAVCaptureSession),ImageFileCapture(a decoded still re-emitted at ~30 fps), orVideoFileCapture(anAVAssetReaderlooped) — and writes their BGRA frames into a fixed-size mmap'd file (/tmp/SimCam.bgra). File sources are downscaled to fit the canvas via the pureScaleToFit. - iOS-Simulator side (
Injected/VirtualCamera/, vendored fromasc-pro/SimCam): a small ObjC dylib that hooks AVFoundation / UIImagePickerController inside every simulator-launched app and substitutes the shared-buffer frame for the (non-existent) hardware camera. Loaded viaDYLD_INSERT_LIBRARIES.
The browser is the picker; baguette is the producer; the dylib is the consumer — and the dylib is source-agnostic: image and video frames are indistinguishable from webcam frames at the shared-buffer boundary, so adding file sources needed no dylib change. No CLI verb — the surface is the browser's camera control card and its WebSocket.
- Browser camera card on
/simulators/<UDID>(sidebar view, under the Camera disclosure). A source selector (Webcam / Image / Video), a device dropdown or a file chooser, Start/Stop, Fit/Fill, Mirror, live FPS. - Wire JSON over the
/simulators/:udid/cameraWebSocket + thePOST /simulators/:udid/camera-sourceupload route — agents can drive the same flow programmatically.
The browser opens ws://<host>:<port>/simulators/<udid>/camera and
exchanges text frames.
Browser → server:
{ "type": "camera_list" }
{ "type": "camera_start",
"source": "webcam", // "webcam" | "image" | "video"; default "webcam"
"deviceUID": "0x14600000046d0825", // required for webcam, ignored otherwise
"fit": "fit", // "fit" | "fill"
"mirror": false }
{ "type": "camera_start", "source": "image", "fit": "fit", "mirror": false }
{ "type": "camera_start", "source": "video", "fit": "fill", "mirror": false }
{ "type": "camera_stop" }
{ "type": "camera_set_flags",
"fit": "fill",
"mirror": true }For image / video there is no path on the wire — the browser
uploads the file first (see the route below) and the server resolves
the staged host file for this udid. A missing source defaults to
webcam, so pre-existing clients keep working.
Server → browser:
{ "type": "camera_devices",
"devices": [
{ "uid": "0x14600000046d0825",
"name": "FaceTime HD Camera",
"isDefault": true }
]
}
{ "type": "camera_state",
"ok": true,
"phase": "streaming", // "idle" | "streaming"
"fps": 29.97,
"source": "webcam", // "webcam" | "image" | "video" while streaming
"device": "0x14600000046d0825" } // present only for a webcam source
{ "type": "camera_state",
"ok": false,
"phase": "idle",
"fps": 0,
"error": "Camera access denied. Open System Settings → Privacy → Camera and enable baguette." }POST /simulators/:udid/camera-source?name=<filename>
body = raw file bytes (application/octet-stream)
→ { "ok": true, "kind": "image" } // or "video"
Accepts images (png jpg jpeg gif heic heif) and videos
(mov mp4 m4v); anything else is refused 415 before the body is
read, and a udid that isn't a known device is refused 404. Unlike
/files (consumed synchronously by simctl), the bytes are staged
into a persistent per-udid slot because the camera WebSocket
streams them later — a new upload replaces the previous one, and the
slot is cleared when the camera socket closes. The browser never sends
a host path; camera_start just names the source kind and the
server reads the staged file.
Both halves of the staged path are treated as untrusted: ?name= is
reduced to its last path component, and the udid must name a
CameraSourceSlot (letters, digits, -, _) before it becomes a
directory — the slot is replaced with a recursive delete on every
upload, and the udid arrives percent-decoded off the request path, so
an unchecked one could carry .. out of the staging root.
camera_devices lands once on connect and again after every
camera_list. camera_state lands after every camera_start /
camera_stop / camera_set_flags.
Browser Server (baguette) iOS Simulator
┌────────────┐ WS ┌────────────────────────┐ ┌──────────────────┐
│ sim-camera │◀─────▶│ /simulators/:udid/camera │ │ AVCaptureVideo │
│ .js (card) │ JSON │ CameraSession (state) │ │ PreviewLayer . │
└────────────┘ │ ├─ AVCameraCapture │ │ setSession: │
│ │ (BGRAConverter) │ │ hook ▲ │
│ ├─ SharedMemoryFrame │ │ │ │
│ │ Sink (mmap) ─┼───────────────┼──▶ /tmp/SimCam │
│ │ │ 24-byte hdr │ .bgra (read) │
│ └─ SimctlSimulator │ + BGRA │ │ │
│ Injection ──────▶│ launchctl │ VirtualCamera │
└────────────────────────┘ setenv │ .dylib │
DYLD_INSERT │ (DisplayLink) │
_LIBRARIES └──────────────────┘
Domain/Camera/— pure value types and@Mockablecollaborators:CameraDevice—{uid, name, isDefault}, structurally equal.CameraFrame— BGRA bytes + dims + sequence + timestamp, validated on construction (rejects oversized frames or mismatched pixel data length).CameraFlags—{fillGravity, mirror}..packed() -> UInt32matches the dylib'skSimCamFlag*bit layout.SharedFrameLayout— header offsets + canvas cap (1280×1280). StaticencodeHeader(...) -> [UInt8]is little-endian and byte-for-byte tested.BGRAConverter— pure factory that strips row-padding from aCVPixelBufferbase address into a tightly packedCameraFrame.CameraSession—@MainActororchestrator. Drives three collaborators (CameraCapture,CameraFrameSink,SimulatorInjection). State:.idle | .streaming(deviceUID:).CameraMessage— pure parser for the inbound WS envelope.
Infrastructure/Camera/:AVCameras— one-shot enumeration viaAVCaptureDevice.DiscoverySession.AVCameraCapture—CameraCaptureorchestrator that converts raw BGRA frames intoCameraFrames with monotonic sequence numbers; depends on aVideoCapturecollaborator. Unit-tested.HostVideoCapture— thin (~80 LOC)AVCaptureSessionwrapper. Integration-only.SharedMemoryFrameSink— mmap-backed writer; rewrites the 24-byte header + pixels andmsync(MS_SYNC)s on every frame.SimctlSimulatorInjection(inInfrastructure/Simulator/, since injection is no longer camera-specific) — read-modify-writes the simulator'sDYLD_INSERT_LIBRARIES:launchctl getenv, merge via the pureInjectedDylibs, thenlaunchctl setenv(orunsetenvwhen nothing is left). Uses the existingSubprocesscollaborator → 100% unit-tested. The merge exists because that variable is one string for the whole simulator and more than one baguette feature injects into it — see Sharing the variable.VirtualCameraInstaller— resolves the bundledVirtualCamera.dylibfromBundle.module, sha256-keys it, and copies into~/Library/Application Support/Baguette/builds/<sha12>/.
Vendored under Injected/VirtualCamera/. Internal symbols retain the SimCam
prefix to keep upstream re-syncs diff-friendly; see
Injected/VirtualCamera/VENDORED_FROM.md. The dylib:
- Hooks
-[AVCaptureVideoPreviewLayer setSession:]and attaches aCADisplayLinkdriver that pushes the latest BGRA frame from/tmp/SimCam.bgrainto the layer'scontents. - Hooks
-[AVCapturePhotoOutput capturePhotoWithSettings:delegate:]and synthesises a delegate sequence from the latest shared frame (still capture works without a real camera). - Hooks
+[UIImagePickerController isSourceTypeAvailable:]to report.cameraas available; walks the picker's view tree onviewDidAppear:and intercepts the disabled-shutter delegate so the simulator's picker actually delivers a photo on tap.
build.shrunsInjected/VirtualCamera/build.shfirst → producesInjected/VirtualCamera/VirtualCamera.dylib(fat: arm64 + x86_64, linker-signed adhoc, install-name@rpath/VirtualCamera.dylib).- The artifact is copied into
Sources/Baguette/Resources/VirtualCamera/VirtualCamera.dylibso SPM bundles it as a.copyresource. - First time
camera_startlands on the WS,VirtualCameraInstaller.installIfNeeded()reads the bundled bytes, computessha256(bytes).prefix(12), and copies into~/Library/Application Support/Baguette/builds/<sha12>/VirtualCamera.dylib. Idempotent — if the file already exists at that path we trust its contents (the path itself is sha-keyed). SimctlSimulatorInjection.arm(...)reads the simulator's currentDYLD_INSERT_LIBRARIES(launchctl getenv), adds this dylib to it, and writes the join back (launchctl setenv). The env var survives until the simulator reboots; apps launched after the arming load the dylib via dyld.- Frames pump through
/tmp/SimCam.bgra; the dylib's display-link driver picks them up on the next tick.
- Per-hash install dir. iOS 26's simulator dyld page-hash cache
rejects a replaced dylib at the same path with
code:codesigning(3) invalid-page(2). Every release ships a different sha and lands at a different path, dodging the cache. - Linker adhoc sign only. The
clang -Wl,-adhoc_codesignflag inInjected/VirtualCamera/build.shsets thelinker-signedflag the simulator's dyld accepts. A post-buildcodesign --force --sign -strips that flag and the dylib stops loading. setSourceType: .camerathrows without the hook. Without swizzling+isSourceTypeAvailable:,UIImagePickerController().sourceType = .cameraraisesNSInvalidArgumentException('Source type 1 not available')in the simulator. The hook lies and returnsYESfor.camera.- Apps launched before arming don't load the dylib. dyld
honours
DYLD_INSERT_LIBRARIESonly at exec time. After arming, a fresh launch (or terminate + relaunch) picks the dylib up. Baguette doesn't reopen apps for the user; the camera card surfaces this when the captured frame doesn't appear in the live preview.
There are three producers today — AVCameraCapture (webcam),
ImageFileCapture, VideoFileCapture — each a CameraCapture the
CameraSession selects by inspecting a CameraSource
(.device / .image / .video). To add a fourth (e.g. a browser
getUserMedia stream):
- Add a case to
CameraSource+ awireKind, and teachCameraStartSource/CameraMessage.parsethe newsourcetoken (parser test first). - New
CameraCaptureimplementation inInfrastructure/Camera/. Use a role-noun collaborator (likeVideoDecoder) if the API is conversational; a one-shot decode (likeStillImage.load) if not. Fit the frame into the canvas withScaleToFitand hand it toonFrameas aCameraFrame. - Inject it into
CameraSessionand add itscasetocapture(for:); resolve the source inServer.handleCameraLine.
SharedMemoryFrameSink, SimulatorInjection, and the dylib stay the
same — they don't care where the bytes came from.
The preview-layer painting above shows frames only in apps that already
got a working AVCaptureSession — which needs a real AVCaptureDevice.
A simulator on a Mac without a camera has none, so AVCaptureDevice
discovery returns nil and real camera apps (expo-camera, VisionCamera,
straight AVFoundation) never start — they show a permission/loading
state, and there's nothing for the preview hook to paint.
SimCamVirtualCamera.m fixes that by mocking the entire capture graph
at the public AVFoundation boundary (the approach
swmansion/SimCam uses; baguette feeds
from the shared buffer instead of a socket). It's app-free — the app
sees a normal camera:
+[AVCaptureDevice defaultDeviceWithMediaType:]and-[AVCaptureDeviceDiscoverySession devices]→ a fabricatedAVCaptureDevicesubclass.-[AVCaptureDeviceInput initWithDevice:error:]→ a dummy input for the fake device, so the real initializer (which dereferences the device format's privateFigCaptureSource) never runs.-[AVCaptureSession canAddInput:/addInput:/canAddOutput:/addOutput:]→ accept the dummy graph without wiring real hardware.-[AVCaptureVideoDataOutput setSampleBufferDelegate:queue:]→ capture the delegate; a 30 fps timer buildsCVPixelBuffer→CMSampleBufferfrom/tmp/SimCam.bgraand callscaptureOutput:didOutputSampleBuffer:fromConnection:directly.- The fake
AVCaptureDeviceFormatshims the private accessors AVFoundation reads during setup (figCaptureSourceVideoFormat→ NULL,videoSupportedFrameRateRanges→@[]) plus+[AVCapturePhotoSettings photoSettings], soAVCapturePhotoOutputinit doesn't crash on the fabricated format.
With this, an unmodified app gets a device, AVCaptureSession "runs",
onCameraReady-style callbacks fire, and the preview + data-output show
baguette's image/video — no app edits.
Injection is automatic (all apps), and armed only while streaming.
camera_start arms the sim's launchd domain
(SimctlSimulatorInjection: launchctl setenv DYLD_INSERT_LIBRARIES),
so every app launched afterward loads the dylib — SimCam-style, no
per-app configuration. stop (and the WS defer) disarms, so the
dylib does not stay injected into every future launch until reboot (the
bug SimCam is known for). CameraSession owns this: it records the armed
simulator and dylib path on start, and removes that entry on
stop / failed-start.
That variable is a single string for the simulator's whole launchd
domain, and baguette has more than one feature that injects into apps
(the virtual camera, and motion). So arming never writes a bare path:
it reads the current value, adds or removes its own entry, and writes
the join back. InjectedDylibs is the pure value that does the merge.
Three consequences worth knowing:
- Starting the camera while motion is armed keeps both loaded; stopping either leaves the other alone.
- Entries are matched by dylib filename, not full path — every release installs under a fresh sha-keyed directory (see Per-hash install dir), so the same dylib legitimately arrives under a new path, and two copies of one dylib in dyld's list is a load error rather than a merge.
- A dylib you armed by hand is preserved, not clobbered. The last
baguette entry leaving takes the whole variable with it (
unsetenv) rather than setting an empty string, which dyld reports as a library it failed to load.
The one ordering rule: the app must be (re)launched after
camera_start. The dylib is inserted at exec time, so:
- In the browser camera card, pick a source and Start (arms + streams).
- Relaunch the target app — tap its icon,
xcrun simctl launch <udid> <bundle-id>, orexpo run:ios(which launches viasimctl). An app already running from before Start won't have the dylib; a Metro JS reload doesn't re-exec, so relaunch the native process. - Open the camera screen — the app sees the virtual camera.
To inject into a single app without arming the whole sim (e.g. a launch
that bypasses launchd, like some Xcode Run configs),
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES passes the dylib to one launch:
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$HOME/Library/Application Support/Baguette/builds/<sha>/VirtualCamera.dylib" \
xcrun simctl launch --terminate-running-process <udid> <bundle-id>
The dylib survives Metro/JS reloads; relaunch only when the native app restarts.
- One camera at a time per host. All simulators write
/tmp/SimCam.bgra; the dylib reads whichever bytes landed last. The Server's camera WS doesn't reject a second concurrent start in v1 — the second one just trashes the first one's frames. To scope per-sim we'd patch the dylib to accept a path override. - No CLI yet.
baguette camera --udid … --device <UID>would be a thin layer over the same WS handler; the wire path is already there for agents that need it. - Video rotation isn't applied.
VideoFileCapturestreams frames in their encoded orientation — a clip recorded with a rotation transform (many phone videos) plays sideways. Fitting and looping work; rotation correction is deferred. - No audio. A video's audio track is ignored — the camera path carries frames only.
- Still images are re-emitted at ~30 fps. A single write would be
hidden after ~1 s (the dylib's reader gates on an advancing sequence
and shows "No camera signal" once stale), so
ImageFileCapturere-writes the same pixels under a fresh sequence on a timer. - No "apps needing reopen" diagnostic. SimCamMac surfaces a list of running apps that started before the dylib was armed. Baguette defers that to a v2; users who don't see frames should terminate-and-relaunch the iOS app.
- Mac-only producer. A future browser
getUserMediasource (sketched in the design phase) would let the page's webcam feed the iOS app without going through AVFoundation on the host. - Virtual-camera format shims are AVFoundation-version-specific.
The graph mock neutralises the specific private
AVCaptureDeviceFormataccessors AVFoundation reads on iOS 26 during capture setup (figCaptureSourceVideoFormat,videoSupportedFrameRateRanges). A future iOS may read a different accessor and crash the target app until that one is shimmed too — the trade-off of mocking private internals. Verified on iOS 26 with expo-camera 57. - No metadata/barcode delivery from the virtual camera. Frames reach
AVCaptureVideoDataOutputand the preview, butAVCaptureMetadataOutputisn't fed synthesized barcode objects — an app that scans via metadata output sees the camera but won't detect a code. FeedingAVCaptureMetadataOutput(e.g. Vision QR detection over the frames) is a follow-up.