diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 32422d6..8a17c67 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,6 +75,15 @@ jobs:
- name: Build for multiple platforms
run: make build-all
+ - name: Clean up cross-platform artifacts
+ run: |
+ if [ "$RUNNER_OS" == "macOS" ]; then
+ rm -f dist/sim-linux-amd64 dist/sim-windows-amd64.exe
+ else
+ rm -f dist/sim-darwin-amd64 dist/sim-darwin-arm64
+ fi
+ shell: bash
+
- name: Create checksums (Linux only)
if: matrix.os == 'ubuntu-latest'
run: |
diff --git a/.gitignore b/.gitignore
index 55ab1e0..adb7f8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,17 @@ recording_*.mp4
# Temporary files
/tmp/coverage.out
coverage.html
+
+# MiniSimCam
+MiniSimCam/.build/
+MiniSimCam/Example/CameraPreviewApp/build/
+MiniSimCam/Example/CameraPreviewApp/module_cache/
+MiniSimCam/module_cache/
+module_cache/
+cmd/assets/
+
+# Xcode user state files (never commit)
+**/*.xcuserstate
+
+# User Scratch
+scratch/
diff --git a/.golangci.yml b/.golangci.yml
index 351cddc..c9ba352 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -109,6 +109,15 @@ linters:
- lll
- nestif
path: cmd/
+ - linters:
+ - err113
+ - nlreturn
+ - gocritic
+ - tagliatelle
+ - gocyclo
+ - testpackage
+ - cyclop
+ path: cmd/cam.*\.go
- linters:
- gci
path: cmd/device.go
@@ -121,6 +130,7 @@ linters:
- revive
- gosec
- unparam
+ - staticcheck
path: tests/
paths:
- third_party$
diff --git a/Makefile b/Makefile
index d0a3314..fc5e11b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build clean install test test-race test-coverage lint fmt vet check help
+.PHONY: build build-all clean install test test-race test-coverage lint fmt vet check help cam-build cam-clean
# Default target
all: build
@@ -7,15 +7,28 @@ all: build
VERSION := $(shell grep 'version:' config.yaml | awk '{print $$2}' | tr -d '"')
LDFLAGS := -ldflags "-X github.com/annurdien/sim-cli/cmd.Version=$(VERSION)"
-# Build the application
+UNAME_S := $(shell uname -s)
+
+# Build the application (with embedded cam binaries on macOS)
build:
+ifeq ($(UNAME_S),Darwin)
+ cd MiniSimCam && ./Scripts/build.sh
+ go build -tags cam_embed $(LDFLAGS) -o sim
+else
go build $(LDFLAGS) -o sim
+endif
# Build for multiple platforms
build-all:
mkdir -p dist
+ifeq ($(UNAME_S),Darwin)
+ cd MiniSimCam && ./Scripts/build.sh
+ GOOS=darwin GOARCH=amd64 go build -tags cam_embed $(LDFLAGS) -o dist/sim-darwin-amd64
+ GOOS=darwin GOARCH=arm64 go build -tags cam_embed $(LDFLAGS) -o dist/sim-darwin-arm64
+else
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-darwin-amd64
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/sim-darwin-arm64
+endif
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-linux-amd64
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-windows-amd64.exe
@@ -23,8 +36,19 @@ build-all:
clean:
rm -f sim
rm -rf dist/
+ rm -rf cmd/assets/
rm -f coverage.out coverage.html
+# Build MiniSimCam (FrameHost + MiniCamInject dylib)
+cam-build:
+ cd MiniSimCam && ./Scripts/build.sh
+
+# Clean MiniSimCam build artifacts
+cam-clean:
+ cd MiniSimCam && swift package clean
+ rm -rf MiniSimCam/.build/injector
+ rm -rf cmd/assets/
+
# Install dependencies
deps:
go mod download
@@ -79,4 +103,6 @@ help:
@echo " vet - Run go vet"
@echo " check - Run all checks (fmt, vet, lint, test-race)"
@echo " install - Install to /usr/local/bin"
+ @echo " cam-build - Build MiniSimCam (FrameHost + injector dylib)"
+ @echo " cam-clean - Clean MiniSimCam build artifacts"
@echo " help - Show this help"
\ No newline at end of file
diff --git a/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100755
index 0000000..919434a
--- /dev/null
+++ b/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100755
index 0000000..cf7ea32
--- /dev/null
+++ b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,57 @@
+
+
+
+
+ SchemeUserState
+
+ FrameHost.xcscheme_^#shared#^_
+
+ orderHint
+ 3
+
+ MiniCamInject.xcscheme_^#shared#^_
+
+ orderHint
+ 1
+
+ MiniSimCam-Package.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+ MiniSimCamShared.xcscheme_^#shared#^_
+
+ orderHint
+ 2
+
+
+ SuppressBuildableAutocreation
+
+ FrameHost
+
+ primary
+
+
+ FrameTransportTests
+
+ primary
+
+
+ MiniCamInject
+
+ primary
+
+
+ MiniSimCamShared
+
+ primary
+
+
+ ProtocolTests
+
+ primary
+
+
+
+
+
diff --git a/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift b/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift
new file mode 100755
index 0000000..5cf958a
--- /dev/null
+++ b/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift
@@ -0,0 +1,146 @@
+// CameraPreviewApp.swift
+// Minimal SwiftUI example app demonstrating both Stage A (cooperative) and
+// Stage B (transparent injection) modes of MiniSimCam.
+//
+// Stage A: Compile with MINISIMCAM_STAGE_A=1 — uses SharedMemoryCameraSource
+// Stage B: Use normal AVFoundation; the injector dylib intercepts it.
+
+import SwiftUI
+import AVFoundation
+import CoreMedia
+
+@main
+struct CameraPreviewApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+
+// MARK: - Content View
+
+struct ContentView: View {
+ @StateObject private var camera = CameraController()
+
+ var body: some View {
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ CameraPreviewView(session: camera.session)
+ .ignoresSafeArea()
+
+ VStack {
+ Spacer()
+ HStack {
+ Image(systemName: "camera.fill")
+ .foregroundStyle(.white)
+ Text("MiniSimCam Preview")
+ .foregroundStyle(.white)
+ .font(.footnote.bold())
+ Spacer()
+ Text("Frame \(camera.frameCount)")
+ .foregroundStyle(.white.opacity(0.6))
+ .font(.footnote.monospaced())
+ }
+ .padding()
+ .background(.ultraThinMaterial)
+ }
+ }
+ .onAppear { camera.start() }
+ .onDisappear { camera.stop() }
+ }
+}
+
+// MARK: - Camera Preview Layer
+
+class PreviewView: UIView {
+ var videoPreviewLayer: AVCaptureVideoPreviewLayer {
+ guard let layer = layer as? AVCaptureVideoPreviewLayer else {
+ fatalError("Expected `AVCaptureVideoPreviewLayer` type for layer. Check PreviewView.layerClass implementation.")
+ }
+ return layer
+ }
+
+ var session: AVCaptureSession? {
+ get { videoPreviewLayer.session }
+ set { videoPreviewLayer.session = newValue }
+ }
+
+ override class var layerClass: AnyClass {
+ return AVCaptureVideoPreviewLayer.self
+ }
+}
+
+struct CameraPreviewView: UIViewRepresentable {
+ let session: AVCaptureSession
+
+ func makeUIView(context: Context) -> PreviewView {
+ let view = PreviewView()
+ view.backgroundColor = .clear
+ view.videoPreviewLayer.videoGravity = .resizeAspectFill
+ view.session = session
+ return view
+ }
+
+ func updateUIView(_ uiView: PreviewView, context: Context) {
+ uiView.session = session
+ }
+}
+
+// MARK: - Camera Controller (Stage B: transparent AVFoundation)
+
+@MainActor
+final class CameraController: NSObject, ObservableObject {
+ @Published var frameCount: Int = 0
+ let session = AVCaptureSession()
+
+ private let output = AVCaptureVideoDataOutput()
+ private let queue = DispatchQueue(label: "com.example.camera.frames")
+
+ func start() {
+ session.sessionPreset = .hd1280x720
+
+ if let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device),
+ session.canAddInput(input) {
+ session.addInput(input)
+ }
+
+ output.videoSettings = [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
+ ]
+ output.setSampleBufferDelegate(self, queue: queue)
+ if session.canAddOutput(output) {
+ session.addOutput(output)
+ }
+
+ session.startRunning()
+ }
+
+ func stop() {
+ session.stopRunning()
+ }
+}
+
+extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
+ nonisolated func captureOutput(
+ _ output: AVCaptureOutput,
+ didOutput sampleBuffer: CMSampleBuffer,
+ from connection: AVCaptureConnection
+ ) {
+ // We only use this delegate to increment the frame counter.
+ // The actual rendering is handled by AVCaptureVideoPreviewLayer!
+ Task { @MainActor in
+ self.frameCount += 1
+ }
+ }
+
+ nonisolated func captureOutput(
+ _ output: AVCaptureOutput,
+ didDrop sampleBuffer: CMSampleBuffer,
+ from connection: AVCaptureConnection
+ ) {
+ // Dropped frames are acceptable in the MVP.
+ }
+}
diff --git a/MiniSimCam/Example/CameraPreviewApp/build_app.sh b/MiniSimCam/Example/CameraPreviewApp/build_app.sh
new file mode 100755
index 0000000..08eb859
--- /dev/null
+++ b/MiniSimCam/Example/CameraPreviewApp/build_app.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+set -euo pipefail
+cd "$(dirname "$0")"
+
+APP_NAME="CameraPreviewApp"
+BUNDLE_ID="com.example.camerapreview"
+TARGET_DIR="build/${APP_NAME}.app"
+
+mkdir -p "$TARGET_DIR"
+
+echo "
+
+
+
+ CFBundleExecutable
+ ${APP_NAME}
+ CFBundleIdentifier
+ ${BUNDLE_ID}
+ CFBundleName
+ ${APP_NAME}
+ CFBundleVersion
+ 1
+ CFBundleShortVersionString
+ 1.0
+ UILaunchStoryboardName
+ LaunchScreen
+ NSCameraUsageDescription
+ Camera test
+
+" > "$TARGET_DIR/Info.plist"
+
+xcrun swiftc \
+ -target arm64-apple-ios17.0-simulator \
+ -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) \
+ -parse-as-library \
+ CameraPreviewApp.swift \
+ -o "$TARGET_DIR/$APP_NAME"
+
+xcrun simctl install booted "$TARGET_DIR"
+echo "Installed $BUNDLE_ID"
diff --git a/MiniSimCam/Example/CameraPreviewApp/entitlements.plist b/MiniSimCam/Example/CameraPreviewApp/entitlements.plist
new file mode 100755
index 0000000..9acd128
--- /dev/null
+++ b/MiniSimCam/Example/CameraPreviewApp/entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.get-task-allow
+
+
+
diff --git a/MiniSimCam/Package.resolved b/MiniSimCam/Package.resolved
new file mode 100755
index 0000000..c7e2987
--- /dev/null
+++ b/MiniSimCam/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser",
+ "state" : {
+ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
+ "version" : "1.8.2"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/MiniSimCam/Package.swift b/MiniSimCam/Package.swift
new file mode 100755
index 0000000..d9bf77b
--- /dev/null
+++ b/MiniSimCam/Package.swift
@@ -0,0 +1,99 @@
+// swift-tools-version: 5.9
+// Package.swift — MiniSimCam monorepo SPM manifest.
+
+import PackageDescription
+
+let package = Package(
+ name: "MiniSimCam",
+ platforms: [
+ .macOS(.v13),
+ .iOS(.v16),
+ ],
+ products: [
+ // FrameHost: macOS command-line executable (frame producer).
+ .executable(name: "FrameHost", targets: ["FrameHost"]),
+
+ // MiniSimCamShared: C/Swift shared-memory protocol (importable in tests).
+ .library(name: "MiniSimCamShared", targets: ["MiniSimCamShared"]),
+
+ // MiniCamInject: iOS Simulator dylib (built via xcodebuild, not SPM).
+ // Listed as a library target so Xcode can reference the sources.
+ .library(name: "MiniCamInject", type: .dynamic, targets: ["MiniCamInject"]),
+ ],
+ dependencies: [
+ .package(
+ url: "https://github.com/apple/swift-argument-parser",
+ from: "1.4.0"
+ ),
+ ],
+ targets: [
+
+ // ------------------------------------------------------------------
+ // Shared C headers (system module map style).
+ // ------------------------------------------------------------------
+ .target(
+ name: "MiniSimCamShared",
+ path: "Shared",
+ sources: ["AtomicHelpers.c"],
+ publicHeadersPath: "include"
+ ),
+
+ // ------------------------------------------------------------------
+ // FrameHost — macOS executable.
+ // ------------------------------------------------------------------
+ .executableTarget(
+ name: "FrameHost",
+ dependencies: [
+ "MiniSimCamShared",
+ .product(name: "ArgumentParser", package: "swift-argument-parser"),
+ ],
+ path: "Sources/FrameHost",
+ exclude: ["Info.plist"],
+ linkerSettings: [
+ .unsafeFlags([
+ "-Xlinker", "-sectcreate",
+ "-Xlinker", "__TEXT",
+ "-Xlinker", "__info_plist",
+ "-Xlinker", "Sources/FrameHost/Info.plist"
+ ])
+ ]
+ ),
+
+ // ------------------------------------------------------------------
+ // MiniCamInject — iOS Simulator dynamic library.
+ // SPM can resolve types; actual build must use xcodebuild for the
+ // simulator-sdk target.
+ // ------------------------------------------------------------------
+ .target(
+ name: "MiniCamInject",
+ dependencies: ["MiniSimCamShared"],
+ path: "Sources/MiniCamInject",
+ publicHeadersPath: ".",
+ cxxSettings: [
+ .headerSearchPath("../../Shared/include"),
+ .unsafeFlags(["-std=c++17"]),
+ ],
+ linkerSettings: [
+ .linkedFramework("AVFoundation"),
+ .linkedFramework("CoreMedia"),
+ .linkedFramework("CoreVideo"),
+ ]
+ ),
+
+ // ------------------------------------------------------------------
+ // Tests
+ // ------------------------------------------------------------------
+ .testTarget(
+ name: "ProtocolTests",
+ dependencies: ["MiniSimCamShared"],
+ path: "Tests/ProtocolTests"
+ ),
+
+ .testTarget(
+ name: "FrameTransportTests",
+ dependencies: ["FrameHost", "MiniSimCamShared"],
+ path: "Tests/FrameTransportTests"
+ ),
+ ],
+ cxxLanguageStandard: .cxx17
+)
diff --git a/MiniSimCam/Scripts/build.sh b/MiniSimCam/Scripts/build.sh
new file mode 100755
index 0000000..0eab3bf
--- /dev/null
+++ b/MiniSimCam/Scripts/build.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# build.sh -- Builds FrameHost (macOS) and MiniCamInject.dylib (iOS Simulator).
+# Usage: ./Scripts/build.sh [project-dir]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="${1:-"$SCRIPT_DIR/.."}" # MiniSimCam root
+
+# Resolve repo root robustly: prefer git, fall back to PROJECT_DIR/..
+if REPO_ROOT="$(git -C "${PROJECT_DIR}" rev-parse --show-toplevel 2>/dev/null)"; then
+ : # already set
+else
+ REPO_ROOT="$(cd "${PROJECT_DIR}/.." && pwd)"
+fi
+
+cd "$PROJECT_DIR"
+
+echo "=================================================="
+echo " MiniSimCam Build"
+echo "=================================================="
+
+# -- 1. Build FrameHost (macOS release) ----------------------------------------
+echo ""
+echo ">> Building FrameHost (macOS)..."
+swift build --disable-sandbox -c release --product FrameHost 2>&1
+
+FRAME_HOST_BIN="$(swift build --disable-sandbox -c release --product FrameHost --show-bin-path 2>/dev/null)/FrameHost"
+echo " OK FrameHost -> ${FRAME_HOST_BIN}"
+
+# -- 2. Build MiniCamInject.dylib (iOS Simulator) ------------------------------
+echo ""
+echo ">> Building MiniCamInject (iOS Simulator)..."
+
+BUILD_DIR="${PROJECT_DIR}/.build/injector"
+mkdir -p "${BUILD_DIR}"
+
+SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
+INCLUDE_FLAGS="-I ${PROJECT_DIR}/Shared/include -I ${PROJECT_DIR}/Sources/MiniCamInject"
+
+compile_arch() {
+ local ARCH="$1"
+ local TARGET="${ARCH}-apple-ios16.0-simulator"
+ echo " Compiling ${ARCH}..."
+
+ # SharedFrameReader.cpp -- pure C++17 (no ObjC)
+ clang++ \
+ -target "${TARGET}" \
+ -isysroot "${SIM_SDK}" \
+ ${INCLUDE_FLAGS} \
+ -std=c++17 \
+ -fPIC -g \
+ -c "${PROJECT_DIR}/Sources/MiniCamInject/SharedFrameReader.cpp" \
+ -o "${BUILD_DIR}/SharedFrameReader_${ARCH}.o"
+
+ # SampleBufferFactory.mm -- ObjC++ with ARC
+ clang++ \
+ -target "${TARGET}" \
+ -isysroot "${SIM_SDK}" \
+ ${INCLUDE_FLAGS} \
+ -std=c++17 \
+ -fPIC -g \
+ -fobjc-arc \
+ -c "${PROJECT_DIR}/Sources/MiniCamInject/SampleBufferFactory.mm" \
+ -o "${BUILD_DIR}/SampleBufferFactory_${ARCH}.o"
+
+ # CaptureHooks.mm -- ObjC++ with ARC
+ clang++ \
+ -target "${TARGET}" \
+ -isysroot "${SIM_SDK}" \
+ ${INCLUDE_FLAGS} \
+ -std=c++17 \
+ -fPIC -g \
+ -fobjc-arc \
+ -c "${PROJECT_DIR}/Sources/MiniCamInject/CaptureHooks.mm" \
+ -o "${BUILD_DIR}/CaptureHooks_${ARCH}.o"
+
+ # EntryPoint.mm -- ObjC++ with ARC
+ clang++ \
+ -target "${TARGET}" \
+ -isysroot "${SIM_SDK}" \
+ ${INCLUDE_FLAGS} \
+ -std=c++17 \
+ -fPIC -g \
+ -fobjc-arc \
+ -c "${PROJECT_DIR}/Sources/MiniCamInject/EntryPoint.mm" \
+ -o "${BUILD_DIR}/EntryPoint_${ARCH}.o"
+
+ # Link dylib
+ clang++ \
+ -target "${TARGET}" \
+ -dynamiclib \
+ -isysroot "${SIM_SDK}" \
+ -framework AVFoundation \
+ -framework CoreMedia \
+ -framework CoreVideo \
+ -framework Foundation \
+ -framework QuartzCore \
+ -framework VideoToolbox \
+ -framework CoreGraphics \
+ "${BUILD_DIR}/SharedFrameReader_${ARCH}.o" \
+ "${BUILD_DIR}/SampleBufferFactory_${ARCH}.o" \
+ "${BUILD_DIR}/CaptureHooks_${ARCH}.o" \
+ "${BUILD_DIR}/EntryPoint_${ARCH}.o" \
+ -o "${BUILD_DIR}/MiniCamInject_${ARCH}.dylib"
+}
+
+compile_arch arm64
+compile_arch x86_64
+
+# Create universal binary.
+lipo -create \
+ "${BUILD_DIR}/MiniCamInject_arm64.dylib" \
+ "${BUILD_DIR}/MiniCamInject_x86_64.dylib" \
+ -output "${BUILD_DIR}/MiniCamInject.dylib"
+
+# Copy binaries to cmd/assets for go:embed
+ASSETS_DIR="${REPO_ROOT}/cmd/assets"
+mkdir -p "${ASSETS_DIR}"
+cp "${FRAME_HOST_BIN}" "${ASSETS_DIR}/FrameHost"
+cp "${BUILD_DIR}/MiniCamInject.dylib" "${ASSETS_DIR}/MiniCamInject.dylib"
+echo " OK Copied assets to ${ASSETS_DIR}"
+
+echo ""
+echo "=================================================="
+echo " Build complete."
+echo ""
+echo " FrameHost: ${FRAME_HOST_BIN}"
+echo " MiniCamInject: ${BUILD_DIR}/MiniCamInject.dylib"
+echo ""
+echo " Next steps:"
+echo " sim cam start --image "
+echo " sim cam launch --bundle-id com.example.CameraPreviewApp"
+echo "=================================================="
diff --git a/MiniSimCam/Scripts/launch.sh b/MiniSimCam/Scripts/launch.sh
new file mode 100755
index 0000000..10d741c
--- /dev/null
+++ b/MiniSimCam/Scripts/launch.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# launch.sh — Launches an app on the booted simulator with MiniCamInject loaded.
+# Usage: ./launch.sh [udid]
+set -euo pipefail
+
+BUNDLE_ID="${1:-}"
+UDID="${2:-booted}"
+
+if [[ -z "$BUNDLE_ID" ]]; then
+ echo "Usage: launch.sh [udid]" >&2
+ exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$SCRIPT_DIR/.."
+DYLIB="$PROJECT_DIR/.build/injector/MiniCamInject.dylib"
+SHM_PATH="/tmp/minisimcam.${UDID}.frames"
+
+if [[ ! -f "$DYLIB" ]]; then
+ echo "⚠️ MiniCamInject.dylib not found. Run 'sim cam build' first." >&2
+ exit 1
+fi
+
+echo "Launching $BUNDLE_ID on simulator $UDID"
+echo " dylib: $DYLIB"
+echo " shm: $SHM_PATH"
+
+SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$DYLIB" \
+SIMCTL_CHILD_MINISIMCAM_PATH="$SHM_PATH" \
+xcrun simctl launch "$UDID" "$BUNDLE_ID"
diff --git a/MiniSimCam/Shared/AtomicHelpers.c b/MiniSimCam/Shared/AtomicHelpers.c
new file mode 100755
index 0000000..09b5791
--- /dev/null
+++ b/MiniSimCam/Shared/AtomicHelpers.c
@@ -0,0 +1,43 @@
+// AtomicHelpers.c
+// C implementations of the atomic accessor wrappers.
+// Uses GCC/Clang __atomic_* builtins which work on any integer at a pointer.
+// Swift calls these via the module map; C++ uses std::atomic* casts directly.
+
+#include "AtomicHelpers.h"
+#include "MiniCamProtocol.h"
+#include
+
+// Byte-pointer helpers using the offsets defined in MiniCamProtocol.h.
+#define SEQ_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + MSC_OFF_SEQUENCE))
+#define IDX_PTR(h) ((volatile uint32_t *)((uint8_t *)(h) + MSC_OFF_PUBLISHED_INDEX))
+#define FP_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + MSC_OFF_FRAMES_PRODUCED))
+
+// ---- Sequence ---------------------------------------------------------------
+
+uint64_t msc_seq_load_acquire(const void *header) {
+ return __atomic_load_n(SEQ_PTR(header), __ATOMIC_ACQUIRE);
+}
+
+void msc_seq_store_release(void *header, uint64_t value) {
+ __atomic_store_n(SEQ_PTR(header), value, __ATOMIC_RELEASE);
+}
+
+// ---- PublishedIndex ---------------------------------------------------------
+
+uint32_t msc_idx_load_acquire(const void *header) {
+ return __atomic_load_n(IDX_PTR(header), __ATOMIC_ACQUIRE);
+}
+
+void msc_idx_store_relaxed(void *header, uint32_t value) {
+ __atomic_store_n(IDX_PTR(header), value, __ATOMIC_RELAXED);
+}
+
+// ---- FramesProduced ---------------------------------------------------------
+
+uint64_t msc_fp_load_relaxed(const void *header) {
+ return __atomic_load_n(FP_PTR(header), __ATOMIC_RELAXED);
+}
+
+uint64_t msc_fp_fetch_add(void *header, uint64_t delta) {
+ return __atomic_fetch_add(FP_PTR(header), delta, __ATOMIC_RELAXED);
+}
diff --git a/MiniSimCam/Shared/include/AtomicHelpers.h b/MiniSimCam/Shared/include/AtomicHelpers.h
new file mode 100755
index 0000000..35717b3
--- /dev/null
+++ b/MiniSimCam/Shared/include/AtomicHelpers.h
@@ -0,0 +1,45 @@
+// AtomicHelpers.h
+// Thin C wrappers around stdatomic operations on the MSCStreamHeader fields.
+// These use void* / byte-offsets so Swift can call them without ever touching
+// _Atomic fields directly (which Swift cannot import).
+//
+// IMPORTANT: Offsets must match MSCStreamHeader exactly.
+// Verified by the static assertion in MiniCamProtocol.h.
+
+#pragma once
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// ---- Sequence (offset 32 in MSCStreamHeader, uint64_t _Atomic) -------------
+// See MSC_OFF_SEQUENCE in MiniCamProtocol.h.
+
+/// Acquire-load the sequence field.
+uint64_t msc_seq_load_acquire(const void *header);
+
+/// Store to the sequence field with release ordering.
+void msc_seq_store_release(void *header, uint64_t value);
+
+// ---- PublishedIndex (offset 40, uint32_t _Atomic) -------------------------
+// See MSC_OFF_PUBLISHED_INDEX in MiniCamProtocol.h.
+
+/// Acquire-load the publishedIndex field.
+uint32_t msc_idx_load_acquire(const void *header);
+
+/// Relaxed-store to the publishedIndex field.
+void msc_idx_store_relaxed(void *header, uint32_t value);
+
+// ---- FramesProduced (offset 56, uint64_t _Atomic) -------------------------
+// See MSC_OFF_FRAMES_PRODUCED in MiniCamProtocol.h.
+
+/// Relaxed-load the framesProduced field.
+uint64_t msc_fp_load_relaxed(const void *header);
+
+/// Relaxed fetch-and-add on framesProduced; returns the old value.
+uint64_t msc_fp_fetch_add(void *header, uint64_t delta);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/MiniSimCam/Shared/include/MiniCamConstants.h b/MiniSimCam/Shared/include/MiniCamConstants.h
new file mode 100755
index 0000000..ae005e1
--- /dev/null
+++ b/MiniSimCam/Shared/include/MiniCamConstants.h
@@ -0,0 +1,44 @@
+#pragma once
+// MiniCamConstants.h
+// Shared path conventions and runtime constants.
+
+#include
+
+// ---- Shared-memory file path -----------------------------------------------
+// Full path: MSC_SHM_DIR "/" MSC_SHM_PREFIX MSC_SHM_SUFFIX
+#define MSC_SHM_DIR "/tmp"
+#define MSC_SHM_PREFIX "minisimcam."
+#define MSC_SHM_SUFFIX ".frames"
+
+// Status JSON file written by the FrameHost.
+#define MSC_STATUS_SUFFIX ".status"
+
+// PID file for the FrameHost process.
+#define MSC_PID_SUFFIX ".pid"
+
+// ---- Environment variables (passed via SIMCTL_CHILD_*) ---------------------
+// Full path to the shared-memory file injected by the CLI.
+#define MSC_ENV_PATH "MINISIMCAM_PATH"
+
+// Optional: override frames-per-second for the injector's delivery timer.
+#define MSC_ENV_FPS "MINISIMCAM_FPS"
+
+// ---- Delivery defaults ------------------------------------------------------
+#define MSC_DEFAULT_WIDTH 1280u
+#define MSC_DEFAULT_HEIGHT 720u
+#define MSC_DEFAULT_FPS 30u
+
+// Delivery timer tolerance: 10% of frame duration is acceptable jitter.
+#define MSC_TIMER_LEEWAY_RATIO 0.1
+
+// ---- Safety limits ----------------------------------------------------------
+// Maximum frame dimension supported by this version.
+#define MSC_MAX_DIMENSION 3840u
+
+// Maximum time (ms) the consumer waits when a sequence is odd before giving up.
+#define MSC_SEQ_RETRY_LIMIT_US 1000u
+
+// ---- Producer health --------------------------------------------------------
+// If no frame is published within this many nanoseconds, the injector treats
+// the producer as stalled and may deliver a placeholder frame.
+#define MSC_STALE_THRESHOLD_NS (500 * 1000 * 1000ull) // 500 ms
diff --git a/MiniSimCam/Shared/include/MiniCamProtocol.h b/MiniSimCam/Shared/include/MiniCamProtocol.h
new file mode 100755
index 0000000..e119e32
--- /dev/null
+++ b/MiniSimCam/Shared/include/MiniCamProtocol.h
@@ -0,0 +1,96 @@
+#pragma once
+// MiniCamProtocol.h
+// Shared between the macOS FrameHost and the iOS Simulator injector dylib.
+// Must stay C-compatible so it can be imported from Swift and ObjC++.
+//
+// ATOMIC FIELDS POLICY
+// ====================
+// The sequence, publishedIndex, and framesProduced fields carry atomic
+// semantics but are declared as plain integer types here so Swift can
+// import them. ALL access to these fields MUST go through the wrapper
+// functions in AtomicHelpers.h. Never read/write them directly.
+//
+// The C++ injector (SharedFrameReader.cpp) casts these fields to
+// std::atomic* — valid under the C++ memory model when the underlying
+// type has the same size and alignment.
+
+#include
+
+#define MSC_MAGIC 0x4D534343u // "MSCC"
+#define MSC_VERSION 1u
+#define MSC_BUFFER_COUNT 3u
+
+// kCVPixelFormatType_32BGRA = 0x42475241 = 'BGRA'
+#define MSC_PIXEL_FORMAT 0x42475241u
+
+// Align frame rows to 64 bytes (cache-line sized).
+#define MSC_ROW_ALIGNMENT 64u
+
+// ----------------------------------------------------------------------------
+// Stream header — lives at offset 0 in the shared-memory file.
+// The three frame buffers follow immediately after sizeof(MSCStreamHeader).
+//
+// Field layout (all offsets from struct start, verified by static assert):
+// [ 0] uint32_t magic
+// [ 4] uint32_t version
+// [ 8] uint32_t width
+// [ 12] uint32_t height
+// [ 16] uint32_t bytesPerRow
+// [ 20] uint32_t pixelFormat
+// [ 24] uint32_t bufferCount
+// [ 28] uint32_t bufferSize
+// [ 32] uint64_t sequence ← ATOMIC, use msc_seq_*()
+// [ 40] uint32_t publishedIndex ← ATOMIC, use msc_idx_*()
+// [ 44] uint32_t _pad0 ← explicit padding for alignment
+// [ 48] uint64_t presentationTimeNs (plain, written under seq lock)
+// [ 56] uint64_t framesProduced ← ATOMIC, use msc_fp_*()
+// [ 64] uint8_t reserved[64]
+// [128] = total
+// ----------------------------------------------------------------------------
+typedef struct {
+ uint32_t magic; // [ 0]
+ uint32_t version; // [ 4]
+ uint32_t width; // [ 8]
+ uint32_t height; // [ 12]
+ uint32_t bytesPerRow; // [ 16]
+ uint32_t pixelFormat; // [ 20]
+ uint32_t bufferCount; // [ 24]
+ uint32_t bufferSize; // [ 28]
+ uint64_t sequence; // [ 32] ATOMIC — use msc_seq_*()
+ uint32_t publishedIndex; // [ 40] ATOMIC — use msc_idx_*()
+ uint32_t _pad0; // [ 44] explicit pad
+ uint64_t presentationTimeNs; // [ 48]
+ uint64_t framesProduced; // [ 56] ATOMIC — use msc_fp_*()
+ uint8_t reserved[64]; // [ 64]
+} MSCStreamHeader; // [128] total
+
+// Byte offsets used by AtomicHelpers.c — must match the layout above.
+#define MSC_OFF_SEQUENCE 32u
+#define MSC_OFF_PUBLISHED_INDEX 40u
+#define MSC_OFF_FRAMES_PRODUCED 56u
+
+// Compile-time size check (C11 _Static_assert).
+_Static_assert(sizeof(MSCStreamHeader) == 128, "MSCStreamHeader must be exactly 128 bytes");
+
+#define MSC_HEADER_EXPECTED_SIZE 128u
+
+// ----------------------------------------------------------------------------
+// Helpers
+// ----------------------------------------------------------------------------
+
+// Compute aligned bytes-per-row for a given pixel width.
+static inline uint32_t msc_bytes_per_row(uint32_t width) {
+ uint32_t raw = width * 4u;
+ return (raw + MSC_ROW_ALIGNMENT - 1u) & ~(MSC_ROW_ALIGNMENT - 1u);
+}
+
+// Total mmap size: header + 3 * bufferSize.
+static inline uint64_t msc_mapping_size(uint32_t bufferSize) {
+ return (uint64_t)MSC_HEADER_EXPECTED_SIZE + (uint64_t)MSC_BUFFER_COUNT * bufferSize;
+}
+
+// Pointer to frame buffer N inside a mapped region.
+static inline void *msc_frame_ptr(void *base, uint32_t bufferSize, uint32_t index) {
+ uint8_t *p = (uint8_t *)base;
+ return p + MSC_HEADER_EXPECTED_SIZE + (uint64_t)bufferSize * index;
+}
diff --git a/MiniSimCam/Shared/include/module.modulemap b/MiniSimCam/Shared/include/module.modulemap
new file mode 100755
index 0000000..c8317cb
--- /dev/null
+++ b/MiniSimCam/Shared/include/module.modulemap
@@ -0,0 +1,6 @@
+module MiniSimCamShared {
+ header "MiniCamProtocol.h"
+ header "MiniCamConstants.h"
+ header "AtomicHelpers.h"
+ export *
+}
diff --git a/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift
new file mode 100755
index 0000000..74bb9e1
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift
@@ -0,0 +1,162 @@
+// CameraDiscovery.swift
+// Enumerates all available video capture devices (built-in, Continuity Camera, external)
+// using AVCaptureDevice.DiscoverySession, and provides fuzzy name/ID matching.
+
+import Foundation
+import AVFoundation
+
+// MARK: - CameraInfo
+
+struct CameraInfo {
+ let uniqueID: String
+ let localizedName: String
+ let deviceType: AVCaptureDevice.DeviceType
+ let device: AVCaptureDevice
+
+ var typeLabel: String {
+ switch deviceType {
+ case .builtInWideAngleCamera: return "Built-in"
+ default:
+ if #available(macOS 14.0, *), deviceType == .continuityCamera {
+ return "Continuity Camera"
+ }
+ return "External"
+ }
+ }
+}
+
+// MARK: - CameraDiscovery
+
+enum CameraDiscovery {
+
+ // Device types to discover. continuityCamera requires NSCameraUseContinuityCameraDeviceType
+ // in Info.plist (macOS 14+). externalUnknown catches USB webcams.
+ private static var deviceTypes: [AVCaptureDevice.DeviceType] {
+ var types: [AVCaptureDevice.DeviceType] = [
+ .builtInWideAngleCamera,
+ .externalUnknown,
+ ]
+ if #available(macOS 14.0, *) {
+ types.append(.continuityCamera)
+ }
+ return types
+ }
+
+ // MARK: - Enumerate
+
+ /// Returns all discovered video devices.
+ static func allDevices() -> [CameraInfo] {
+ let session = AVCaptureDevice.DiscoverySession(
+ deviceTypes: deviceTypes,
+ mediaType: .video,
+ position: .unspecified
+ )
+ return session.devices.map { device in
+ CameraInfo(
+ uniqueID: device.uniqueID,
+ localizedName: device.localizedName,
+ deviceType: device.deviceType,
+ device: device
+ )
+ }
+ }
+
+ // MARK: - Find
+
+ /// Find a device by exact uniqueID or case-insensitive substring of localizedName.
+ /// - Returns: the matching `AVCaptureDevice`, or nil if not found.
+ /// - Throws: `CameraError.ambiguousDevice` if multiple name matches exist.
+ static func findDevice(byNameOrID query: String) throws -> AVCaptureDevice? {
+ let all = allDevices()
+
+ // 1. Exact uniqueID match takes absolute priority.
+ if let exact = all.first(where: { $0.uniqueID == query }) {
+ return exact.device
+ }
+
+ // 2. Case-insensitive substring match on localizedName.
+ let needle = query.lowercased()
+ let matches = all.filter { $0.localizedName.lowercased().contains(needle) }
+
+ switch matches.count {
+ case 0: return nil
+ case 1: return matches[0].device
+ default:
+ let names = matches.map { "\"\($0.localizedName)\"" }.joined(separator: ", ")
+ throw CameraError.ambiguousDevice("'\(query)' matches multiple cameras: \(names). Use a more specific name or the exact uniqueID (from 'sim cam list').")
+ }
+ }
+
+ // MARK: - Print Table
+
+ /// Prints a formatted table of all discovered cameras to stdout.
+ static func printTable() {
+ let all = allDevices()
+
+ if all.isEmpty {
+ print("[CameraDiscovery] No video devices found.")
+ return
+ }
+
+ // Helper: left-pad a string to exactly `width` characters.
+ func lpad(_ s: String, _ width: Int) -> String {
+ if s.count >= width { return String(s.prefix(width)) }
+ return s + String(repeating: " ", count: width - s.count)
+ }
+
+ // Column widths (minimum enforced).
+ let idColW = max(12, all.map { $0.uniqueID.count }.max() ?? 12)
+ let nameColW = max(24, all.map { $0.localizedName.count }.max() ?? 24)
+ let typeColW = max(18, all.map { $0.typeLabel.count }.max() ?? 18)
+ let numColW = 3
+
+ // Total inner width: " # Name Type UniqueID " + separators
+ let innerW = numColW + 2 + nameColW + 2 + typeColW + 2 + idColW + 4
+ let border = String(repeating: "─", count: innerW)
+
+ let header = "│ Available Cameras"
+ + String(repeating: " ", count: max(0, innerW - 18)) + "│"
+ let colHeader = "│ "
+ + lpad("#", numColW) + " "
+ + lpad("Name", nameColW) + " "
+ + lpad("Type", typeColW) + " "
+ + lpad("UniqueID", idColW) + " │"
+
+ print("┌\(border)┐")
+ print(header)
+ print("├\(border)┤")
+ print(colHeader)
+ print("├\(border)┤")
+
+ for (i, cam) in all.enumerated() {
+ let displayID = cam.uniqueID.count > idColW
+ ? String(cam.uniqueID.prefix(idColW - 1)) + "…"
+ : cam.uniqueID
+ let row = "│ "
+ + lpad("\(i + 1)", numColW) + " "
+ + lpad(cam.localizedName, nameColW) + " "
+ + lpad(cam.typeLabel, typeColW) + " "
+ + lpad(displayID, idColW) + " │"
+ print(row)
+ }
+
+ print("└\(border)┘")
+ print("\nUse: sim cam start --camera --camera-id \"\"")
+ }
+
+ /// Prints a JSON array of all discovered cameras to stdout.
+ static func printJSON() {
+ let all = allDevices()
+ let mapped = all.map { [
+ "uniqueID": $0.uniqueID,
+ "localizedName": $0.localizedName,
+ "typeLabel": $0.typeLabel
+ ] }
+ if let data = try? JSONSerialization.data(withJSONObject: mapped, options: .prettyPrinted),
+ let jsonString = String(data: data, encoding: .utf8) {
+ print(jsonString)
+ } else {
+ print("[]")
+ }
+ }
+}
diff --git a/MiniSimCam/Sources/FrameHost/CameraSource.swift b/MiniSimCam/Sources/FrameHost/CameraSource.swift
new file mode 100755
index 0000000..2b50c19
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/CameraSource.swift
@@ -0,0 +1,530 @@
+// CameraSource.swift
+// Captures live frames from the Mac's camera using AVFoundation.
+// Supports explicit camera selection by name/ID and hot-swap reconnect.
+
+import Foundation
+import AVFoundation
+import CoreVideo
+import CoreImage
+import CoreGraphics
+
+// MARK: - ScaleMode
+
+/// Controls how camera frames are scaled to match the target resolution.
+enum ScaleMode: String {
+ /// Crop to fill: scales proportionally, then crops equally from opposite edges.
+ case fill = "fill"
+ /// Fit with letterbox: scales uniformly to fit inside the target, pads remaining
+ /// area with black. Preserves the full camera frame.
+ case fit = "fit"
+}
+
+final class CameraSource: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
+
+ // MARK: - Configuration
+
+ private let writer: SharedFrameWriter
+ private let targetWidth: Int
+ private let targetHeight: Int
+ private let fps: Int
+ private let udid: String
+ private let statusPath: String
+ /// Optional explicit camera identifier (uniqueID or localizedName substring).
+ private let cameraID: String?
+ private let scaleMode: ScaleMode
+
+ // MARK: - Session state
+
+ private let session = AVCaptureSession()
+ private let captureQueue = DispatchQueue(label: "com.minisimcam.cameraCapture")
+ /// AVFoundation requires all session configuration and lifecycle operations
+ /// to be serialized on one queue.
+ private let sessionQueue = DispatchQueue(label: "com.minisimcam.cameraSession")
+ private let stateLock = NSLock()
+ private let statusLock = NSLock()
+
+ /// GPU-accelerated CoreImage context for fit-mode scaling.
+ private lazy var ciContext: CIContext = CIContext(options: [
+ .useSoftwareRenderer: false,
+ .cacheIntermediates: false
+ ])
+
+ /// The device currently in use; set after successful setup.
+ private var activeDevice: AVCaptureDevice?
+ /// Human-readable device name; used in status and logging.
+ private var activeCameraName: String = "mac-camera"
+ /// Device type label (Built-in / Continuity Camera / External).
+ private var activeCameraType: String = ""
+
+ // MARK: - Hot-swap state
+
+ private var isConnected: Bool = false
+ private var isStopped: Bool = false
+ private var reconnectAttempts: Int = 0
+ private let maxReconnectAttempts: Int = 5
+ private var lastDisconnectedAt: Date?
+ private var disconnectObserver: NSObjectProtocol?
+ private var connectObserver: NSObjectProtocol?
+
+ // MARK: - Status tracking
+
+ private let startedAt = Date()
+ private var lastStatusWrite = Date.distantPast
+ private let _timebaseInfo: mach_timebase_info = {
+ var info = mach_timebase_info(numer: 0, denom: 0)
+ mach_timebase_info(&info)
+ return info
+ }()
+
+ // MARK: - Init
+
+ init(writer: SharedFrameWriter,
+ width: Int,
+ height: Int,
+ fps: Int,
+ udid: String,
+ statusPath: String,
+ cameraID: String? = nil,
+ scaleMode: ScaleMode = .fill) {
+ self.writer = writer
+ self.targetWidth = width
+ self.targetHeight = height
+ self.fps = fps
+ self.udid = udid
+ self.statusPath = statusPath
+ self.cameraID = cameraID
+ self.scaleMode = scaleMode
+ super.init()
+ }
+
+ // MARK: - Public lifecycle
+
+ func start() throws {
+ var startError: Error?
+ sessionQueue.sync {
+ do {
+ let device = try resolveDevice()
+ try configureSession(with: device)
+ updateActiveDevice(device)
+
+ isStopped = false
+ isConnected = true
+ reconnectAttempts = 0
+
+ session.startRunning()
+ registerHotSwapObservers()
+ let metadata = activeCameraMetadata()
+ writeStatus(nowNs: clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW))
+ print("[CameraSource] started — device='\(metadata.name)' (\(metadata.type))")
+ } catch {
+ startError = error
+ }
+ }
+ if let startError { throw startError }
+ }
+
+ func stop() {
+ sessionQueue.sync {
+ isStopped = true
+ isConnected = false
+ unregisterHotSwapObservers()
+ session.stopRunning()
+ }
+ // Clear status
+ try? FileManager.default.removeItem(atPath: statusPath)
+ }
+
+ // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
+
+ func captureOutput(_ output: AVCaptureOutput,
+ didOutput sampleBuffer: CMSampleBuffer,
+ from connection: AVCaptureConnection) {
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
+
+ let nowNs = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
+
+ do {
+ switch scaleMode {
+ case .fill:
+ if CVPixelBufferGetWidth(pixelBuffer) == targetWidth,
+ CVPixelBufferGetHeight(pixelBuffer) == targetHeight {
+ try writer.publish(pixelBuffer: pixelBuffer, pts: nowNs)
+ } else if let scaled = scale(pixelBuffer, mode: .fill) {
+ try writer.publish(pixelBuffer: scaled, pts: nowNs)
+ } else {
+ throw CameraError.scaleFailed
+ }
+ case .fit:
+ // Letterbox path: scale to fit target, pad remainder with black.
+ if let scaled = scale(pixelBuffer, mode: .fit) {
+ try writer.publish(pixelBuffer: scaled, pts: nowNs)
+ } else {
+ throw CameraError.scaleFailed
+ }
+ }
+ writeStatus(nowNs: nowNs)
+ } catch {
+ print("[CameraSource] Error publishing frame: \(error)")
+ }
+ }
+
+ // MARK: - Private: fit scaling
+
+ /// Scales `src` to fit within targetWidth × targetHeight while preserving
+ /// aspect ratio. The remaining area is filled with black (letterbox / pillarbox).
+ private func scale(_ src: CVPixelBuffer, mode: ScaleMode) -> CVPixelBuffer? {
+ let srcW = CVPixelBufferGetWidth(src)
+ let srcH = CVPixelBufferGetHeight(src)
+
+ // Compute uniform scale and centering offset. Fit leaves letterbox
+ // space; fill overflows evenly and is cropped by the output bounds.
+ let scaleX = CGFloat(targetWidth) / CGFloat(srcW)
+ let scaleY = CGFloat(targetHeight) / CGFloat(srcH)
+ let scale = mode == .fit ? min(scaleX, scaleY) : max(scaleX, scaleY)
+
+ let scaledW = Int((CGFloat(srcW) * scale).rounded())
+ let scaledH = Int((CGFloat(srcH) * scale).rounded())
+ let padX = (targetWidth - scaledW) / 2
+ let padY = (targetHeight - scaledH) / 2
+
+ // Allocate a black output CVPixelBuffer at target dimensions.
+ let attrs: [CFString: Any] = [
+ kCVPixelBufferCGImageCompatibilityKey: true,
+ kCVPixelBufferCGBitmapContextCompatibilityKey: true,
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as [CFString: Any],
+ ]
+ var outBuf: CVPixelBuffer?
+ guard CVPixelBufferCreate(
+ kCFAllocatorDefault,
+ targetWidth, targetHeight,
+ kCVPixelFormatType_32BGRA,
+ attrs as CFDictionary,
+ &outBuf
+ ) == kCVReturnSuccess, let out = outBuf else { return nil }
+
+ // Clear to black.
+ CVPixelBufferLockBaseAddress(out, [])
+ if let base = CVPixelBufferGetBaseAddress(out) {
+ memset(base, 0, CVPixelBufferGetDataSize(out))
+ }
+ CVPixelBufferUnlockBaseAddress(out, [])
+
+ // Build scaled + translated CIImage and render into the output buffer.
+ // CoreImage uses bottom-left origin; the translation puts the image
+ // in the vertical center of the output.
+ let ciSrc = CIImage(cvPixelBuffer: src)
+ let scaled = ciSrc
+ .transformed(by: CGAffineTransform(scaleX: scale, y: scale))
+ .transformed(by: CGAffineTransform(translationX: CGFloat(padX), y: CGFloat(padY)))
+
+ ciContext.render(
+ scaled,
+ to: out,
+ bounds: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight),
+ colorSpace: CGColorSpaceCreateDeviceRGB()
+ )
+
+ return out
+ }
+
+ // MARK: - Private: session setup
+
+ private func resolveDevice() throws -> AVCaptureDevice {
+ if let id = cameraID {
+ guard let device = try CameraDiscovery.findDevice(byNameOrID: id) else {
+ throw CameraError.deviceNotFound(id)
+ }
+ return device
+ }
+ guard let device = AVCaptureDevice.default(for: .video) else {
+ throw CameraError.noCameraFound
+ }
+ return device
+ }
+
+ private func configureSession(with device: AVCaptureDevice) throws {
+ // Remove existing inputs/outputs for hot-swap restart.
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+ session.inputs.forEach { session.removeInput($0) }
+ session.outputs.forEach { session.removeOutput($0) }
+
+ switch scaleMode {
+ case .fill:
+ // Use a native/high-quality source and perform aspect-fill scaling
+ // below. A fixed 720p preset cannot satisfy arbitrary targets.
+ if session.canSetSessionPreset(.photo) {
+ session.sessionPreset = .photo
+ } else if session.canSetSessionPreset(.high) {
+ session.sessionPreset = .high
+ } else if session.canSetSessionPreset(.hd1280x720) {
+ session.sessionPreset = .hd1280x720
+ }
+ case .fit:
+ // .photo requests the camera's highest-quality native format on macOS,
+ // avoiding the 16:9 center-crop that hd presets apply.
+ // We scale to the target dimensions ourselves in the callback.
+ if session.canSetSessionPreset(.photo) {
+ session.sessionPreset = .photo
+ } else {
+ session.sessionPreset = .high
+ }
+ }
+
+ let input = try AVCaptureDeviceInput(device: device)
+ guard session.canAddInput(input) else { throw CameraError.cannotAddInput }
+ session.addInput(input)
+
+ let videoOutput = AVCaptureVideoDataOutput()
+ videoOutput.alwaysDiscardsLateVideoFrames = true
+ videoOutput.videoSettings = [
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
+ ]
+ videoOutput.setSampleBufferDelegate(self, queue: captureQueue)
+ guard session.canAddOutput(videoOutput) else { throw CameraError.cannotAddOutput }
+ session.addOutput(videoOutput)
+
+ // Attempt to set frame rate.
+ do {
+ try device.lockForConfiguration()
+ device.activeVideoMinFrameDuration = CMTimeMake(value: 1, timescale: Int32(fps))
+ device.activeVideoMaxFrameDuration = CMTimeMake(value: 1, timescale: Int32(fps))
+ device.unlockForConfiguration()
+ } catch {
+ print("[CameraSource] Warning: could not set framerate to \(fps): \(error)")
+ }
+
+ }
+
+ // MARK: - Private: hot-swap
+
+ private func registerHotSwapObservers() {
+ let nc = NotificationCenter.default
+
+ disconnectObserver = nc.addObserver(
+ forName: AVCaptureDevice.wasDisconnectedNotification,
+ object: nil,
+ queue: nil
+ ) { [weak self] notification in
+ self?.sessionQueue.async { self?.handleDisconnect(notification: notification) }
+ }
+
+ connectObserver = nc.addObserver(
+ forName: AVCaptureDevice.wasConnectedNotification,
+ object: nil,
+ queue: nil
+ ) { [weak self] notification in
+ self?.sessionQueue.async { self?.handleConnect(notification: notification) }
+ }
+ }
+
+ private func unregisterHotSwapObservers() {
+ let nc = NotificationCenter.default
+ if let obs = disconnectObserver { nc.removeObserver(obs) }
+ if let obs = connectObserver { nc.removeObserver(obs) }
+ disconnectObserver = nil
+ connectObserver = nil
+ }
+
+ private func handleDisconnect(notification: Notification) {
+ guard let device = notification.object as? AVCaptureDevice else { return }
+
+ // Match by localizedName — Continuity Camera gets a new uniqueID on every
+ // connection, so uniqueID is not a stable identifier.
+ let metadata = activeCameraMetadata()
+ let isOurDevice = device.uniqueID == activeDevice?.uniqueID
+ || device.localizedName == metadata.name
+ guard isOurDevice, isConnected, !isStopped else { return }
+
+ print("[CameraSource] Camera disconnected: '\(metadata.name)' — waiting for reconnect.")
+ isConnected = false
+ lastDisconnectedAt = Date()
+ reconnectAttempts = 0 // Reset so a fresh reconnect gets the full budget.
+ session.stopRunning()
+ writeStatusDisconnected()
+ }
+
+ private func handleConnect(notification: Notification) {
+ guard !isConnected, !isStopped else { return }
+ guard reconnectAttempts < maxReconnectAttempts else {
+ print("[CameraSource] Max reconnect attempts (\(maxReconnectAttempts)) reached. Run 'sim cam start --camera' to try again.")
+ return
+ }
+
+ guard let candidate = notification.object as? AVCaptureDevice else { return }
+ // Only consider video devices.
+ guard candidate.hasMediaType(.video) else { return }
+
+ // Determine if this is the device we want to reconnect to.
+ // Primary match: the explicit --camera-id query the user passed.
+ // Fallback match: localizedName of the device we were running before.
+ // We intentionally do NOT match by uniqueID because Continuity Camera
+ // is reassigned a new uniqueID on each connection.
+ let isMatch: Bool
+ if let id = cameraID {
+ let nameLower = id.lowercased()
+ isMatch = candidate.uniqueID == id
+ || candidate.localizedName.lowercased().contains(nameLower)
+ } else {
+ // No explicit camera ID — reconnect if the device name matches what we had.
+ isMatch = candidate.localizedName == activeCameraMetadata().name
+ }
+
+ guard isMatch else { return }
+
+ // Let the OS settle before reopening. Subsequent failures schedule a
+ // bounded retry that rediscovers the current device.
+ scheduleReconnect(candidate: candidate, delay: 0.8)
+ }
+
+ private func scheduleReconnect(candidate: AVCaptureDevice?, delay: TimeInterval) {
+ guard !isConnected, !isStopped, reconnectAttempts < maxReconnectAttempts else { return }
+ sessionQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
+ self?.attemptReconnect(candidate: candidate)
+ }
+ }
+
+ private func attemptReconnect(candidate: AVCaptureDevice?) {
+ guard !isConnected, !isStopped, reconnectAttempts < maxReconnectAttempts else { return }
+ reconnectAttempts += 1
+
+ do {
+ let device = try candidate ?? reconnectCandidate()
+ print("[CameraSource] Reconnecting to '\(device.localizedName)' (attempt \(reconnectAttempts)/\(maxReconnectAttempts))…")
+ try configureSession(with: device)
+ updateActiveDevice(device)
+ session.startRunning()
+ isConnected = true
+ reconnectAttempts = 0
+ lastDisconnectedAt = nil
+ print("[CameraSource] Reconnected successfully to '\(activeCameraMetadata().name)'.")
+ } catch {
+ print("[CameraSource] Reconnect failed: \(error)")
+ let backoff = min(4.0, Double(reconnectAttempts))
+ scheduleReconnect(candidate: nil, delay: backoff)
+ }
+ }
+
+ private func reconnectCandidate() throws -> AVCaptureDevice {
+ if let id = cameraID {
+ guard let device = try CameraDiscovery.findDevice(byNameOrID: id) else {
+ throw CameraError.deviceNotFound(id)
+ }
+ return device
+ }
+ let name = activeCameraMetadata().name
+ guard let device = CameraDiscovery.allDevices().first(where: { $0.localizedName == name })?.device else {
+ throw CameraError.noCameraFound
+ }
+ return device
+ }
+
+ // MARK: - Status
+
+ private func updateActiveDevice(_ device: AVCaptureDevice) {
+ let type = CameraInfo(
+ uniqueID: device.uniqueID,
+ localizedName: device.localizedName,
+ deviceType: device.deviceType,
+ device: device
+ ).typeLabel
+ stateLock.lock()
+ activeDevice = device
+ activeCameraName = device.localizedName
+ activeCameraType = type
+ stateLock.unlock()
+ }
+
+ private func activeCameraMetadata() -> (name: String, type: String) {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+ return (activeCameraName, activeCameraType)
+ }
+
+ private func writeStatus(nowNs: UInt64) {
+ statusLock.lock()
+ defer { statusLock.unlock() }
+ let now = Date()
+ guard now.timeIntervalSince(lastStatusWrite) >= 1.0 else { return }
+ lastStatusWrite = now
+
+ let hdrAgeNs = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - nowNs
+ let ageMs = Double(hdrAgeNs) * Double(_timebaseInfo.numer) / Double(_timebaseInfo.denom) / 1_000_000.0
+
+ let metadata = activeCameraMetadata()
+ let isoFormatter = ISO8601DateFormatter()
+ let status = """
+ {
+ "udid": "\(udid)",
+ "source": "\(metadata.name)",
+ "cameraName": "\(metadata.name)",
+ "cameraType": "\(metadata.type)",
+ "width": \(targetWidth),
+ "height": \(targetHeight),
+ "fps": \(fps),
+ "framesProduced": \(writer.framesProduced),
+ "hostPID": \(ProcessInfo.processInfo.processIdentifier),
+ "startedAt": "\(isoFormatter.string(from: startedAt))",
+ "lastFrameAgeMs": \(ageMs),
+ "running": true
+ }
+ """
+ do {
+ try status.write(toFile: statusPath, atomically: true, encoding: .utf8)
+ } catch {
+ print("[CameraSource] Warning: Failed to write status file: \(error)")
+ }
+ }
+
+ private func writeStatusDisconnected() {
+ statusLock.lock()
+ defer { statusLock.unlock() }
+ let metadata = activeCameraMetadata()
+ let isoFormatter = ISO8601DateFormatter()
+ let disconnectedAt = lastDisconnectedAt.map { isoFormatter.string(from: $0) } ?? ""
+ let status = """
+ {
+ "udid": "\(udid)",
+ "source": "disconnected",
+ "cameraName": "\(metadata.name)",
+ "cameraType": "\(metadata.type)",
+ "width": \(targetWidth),
+ "height": \(targetHeight),
+ "fps": \(fps),
+ "framesProduced": \(writer.framesProduced),
+ "hostPID": \(ProcessInfo.processInfo.processIdentifier),
+ "startedAt": "\(isoFormatter.string(from: startedAt))",
+ "lastFrameAgeMs": 0,
+ "lastDisconnectedAt": "\(disconnectedAt)",
+ "running": false
+ }
+ """
+ do {
+ try status.write(toFile: statusPath, atomically: true, encoding: .utf8)
+ } catch {
+ print("[CameraSource] Warning: Failed to write disconnected status: \(error)")
+ }
+ }
+}
+
+// MARK: - Errors
+
+enum CameraError: Error, CustomStringConvertible {
+ case noCameraFound
+ case cannotAddInput
+ case cannotAddOutput
+ case deviceNotFound(String)
+ case ambiguousDevice(String)
+ case scaleFailed
+
+ var description: String {
+ switch self {
+ case .noCameraFound: return "No video capture device found"
+ case .cannotAddInput: return "Could not add video input to capture session"
+ case .cannotAddOutput: return "Could not add video output to capture session"
+ case .deviceNotFound(let id): return "No camera found matching '\(id)' — run 'sim cam list' to see available devices"
+ case .ambiguousDevice(let m): return m
+ case .scaleFailed: return "Could not scale camera frame to the target resolution"
+ }
+ }
+}
diff --git a/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h b/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h
new file mode 100755
index 0000000..1e0f864
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h
@@ -0,0 +1,6 @@
+// FrameHost-Bridging-Header.h
+// Imports the shared C protocol header into Swift.
+
+#include "MiniCamProtocol.h"
+#include "MiniCamConstants.h"
+#include
diff --git a/MiniSimCam/Sources/FrameHost/FrameLoop.swift b/MiniSimCam/Sources/FrameHost/FrameLoop.swift
new file mode 100755
index 0000000..26afb16
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/FrameLoop.swift
@@ -0,0 +1,138 @@
+// FrameLoop.swift
+// Drives frame publishing at a configured FPS using a DispatchSourceTimer.
+// Also writes a status JSON file that the Go CLI reads for `sim cam status`.
+
+import Foundation
+
+struct FrameLoopStatus: Codable {
+ var udid: String
+ var source: String
+ var width: Int
+ var height: Int
+ var fps: Int
+ var framesProduced: UInt64
+ var hostPID: Int32
+ var startedAt: String // ISO-8601
+ var lastFrameAgeMs: Double // milliseconds since last publish
+ var running: Bool
+}
+
+// MARK: - Monotonic clock
+
+/// Cached mach timebase info — initialised once, not on every call.
+private let _timebaseInfo: mach_timebase_info_data_t = {
+ var info = mach_timebase_info_data_t()
+ mach_timebase_info(&info)
+ return info
+}()
+
+/// Returns a monotonic nanosecond timestamp anchored to mach_absolute_time.
+func currentMonotonicNs() -> UInt64 {
+ let ticks = mach_absolute_time()
+ return ticks * UInt64(_timebaseInfo.numer) / UInt64(_timebaseInfo.denom)
+}
+
+final class FrameLoop {
+
+ // MARK: - State
+
+ private let writer: SharedFrameWriter
+ private let statusPath: String
+ private let frame: BGRAFrame
+ private let fps: Int
+ private let udid: String
+ private let sourceName: String
+
+ private var timer: DispatchSourceTimer?
+ private var startDate: Date = .now
+ private var lastPublishNs: UInt64 = 0
+ private var framesProducedLocal: UInt64 = 0
+ private let queue = DispatchQueue(label: "com.minisimcam.frameloop", qos: .userInteractive)
+
+ // Status throttle: write at most once per second.
+ private var lastStatusWriteNs: UInt64 = 0
+ private let statusWriteIntervalNs: UInt64 = 1_000_000_000
+
+ // MARK: - Init
+
+ init(
+ writer: SharedFrameWriter,
+ frame: BGRAFrame,
+ fps: Int,
+ udid: String,
+ sourceName: String,
+ statusPath: String
+ ) {
+ self.writer = writer
+ self.frame = frame
+ self.fps = fps
+ self.udid = udid
+ self.sourceName = sourceName
+ self.statusPath = statusPath
+ }
+
+ // MARK: - Lifecycle
+
+ func start() {
+ startDate = .now
+ let intervalNs = 1_000_000_000 / UInt64(fps)
+ let leewayNs = intervalNs / 10 // 10% leeway
+
+ let src = DispatchSource.makeTimerSource(flags: .strict, queue: queue)
+ src.schedule(deadline: .now(), repeating: .nanoseconds(Int(intervalNs)),
+ leeway: .nanoseconds(Int(leewayNs)))
+ src.setEventHandler { [weak self] in self?.tick() }
+ src.resume()
+ timer = src
+ }
+
+ func stop() {
+ timer?.cancel()
+ timer = nil
+ writeStatus(running: false, force: true)
+ }
+
+ // MARK: - Private
+
+ private func tick() {
+ let pts = currentMonotonicNs()
+ do {
+ try writer.publish(frame: frame, pts: pts)
+ lastPublishNs = pts
+ framesProducedLocal &+= 1
+ } catch {
+ fputs("[FrameLoop] publish error: \(error)\n", stderr)
+ }
+ // Throttle: write status at most once per second, not every frame.
+ writeStatus(running: true, force: false)
+ }
+
+ /// Write status JSON, subject to throttle unless `force` is true.
+ private func writeStatus(running: Bool, force: Bool) {
+ let now = currentMonotonicNs()
+ guard force || (now &- lastStatusWriteNs) >= statusWriteIntervalNs else { return }
+ lastStatusWriteNs = now
+
+ let ageMs: Double = lastPublishNs == 0 ? 0
+ : Double(now &- lastPublishNs) / 1_000_000.0
+
+ let status = FrameLoopStatus(
+ udid: udid,
+ source: sourceName,
+ width: frame.width,
+ height: frame.height,
+ fps: fps,
+ framesProduced: framesProducedLocal,
+ hostPID: ProcessInfo.processInfo.processIdentifier,
+ startedAt: ISO8601DateFormatter().string(from: startDate),
+ lastFrameAgeMs: ageMs,
+ running: running
+ )
+
+ guard let data = try? JSONEncoder().encode(status) else { return }
+ // Single atomic write — no need for a second replaceItem on top.
+ try? data.write(to: URL(fileURLWithPath: statusPath), options: .atomic)
+ }
+}
+
+// currentMonotonicNs() is defined above, before FrameLoop, using a cached timebase.
diff --git a/MiniSimCam/Sources/FrameHost/ImageSource.swift b/MiniSimCam/Sources/FrameHost/ImageSource.swift
new file mode 100755
index 0000000..42687d6
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/ImageSource.swift
@@ -0,0 +1,129 @@
+// ImageSource.swift
+// Loads a PNG or JPEG and produces raw BGRA frames aligned to MSC_ROW_ALIGNMENT.
+
+import CoreGraphics
+import ImageIO
+import Foundation
+
+/// A decoded, BGRA-converted image ready for shared-memory publishing.
+struct BGRAFrame {
+ let width: Int
+ let height: Int
+ let bytesPerRow: Int
+ let data: Data // Owned copy; safe to share across threads after init.
+}
+
+struct ImageSource {
+
+ // MARK: - Public
+
+ /// Loads an image file and converts it to a BGRA frame at the given dimensions.
+ /// Throws `ImageSourceError` on failure.
+ static func load(url: URL, targetWidth: Int, targetHeight: Int) throws -> BGRAFrame {
+ guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
+ let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
+ throw ImageSourceError.cannotLoadImage(url)
+ }
+ return try render(cgImage: cgImage, width: targetWidth, height: targetHeight)
+ }
+
+ /// Generates a synthetic color-bar test card at the given dimensions.
+ static func colorBars(width: Int, height: Int) throws -> BGRAFrame {
+ let bytesPerRow = alignedBytesPerRow(width)
+ let totalBytes = bytesPerRow * height
+
+ var pixels = [UInt8](repeating: 0, count: totalBytes)
+
+ // SMPTE-style color bars: 7 vertical stripes in BGRA order.
+ let barColors: [(b: UInt8, g: UInt8, r: UInt8)] = [
+ (192, 192, 192), // White
+ (192, 192, 0), // Yellow
+ (0, 192, 192), // Cyan
+ (0, 192, 0), // Green
+ (192, 0, 192), // Magenta
+ (192, 0, 0), // Red
+ (0, 0, 192), // Blue
+ ]
+ let barWidth = width / barColors.count
+
+ for y in 0.. BGRAFrame {
+ let bytesPerRow = alignedBytesPerRow(width)
+ let totalBytes = bytesPerRow * height
+
+ guard totalBytes > 0 else {
+ throw ImageSourceError.zeroDimension
+ }
+
+ // BGRA with premultiplied alpha matches kCVPixelFormatType_32BGRA.
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue
+ | CGImageAlphaInfo.premultipliedFirst.rawValue
+
+ var pixelBuffer = [UInt8](repeating: 0, count: totalBytes)
+ try pixelBuffer.withUnsafeMutableBytes { rawBuf in
+ guard let ctx = CGContext(
+ data: rawBuf.baseAddress,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: bitmapInfo
+ ) else {
+ throw ImageSourceError.cannotCreateContext
+ }
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
+ }
+
+ return BGRAFrame(
+ width: width,
+ height: height,
+ bytesPerRow: bytesPerRow,
+ data: Data(pixelBuffer)
+ )
+ }
+
+ /// Round `width * 4` up to the nearest multiple of `MSC_ROW_ALIGNMENT` (64).
+ static func alignedBytesPerRow(_ width: Int) -> Int {
+ let raw = width * 4
+ return (raw + 63) & ~63
+ }
+}
+
+// MARK: - Errors
+
+enum ImageSourceError: Error, CustomStringConvertible {
+ case cannotLoadImage(URL)
+ case cannotCreateContext
+ case zeroDimension
+
+ var description: String {
+ switch self {
+ case .cannotLoadImage(let url): return "Cannot load image at \(url.path)"
+ case .cannotCreateContext: return "Cannot create BGRA CGContext"
+ case .zeroDimension: return "Width or height is zero"
+ }
+ }
+}
diff --git a/MiniSimCam/Sources/FrameHost/Info.plist b/MiniSimCam/Sources/FrameHost/Info.plist
new file mode 100755
index 0000000..9b96054
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/Info.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ NSCameraUsageDescription
+ MiniSimCam needs access to your camera to inject live video into the iOS Simulator.
+ NSCameraUseContinuityCameraDeviceType
+
+
+
diff --git a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift
new file mode 100755
index 0000000..a863946
--- /dev/null
+++ b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift
@@ -0,0 +1,234 @@
+// SharedFrameWriter.swift
+// Creates and manages the shared-memory triple-buffer that the injector reads.
+
+import Foundation
+import Darwin
+import CoreVideo
+import MiniSimCamShared
+
+/// Writes BGRA frames into a memory-mapped triple-buffer file.
+///
+/// Layout (offsets):
+/// [0 ..< 128] MSCStreamHeader
+/// [128 ..< 128+N] Frame buffer 0
+/// [128+N ..< 128+2N] Frame buffer 1
+/// [128+2N ..< 128+3N] Frame buffer 2
+///
+/// where N = header.bufferSize.
+final class SharedFrameWriter {
+
+ // MARK: - State
+
+ private let path: String
+ private var mapping: UnsafeMutableRawPointer?
+ private var mappingSize: Int = 0
+ private var frameSize: Int = 0
+
+ // MARK: - Init / Teardown
+
+ init(path: String) {
+ self.path = path
+ }
+
+ deinit {
+ close()
+ }
+
+ func open(width: Int, height: Int) throws {
+ let bytesPerRow = ImageSource.alignedBytesPerRow(width)
+ let bufSize = bytesPerRow * height
+ let totalSize = 128 + 3 * bufSize // header (128 B) + 3 × frame
+
+ var fd = Darwin.open(path, O_RDWR)
+ var needsInit = false
+
+ if fd != -1 {
+ var statBuf = stat()
+ if fstat(fd, &statBuf) == 0 && statBuf.st_size == totalSize {
+ // File exists and is the exact size we need. Reuse it!
+ } else {
+ // Size mismatch. Close and recreate.
+ Darwin.close(fd)
+ fd = -1
+ }
+ }
+
+ if fd == -1 {
+ // Recreate file
+ if FileManager.default.fileExists(atPath: path) {
+ try? FileManager.default.removeItem(atPath: path)
+ }
+ guard FileManager.default.createFile(atPath: path, contents: nil) else {
+ throw WriterError.cannotCreateFile(path)
+ }
+ fd = Darwin.open(path, O_RDWR)
+ guard fd != -1 else { throw WriterError.openFailed(path, errno: errno) }
+
+ guard ftruncate(fd, off_t(totalSize)) == 0 else {
+ Darwin.close(fd)
+ throw WriterError.truncateFailed(errno: errno)
+ }
+ needsInit = true
+ }
+
+ defer { Darwin.close(fd) }
+
+ // mmap read-write shared
+ let ptr = mmap(nil, totalSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
+ guard let ptr, ptr != MAP_FAILED else {
+ throw WriterError.mmapFailed(errno: errno)
+ }
+
+ mapping = ptr
+ mappingSize = totalSize
+ frameSize = bufSize
+
+ let hdr = ptr.bindMemory(to: MSCStreamHeader.self, capacity: 1)
+ if needsInit {
+ hdr.pointee.magic = MSC_MAGIC
+ hdr.pointee.version = MSC_VERSION
+ hdr.pointee.width = UInt32(width)
+ hdr.pointee.height = UInt32(height)
+ hdr.pointee.bytesPerRow = UInt32(bytesPerRow)
+ hdr.pointee.pixelFormat = MSC_PIXEL_FORMAT
+ hdr.pointee.bufferCount = MSC_BUFFER_COUNT
+ hdr.pointee.bufferSize = UInt32(bufSize)
+ // sequence, publishedIndex, framesProduced remain 0 from ftruncate.
+ } else {
+ // If we reused the file, the previous FrameHost might have died in the middle of a write.
+ // If the sequence lock is odd (write in progress), force it to even.
+ let seq = msc_seq_load_acquire(ptr)
+ if seq % 2 != 0 {
+ msc_seq_store_release(ptr, seq &+ 1)
+ }
+ }
+ }
+
+ /// Closes the mapping and removes the shared file.
+ func close() {
+ if let mapping {
+ munmap(mapping, mappingSize)
+ }
+ mapping = nil
+ try? FileManager.default.removeItem(atPath: path)
+ }
+
+ // MARK: - Publishing
+
+ /// Publishes one BGRA frame using the sequence-lock algorithm.
+ /// - Parameter frame: The BGRA frame to publish.
+ /// - Parameter pts: Presentation timestamp in nanoseconds (monotonic).
+ func publish(frame: BGRAFrame, pts: UInt64) throws {
+ guard let base = mapping else {
+ throw WriterError.notOpen
+ }
+
+ // Pick a buffer index that is NOT currently the published one.
+ let currentPublished = Int(msc_idx_load_acquire(base))
+ let writeIndex = (currentPublished + 1) % 3
+
+ // --- Sequence lock: begin write (seq → odd) ---
+ // Canonical pattern: read seq, store seq+1 (odd = write in progress).
+ // On completion store seq+2 (even, advanced). Never re-read in between.
+ let seqBefore = msc_seq_load_acquire(base)
+ msc_seq_store_release(base, seqBefore &+ 1)
+
+ // --- Copy frame data ---
+ let dest = base.advanced(by: 128 + writeIndex * frameSize)
+ _ = frame.data.withUnsafeBytes { src in
+ memcpy(dest, src.baseAddress!, frame.data.count)
+ }
+
+ // --- Update presentation timestamp (plain write, protected by seq lock) ---
+ let hdr = base.bindMemory(to: MSCStreamHeader.self, capacity: 1)
+ hdr.pointee.presentationTimeNs = pts
+
+ // --- Publish buffer index ---
+ msc_idx_store_relaxed(base, UInt32(writeIndex))
+
+ // --- Sequence lock: end write (seq → even, advanced by 2 total) ---
+ msc_seq_store_release(base, seqBefore &+ 2)
+
+ // --- Increment frame counter ---
+ _ = msc_fp_fetch_add(base, 1)
+ }
+
+ /// Publishes one BGRA frame directly from a CVPixelBuffer (zero-copy).
+ /// - Parameter pixelBuffer: A CVPixelBuffer (must be kCVPixelFormatType_32BGRA).
+ /// - Parameter pts: Presentation timestamp in nanoseconds.
+ func publish(pixelBuffer: CVPixelBuffer, pts: UInt64) throws {
+ guard let base = mapping else {
+ throw WriterError.notOpen
+ }
+
+ // Lock the base address for reading
+ CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
+
+ guard let srcBaseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
+ return
+ }
+
+ let currentPublished = Int(msc_idx_load_acquire(base))
+ let writeIndex = (currentPublished + 1) % 3
+
+ let seqBefore = msc_seq_load_acquire(base)
+ msc_seq_store_release(base, seqBefore &+ 1)
+
+ // We must copy row-by-row to handle stride (bytesPerRow) differences
+ // between the camera's CVPixelBuffer and our shared memory.
+ let srcBPR = CVPixelBufferGetBytesPerRow(pixelBuffer)
+ let srcWidth = CVPixelBufferGetWidth(pixelBuffer)
+ let srcHeight = CVPixelBufferGetHeight(pixelBuffer)
+
+ let hdr = base.bindMemory(to: MSCStreamHeader.self, capacity: 1)
+ let dstWidth = Int(hdr.pointee.width)
+ let dstHeight = Int(hdr.pointee.height)
+ let dstBPR = Int(hdr.pointee.bytesPerRow)
+
+ let copyWidth = min(srcWidth, dstWidth)
+ let copyHeight = min(srcHeight, dstHeight)
+ let bytesToCopy = copyWidth * 4
+
+ let destBase = base.advanced(by: 128 + writeIndex * frameSize)
+
+ for row in 0.., --bars, or --camera.")
+ }
+ // --camera-id only makes sense with --camera.
+ if cameraID != nil && !camera {
+ throw ValidationError("--camera-id requires --camera.")
+ }
+ guard width > 0, height > 0 else { throw ValidationError("Width and height must be > 0.") }
+ let maxDimension = Int(MSC_MAX_DIMENSION)
+ guard width <= maxDimension, height <= maxDimension else {
+ throw ValidationError("Width and height must not exceed \(maxDimension).")
+ }
+ guard fps > 0, fps <= 120 else { throw ValidationError("FPS must be between 1 and 120.") }
+ guard !listCameras else { return }
+ guard udid.count >= 8 else { throw ValidationError("UDID looks invalid: \(udid)") }
+ }
+
+ // MARK: - Run
+
+ func run() throws {
+ // Handle --list-cameras first — no UDID, shared memory, or frame loop needed.
+ if listCameras {
+ if json {
+ CameraDiscovery.printJSON()
+ } else {
+ CameraDiscovery.printTable()
+ }
+ return
+ }
+
+ let shmPath = "/tmp/minisimcam.\(udid).frames"
+ let statusPath = "/tmp/minisimcam.\(udid).status"
+ let pidPath = "/tmp/minisimcam.\(udid).pid"
+
+ // Never let a previous crashed host satisfy the CLI's readiness check.
+ try? FileManager.default.removeItem(atPath: statusPath)
+
+ // Write PID file so `sim cam stop` can signal this process.
+ let pid = ProcessInfo.processInfo.processIdentifier
+ try String(pid).write(toFile: pidPath, atomically: false, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(atPath: pidPath) }
+
+ // Open shared memory.
+ let writer = SharedFrameWriter(path: shmPath)
+ try writer.open(width: width, height: height)
+ defer { writer.close() }
+
+ // Start the appropriate source.
+ let sourceName: String
+ let loop: FrameLoop?
+ let camSource: CameraSource?
+
+ if camera {
+ sourceName = cameraID ?? "mac-camera"
+ loop = nil
+ camSource = CameraSource(
+ writer: writer,
+ width: width,
+ height: height,
+ fps: fps,
+ udid: udid,
+ statusPath: statusPath,
+ cameraID: cameraID,
+ scaleMode: scaleMode
+ )
+ try camSource?.start()
+ } else {
+ let frame: BGRAFrame
+ if bars {
+ sourceName = "color-bars"
+ frame = try ImageSource.colorBars(width: width, height: height)
+ } else {
+ let url = URL(fileURLWithPath: image!)
+ sourceName = url.lastPathComponent
+ frame = try ImageSource.load(url: url, targetWidth: width, targetHeight: height)
+ }
+ camSource = nil
+ loop = FrameLoop(
+ writer: writer,
+ frame: frame,
+ fps: fps,
+ udid: udid,
+ sourceName: sourceName,
+ statusPath: statusPath
+ )
+ loop?.start()
+ }
+
+ print("[FrameHost] started — source=\(sourceName) \(width)×\(height) @ \(fps) fps")
+ print("[FrameHost] shared memory: \(shmPath)")
+ print("[FrameHost] status file: \(statusPath)")
+ print("[FrameHost] PID: \(pid)")
+
+ // Install signal handlers for clean shutdown.
+ signal(SIGTERM, SIG_IGN)
+ signal(SIGINT, SIG_IGN)
+
+ let sigTerm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
+ sigTerm.setEventHandler {
+ print("[FrameHost] received SIGTERM — shutting down.")
+ loop?.stop()
+ camSource?.stop()
+ Foundation.exit(0)
+ }
+ sigTerm.resume()
+
+ let sigInt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
+ sigInt.setEventHandler {
+ print("[FrameHost] received SIGINT — shutting down.")
+ loop?.stop()
+ camSource?.stop()
+ Foundation.exit(0)
+ }
+ sigInt.resume()
+
+ // Park the main thread.
+ dispatchMain()
+ }
+}
+
+// Top-level entry — required when file is named main.swift (cannot use @main).
+FrameHostCommand.main()
+
+// MARK: - ArgumentParser conformance
+
+extension ScaleMode: ExpressibleByArgument {
+ init?(argument: String) { self.init(rawValue: argument) }
+}
diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h
new file mode 100755
index 0000000..66bbf84
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h
@@ -0,0 +1,15 @@
+// CaptureHooks.h
+#pragma once
+#include "SharedFrameReader.hpp"
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void MSCInstallHooks(SharedFrameReader* reader, int32_t fps);
+void MSCUninstallHooks(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm
new file mode 100755
index 0000000..fdea7d6
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm
@@ -0,0 +1,663 @@
+// CaptureHooks.mm
+// AVFoundation method swizzles that intercept just enough for the
+// AVCaptureSession + AVCaptureVideoDataOutput path.
+//
+// Intercepted selectors:
+// -[AVCaptureSession startRunning]
+// -[AVCaptureSession stopRunning]
+// -[AVCaptureVideoDataOutput setSampleBufferDelegate:queue:]
+// +[AVCaptureDevice defaultDeviceWithMediaType:]
+// -[AVCaptureSession canAddInput:] (allow synthetic sessions)
+// -[AVCaptureSession addInput:] (no-op for sessions without hardware)
+
+#import
+#import
+#import
+#import
+#import
+#import "CaptureHooks.h"
+#import "SampleBufferFactory.h"
+#import "SharedFrameReader.hpp"
+
+@interface MSCFakeConnection : AVCaptureConnection
+@end
+@implementation MSCFakeConnection
+@end
+
+// ---------------------------------------------------------------------------
+// Delivery state (process-global, guarded by a lock)
+// ---------------------------------------------------------------------------
+
+static dispatch_queue_t gDeliveryQueue;
+static dispatch_source_t gDeliveryTimer;
+static SharedFrameReader* gReader;
+static MSCSampleBufferFactory* gFactory;
+static id gDelegate;
+static dispatch_queue_t gDelegateQueue;
+static BOOL gRunning = NO;
+// Tracks whether the app explicitly called startRunning on the AVCaptureSession.
+// Used to avoid auto-starting the session when the app already did so, which
+// would crash on Simulator by calling startRunning on an already-running session.
+static BOOL gAVSessionStarted = NO;
+static NSLock* gLock;
+static int32_t gFPS = 30;
+static NSHashTable *gPreviewLayers;
+
+// ---------------------------------------------------------------------------
+// Forward declarations
+// ---------------------------------------------------------------------------
+static void startDelivery(void);
+static void stopDelivery(void);
+static void deliverFrame(void);
+
+// ---------------------------------------------------------------------------
+// Swizzle helpers
+// ---------------------------------------------------------------------------
+
+static void swizzleInstance(Class cls, SEL orig, SEL repl) {
+ Method origMethod = class_getInstanceMethod(cls, orig);
+ Method replMethod = class_getInstanceMethod(cls, repl);
+ if (!origMethod || !replMethod) {
+ NSLog(@"[MiniCamInject] ⚠️ Cannot swizzle %@.%@", NSStringFromClass(cls), NSStringFromSelector(orig));
+ return;
+ }
+ method_exchangeImplementations(origMethod, replMethod);
+}
+
+static void swizzleClass(Class cls, SEL orig, SEL repl) {
+ Class meta = object_getClass((id)cls);
+ Method origMethod = class_getInstanceMethod(meta, orig);
+ Method replMethod = class_getInstanceMethod(meta, repl);
+ if (!origMethod || !replMethod) {
+ NSLog(@"[MiniCamInject] ⚠️ Cannot swizzle class method %@.%@", NSStringFromClass(cls), NSStringFromSelector(orig));
+ return;
+ }
+ method_exchangeImplementations(origMethod, replMethod);
+}
+
+// ---------------------------------------------------------------------------
+// AVCaptureSession category — swizzled methods
+// ---------------------------------------------------------------------------
+
+@implementation AVCaptureSession (MiniCamHook)
+
+- (void)msc_startRunning {
+ NSLog(@"[MiniCamInject] AVCaptureSession startRunning intercepted");
+ [gLock lock];
+ gAVSessionStarted = YES;
+ [gLock unlock];
+ [self msc_startRunning]; // call original (if any)
+ // gReader is initialised in EntryPoint — do not recreate here.
+ startDelivery();
+}
+
+- (void)msc_stopRunning {
+ [self msc_stopRunning];
+ stopDelivery();
+}
+
+- (BOOL)msc_canAddInput:(AVCaptureInput *)input {
+ return YES;
+}
+
+- (void)msc_addInput:(AVCaptureInput *)input {
+ NSLog(@"[MiniCamInject] AVCaptureSession addInput: silenced (no hardware)");
+}
+
+- (BOOL)msc_canAddOutput:(AVCaptureOutput *)output {
+ return YES;
+}
+
+- (void)msc_addOutput:(AVCaptureOutput *)output {
+ [self msc_addOutput:output];
+}
+
+- (BOOL)msc_isRunning {
+ [gLock lock];
+ BOOL running = gRunning;
+ [gLock unlock];
+ return running;
+}
+
+@end
+
+// ---------------------------------------------------------------------------
+// AVCaptureVideoDataOutput category — swizzled methods
+// ---------------------------------------------------------------------------
+
+static AVCaptureVideoDataOutput* gOutput;
+
+@implementation AVCaptureVideoDataOutput (MiniCamHook)
+
+- (void)msc_setSampleBufferDelegate:(id)delegate
+ queue:(dispatch_queue_t)queue {
+ [gLock lock];
+ gDelegate = delegate;
+ gDelegateQueue = queue ?: dispatch_get_main_queue();
+ gOutput = self;
+ [gLock unlock];
+
+ NSLog(@"[MiniCamInject] captured delegate=%@ queue=%@", delegate, queue);
+ // Also call original so AVFoundation internal state is consistent.
+ [self msc_setSampleBufferDelegate:delegate queue:queue];
+}
+
+@end
+
+// Fix for AVCapturePhotoOutput crash on iOS Simulator
+@interface AVCaptureOutput (MiniCamHookFix)
+@end
+@implementation AVCaptureOutput (MiniCamHookFix)
++ (NSArray *)availableVideoCodecTypesForSourceDevice:(id)arg1 sourceFormat:(id)arg2 outputDimensions:(CMVideoDimensions)arg3 fileType:(id)arg4 videoCodecTypesAllowList:(id)arg5 {
+ return @[]; // Return empty array to prevent crash
+}
+@end
+
+// ---------------------------------------------------------------------------
+// Fakes for AVCaptureDevice and AVCaptureDeviceInput
+// ---------------------------------------------------------------------------
+
+@interface MSCFakeCaptureDeviceFormat : NSObject
+@end
+@implementation MSCFakeCaptureDeviceFormat
+- (CMFormatDescriptionRef)formatDescription {
+ CMVideoFormatDescriptionRef formatDesc = NULL;
+ CMVideoFormatDescriptionCreate(kCFAllocatorDefault, kCVPixelFormatType_32BGRA, 1280, 720, NULL, &formatDesc);
+ return formatDesc;
+}
+@end
+
+@interface MSCFakeCaptureDevice : AVCaptureDevice
+@end
+@implementation MSCFakeCaptureDevice
+- (BOOL)hasMediaType:(AVMediaType)mediaType { return YES; }
+- (BOOL)supportsAVCaptureSessionPreset:(AVCaptureSessionPreset)preset { return YES; }
+- (BOOL)isFocusPointOfInterestSupported { return YES; }
+- (BOOL)isFocusModeSupported:(AVCaptureFocusMode)focusMode { return YES; }
+- (BOOL)isExposurePointOfInterestSupported { return YES; }
+- (BOOL)isExposureModeSupported:(AVCaptureExposureMode)exposureMode { return YES; }
+- (BOOL)lockForConfiguration:(NSError **)outError { return YES; }
+- (void)unlockForConfiguration {}
+- (void)setFocusPointOfInterest:(CGPoint)focusPointOfInterest {}
+- (void)setFocusMode:(AVCaptureFocusMode)focusMode {}
+- (void)setExposurePointOfInterest:(CGPoint)exposurePointOfInterest {}
+- (void)setExposureMode:(AVCaptureExposureMode)exposureMode {}
+
+// Basic device properties
+- (NSString *)uniqueID { return @"MSCFakeCamera_001"; }
+- (NSString *)localizedName { return @"MiniSimCam Fake Device"; }
+- (AVCaptureDevicePosition)position { return AVCaptureDevicePositionBack; }
+- (BOOL)isConnected { return YES; }
+- (BOOL)isSuspended { return NO; }
+
+// Active format mocking
+- (CMVideoDimensions)activeSensorLocation { return (CMVideoDimensions){1280, 720}; }
+- (void)setActiveVideoMinFrameDuration:(CMTime)activeVideoMinFrameDuration {}
+- (CMTime)activeVideoMinFrameDuration { return CMTimeMake(1, 30); }
+- (void)setActiveVideoMaxFrameDuration:(CMTime)activeVideoMaxFrameDuration {}
+- (CMTime)activeVideoMaxFrameDuration { return CMTimeMake(1, 30); }
+
+// Device type — needed so discovery-session filters (e.g. bestPossibleBackCamera)
+// can match via their preferred-type loop rather than falling through to the last-resort .first.
+- (AVCaptureDeviceType)deviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; }
+
+- (id)activeFormat {
+ static MSCFakeCaptureDeviceFormat *fakeFormat = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeFormat = (MSCFakeCaptureDeviceFormat *)class_createInstance(NSClassFromString(@"MSCFakeCaptureDeviceFormat"), 0);
+ });
+ return fakeFormat;
+}
+@end
+
+@interface MSCFakeCaptureInputPort : AVCaptureInputPort
+@end
+@implementation MSCFakeCaptureInputPort
+- (AVMediaType)mediaType { return AVMediaTypeVideo; }
+- (CMFormatDescriptionRef)formatDescription {
+ // AVCaptureInputPort.formatDescription is declared as CMFormatDescriptionRef,
+ // not id — return a real CF type to silence -Wmismatched-return-types.
+ static CMVideoFormatDescriptionRef sFormatDesc = NULL;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ CMVideoFormatDescriptionCreate(
+ kCFAllocatorDefault,
+ kCVPixelFormatType_32BGRA,
+ 1280, 720,
+ NULL,
+ &sFormatDesc
+ );
+ });
+ return sFormatDesc;
+}
+- (BOOL)isEnabled { return YES; }
+@end
+
+@interface MSCFakeCaptureInput : AVCaptureDeviceInput
+@end
+@implementation MSCFakeCaptureInput
+- (AVCaptureDevice *)device {
+ static MSCFakeCaptureDevice *fakeDevice = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeDevice = (MSCFakeCaptureDevice *)class_createInstance(NSClassFromString(@"MSCFakeCaptureDevice"), 0);
+ });
+ return fakeDevice;
+}
+- (NSArray *)ports {
+ static MSCFakeCaptureInputPort *fakePort = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakePort = (MSCFakeCaptureInputPort *)class_createInstance(NSClassFromString(@"MSCFakeCaptureInputPort"), 0);
+ });
+ return @[fakePort];
+}
+@end
+
+// ---------------------------------------------------------------------------
+// AVCaptureDevice class-method swizzle
+// ---------------------------------------------------------------------------
+
+@implementation AVCaptureDevice (MiniCamHook)
+
++ (nullable AVCaptureDevice *)msc_defaultDeviceWithMediaType:(AVMediaType)mediaType {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ static MSCFakeCaptureDevice *fakeDevice = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeDevice = (MSCFakeCaptureDevice *)class_createInstance(NSClassFromString(@"MSCFakeCaptureDevice"), 0);
+ });
+ return fakeDevice;
+ }
+ return [self msc_defaultDeviceWithMediaType:mediaType];
+}
+
++ (nullable AVCaptureDevice *)msc_defaultDeviceWithDeviceType:(AVCaptureDeviceType)deviceType mediaType:(nullable AVMediaType)mediaType position:(AVCaptureDevicePosition)position {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ static MSCFakeCaptureDevice *fakeDevice = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeDevice = (MSCFakeCaptureDevice *)class_createInstance(NSClassFromString(@"MSCFakeCaptureDevice"), 0);
+ });
+ return fakeDevice;
+ }
+ return [self msc_defaultDeviceWithDeviceType:deviceType mediaType:mediaType position:position];
+}
+
++ (AVAuthorizationStatus)msc_authorizationStatusForMediaType:(AVMediaType)mediaType {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ return AVAuthorizationStatusAuthorized;
+ }
+ return [self msc_authorizationStatusForMediaType:mediaType];
+}
+
++ (void)msc_requestAccessForMediaType:(AVMediaType)mediaType completionHandler:(void (^)(BOOL granted))handler {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ if (handler) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ handler(YES);
+ });
+ }
+ return;
+ }
+ [self msc_requestAccessForMediaType:mediaType completionHandler:handler];
+}
+
+@end
+
+// ---------------------------------------------------------------------------
+// AVCaptureDeviceDiscoverySession swizzle
+// ---------------------------------------------------------------------------
+
+@implementation AVCaptureDeviceDiscoverySession (MiniCamHook)
+
+- (NSArray *)msc_devices {
+ static MSCFakeCaptureDevice *fakeDevice = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeDevice = (MSCFakeCaptureDevice *)class_createInstance(NSClassFromString(@"MSCFakeCaptureDevice"), 0);
+ });
+ return @[fakeDevice];
+}
+
+@end
+
+// ---------------------------------------------------------------------------
+// AVCaptureDeviceInput swizzle
+// ---------------------------------------------------------------------------
+
+@implementation AVCaptureDeviceInput (MiniCamHook)
+
++ (instancetype)msc_deviceInputWithDevice:(AVCaptureDevice *)device error:(NSError **)outError {
+ if ([device isKindOfClass:NSClassFromString(@"MSCFakeCaptureDevice")]) {
+ static MSCFakeCaptureInput *fakeInput = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeInput = (MSCFakeCaptureInput *)class_createInstance(NSClassFromString(@"MSCFakeCaptureInput"), 0);
+ });
+ return fakeInput;
+ }
+ return [self msc_deviceInputWithDevice:device error:outError];
+}
+
+- (instancetype)init_mscWithDevice:(AVCaptureDevice *)device error:(NSError **)outError {
+ if ([device isKindOfClass:NSClassFromString(@"MSCFakeCaptureDevice")]) {
+ static MSCFakeCaptureInput *fakeInput = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeInput = (MSCFakeCaptureInput *)class_createInstance(NSClassFromString(@"MSCFakeCaptureInput"), 0);
+ });
+ return (__bridge id)CFRetain((__bridge CFTypeRef)fakeInput);
+ }
+ return [self init_mscWithDevice:device error:outError];
+}
+
+@end
+
+// Forward declaration
+static void startDelivery(void);
+
+// ---------------------------------------------------------------------------
+// AVCaptureVideoPreviewLayer category — swizzled methods
+// ---------------------------------------------------------------------------
+
+@implementation AVCaptureVideoPreviewLayer (MiniCamHook)
+
+- (instancetype)init_mscWithSession:(AVCaptureSession *)session {
+ id instance = [self init_mscWithSession:session];
+ if (instance) {
+ [gLock lock];
+ [gPreviewLayers addObject:instance];
+ [gLock unlock];
+ ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = kCAGravityResizeAspectFill;
+ // gReader is initialised in EntryPoint — do not recreate here.
+ startDelivery();
+ // Auto-start the session so AVCaptureVideoPreviewLayer activates its rendering
+ // path. Delayed 500 ms so onAppear (and any explicit startRunning the app calls)
+ // has already fired by the time the block runs. gAVSessionStarted gates the call
+ // so apps that do call startRunning() are unaffected.
+ if (session) {
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)),
+ dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ [gLock lock];
+ BOOL alreadyStarted = gAVSessionStarted;
+ [gLock unlock];
+ if (!alreadyStarted) {
+ NSLog(@"[MiniCamInject] preview layer got session but app never called startRunning — auto-starting");
+ @try { [session startRunning]; }
+ @catch (NSException *e) {
+ NSLog(@"[MiniCamInject] auto-start failed: %@", e.reason);
+ }
+ }
+ });
+ }
+ }
+ return instance;
+}
+
+- (instancetype)init_mscWithSessionWithNoConnection:(AVCaptureSession *)session {
+ id instance = [self init_mscWithSessionWithNoConnection:session];
+ if (instance) {
+ [gLock lock];
+ [gPreviewLayers addObject:instance];
+ [gLock unlock];
+ ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = kCAGravityResizeAspectFill;
+ // gReader is initialised in EntryPoint — do not recreate here.
+ startDelivery();
+ // Auto-start the session so AVCaptureVideoPreviewLayer activates its rendering
+ // path. Delayed 500 ms so onAppear (and any explicit startRunning the app calls)
+ // has already fired by the time the block runs. gAVSessionStarted gates the call
+ // so apps that do call startRunning() are unaffected.
+ if (session) {
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)),
+ dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ [gLock lock];
+ BOOL alreadyStarted = gAVSessionStarted;
+ [gLock unlock];
+ if (!alreadyStarted) {
+ NSLog(@"[MiniCamInject] preview layer got session but app never called startRunning — auto-starting");
+ @try { [session startRunning]; }
+ @catch (NSException *e) {
+ NSLog(@"[MiniCamInject] auto-start failed: %@", e.reason);
+ }
+ }
+ });
+ }
+ }
+ return instance;
+}
+
+- (void)msc_setSession:(AVCaptureSession *)session {
+ [gLock lock];
+ if (session) {
+ [gPreviewLayers addObject:self];
+ } else {
+ [gPreviewLayers removeObject:self];
+ }
+ [gLock unlock];
+
+ // Ensure the layer is flipped correctly for the generated CGImage
+ self.contentsGravity = kCAGravityResizeAspectFill;
+
+ if (session) {
+ // gReader is initialised in EntryPoint — do not recreate here.
+ startDelivery();
+ // Auto-start the session so AVCaptureVideoPreviewLayer activates its rendering
+ // path. Apps that configure inputs/outputs without calling startRunning() (e.g.
+ // PermissionManager patterns) would otherwise show a frozen black frame because
+ // the preview layer's internal renderer stays dormant until the session runs.
+ // Delayed 500 ms so the app's own onAppear/startRunning (if any) fires first,
+ // preventing a double-start crash on Simulator. @try/@catch for safety.
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)),
+ dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ [gLock lock];
+ BOOL alreadyStarted = gAVSessionStarted;
+ [gLock unlock];
+ if (!alreadyStarted) {
+ NSLog(@"[MiniCamInject] preview layer got session but app never called startRunning — auto-starting");
+ @try { [session startRunning]; }
+ @catch (NSException *e) {
+ NSLog(@"[MiniCamInject] auto-start failed: %@", e.reason);
+ }
+ }
+ });
+ }
+
+ [self msc_setSession:session];
+}
+
+@end
+
+// ---------------------------------------------------------------------------
+// Delivery engine
+// ---------------------------------------------------------------------------
+
+static void startDelivery(void) {
+ [gLock lock];
+ if (gRunning) { [gLock unlock]; return; }
+ gRunning = YES;
+ [gLock unlock];
+
+ uint64_t intervalNs = 1'000'000'000ULL / (uint64_t)gFPS;
+ uint64_t leewayNs = intervalNs / 10;
+
+ gDeliveryTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, gDeliveryQueue);
+ dispatch_source_set_timer(
+ gDeliveryTimer,
+ DISPATCH_TIME_NOW,
+ intervalNs,
+ leewayNs
+ );
+ dispatch_source_set_event_handler(gDeliveryTimer, ^{ deliverFrame(); });
+ dispatch_resume(gDeliveryTimer);
+ NSLog(@"[MiniCamInject] delivery started @ %d fps", gFPS);
+}
+
+static void stopDelivery(void) {
+ [gLock lock];
+ if (!gRunning) { [gLock unlock]; return; }
+ gRunning = NO;
+ dispatch_source_t t = gDeliveryTimer;
+ gDeliveryTimer = nullptr;
+ [gLock unlock];
+
+ if (t) dispatch_source_cancel(t);
+ NSLog(@"[MiniCamInject] delivery stopped");
+}
+
+static void deliverFrame(void) {
+ if (!gReader) return;
+
+ if (!gReader->isOpen()) {
+ if (!gReader->open()) {
+ return; // Not created yet
+ }
+ }
+
+ // Optimised single-copy path: factory reads from shm directly into CVPixelBuffer.
+ // Factory returns a +1 CF reference; transfer ownership to ARC immediately
+ // so it stays alive across the async dispatch below.
+ CMSampleBufferRef rawBuf = [gFactory sampleBufferFromReader:gReader];
+ if (!rawBuf) return;
+ id arcSampleBuf = CFBridgingRelease(rawBuf); // ARC now owns it
+
+ // --- Update preview layers ---
+ CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer((__bridge CMSampleBufferRef)arcSampleBuf);
+ if (pixelBuffer) {
+ CGImageRef cgImage = NULL;
+ if (VTCreateCGImageFromCVPixelBuffer(pixelBuffer, NULL, &cgImage) == noErr && cgImage) {
+ id arcImage = (__bridge_transfer id)cgImage;
+ [gLock lock];
+ NSArray *layers = [gPreviewLayers allObjects];
+ [gLock unlock];
+
+ if (layers.count > 0) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ for (AVCaptureVideoPreviewLayer *layer in layers) {
+ layer.contents = arcImage;
+ }
+ });
+ }
+ }
+ }
+
+ [gLock lock];
+ id del = gDelegate;
+ dispatch_queue_t q = gDelegateQueue;
+ AVCaptureVideoDataOutput *outObj = gOutput;
+ [gLock unlock];
+
+ if (!del || !q) return;
+
+ dispatch_async(q, ^{
+ if ([del respondsToSelector:@selector(captureOutput:didOutputSampleBuffer:fromConnection:)]) {
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wnonnull"
+ // ARC captures arcSampleBuf, keeping the buffer alive for this block.
+ CMSampleBufferRef sampleBuf = (__bridge CMSampleBufferRef)arcSampleBuf;
+ AVCaptureConnection *conn = outObj.connections.firstObject;
+ if (!conn) {
+ static AVCaptureConnection *fakeConn = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ fakeConn = (AVCaptureConnection *)class_createInstance(NSClassFromString(@"MSCFakeConnection"), 0);
+ });
+ conn = fakeConn;
+ }
+ [del captureOutput:outObj
+ didOutputSampleBuffer:sampleBuf
+ fromConnection:conn];
+#pragma clang diagnostic pop
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Install / uninstall (called from EntryPoint)
+// ---------------------------------------------------------------------------
+
+void MSCInstallHooks(SharedFrameReader* reader, int32_t fps) {
+ gLock = [NSLock new];
+ gDeliveryQueue = dispatch_queue_create("com.minisimcam.delivery", DISPATCH_QUEUE_SERIAL);
+ gReader = reader;
+ gFPS = fps;
+ gFactory = [[MSCSampleBufferFactory alloc] initWithFPS:fps];
+ gPreviewLayers = [NSHashTable weakObjectsHashTable];
+
+ // AVCaptureSession
+ swizzleInstance([AVCaptureSession class],
+ @selector(startRunning), @selector(msc_startRunning));
+ swizzleInstance([AVCaptureSession class],
+ @selector(stopRunning), @selector(msc_stopRunning));
+ swizzleInstance([AVCaptureSession class],
+ @selector(canAddInput:), @selector(msc_canAddInput:));
+ swizzleInstance([AVCaptureSession class],
+ @selector(addInput:), @selector(msc_addInput:));
+ swizzleInstance([AVCaptureSession class],
+ @selector(canAddOutput:), @selector(msc_canAddOutput:));
+ swizzleInstance([AVCaptureSession class],
+ @selector(addOutput:), @selector(msc_addOutput:));
+ swizzleInstance([AVCaptureSession class],
+ @selector(isRunning), @selector(msc_isRunning));
+
+ // AVCaptureVideoDataOutput
+ swizzleInstance([AVCaptureVideoDataOutput class],
+ @selector(setSampleBufferDelegate:queue:),
+ @selector(msc_setSampleBufferDelegate:queue:));
+
+ // AVCaptureVideoPreviewLayer
+ Class layerCls = NSClassFromString(@"AVCaptureVideoPreviewLayer");
+ if (layerCls) {
+ Method m1 = class_getInstanceMethod(layerCls, @selector(setSession:));
+ Method m2 = class_getInstanceMethod(layerCls, @selector(msc_setSession:));
+ if (m1 && m2) method_exchangeImplementations(m1, m2);
+
+ Method m3 = class_getInstanceMethod(layerCls, @selector(initWithSession:));
+ Method m4 = class_getInstanceMethod(layerCls, @selector(init_mscWithSession:));
+ if (m3 && m4) method_exchangeImplementations(m3, m4);
+
+ Method m5 = class_getInstanceMethod(layerCls, @selector(initWithSessionWithNoConnection:));
+ Method m6 = class_getInstanceMethod(layerCls, @selector(init_mscWithSessionWithNoConnection:));
+ if (m5 && m6) method_exchangeImplementations(m5, m6);
+ }
+
+ // AVCaptureDevice (class-level)
+ swizzleClass([AVCaptureDevice class],
+ @selector(defaultDeviceWithMediaType:),
+ @selector(msc_defaultDeviceWithMediaType:));
+ swizzleClass([AVCaptureDevice class],
+ @selector(defaultDeviceWithDeviceType:mediaType:position:),
+ @selector(msc_defaultDeviceWithDeviceType:mediaType:position:));
+ swizzleClass([AVCaptureDevice class],
+ @selector(authorizationStatusForMediaType:),
+ @selector(msc_authorizationStatusForMediaType:));
+ swizzleClass([AVCaptureDevice class],
+ @selector(requestAccessForMediaType:completionHandler:),
+ @selector(msc_requestAccessForMediaType:completionHandler:));
+
+ // AVCaptureDeviceInput
+ swizzleClass([AVCaptureDeviceInput class],
+ @selector(deviceInputWithDevice:error:),
+ @selector(msc_deviceInputWithDevice:error:));
+ swizzleInstance([AVCaptureDeviceInput class],
+ @selector(initWithDevice:error:),
+ @selector(init_mscWithDevice:error:));
+
+ // AVCaptureDeviceDiscoverySession
+ Class discoverySessionCls = NSClassFromString(@"AVCaptureDeviceDiscoverySession");
+ if (discoverySessionCls) {
+ swizzleInstance(discoverySessionCls,
+ @selector(devices),
+ @selector(msc_devices));
+ }
+
+ NSLog(@"[MiniCamInject] hooks installed — fps=%d", fps);
+}
+
+void MSCUninstallHooks(void) {
+ stopDelivery();
+ // Note: method_exchangeImplementations is idempotent for paired calls,
+ // but we cannot easily "un-swizzle" without re-exchanging.
+ // In practice, the process exits when the app is terminated.
+}
diff --git a/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm b/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm
new file mode 100755
index 0000000..03d7878
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm
@@ -0,0 +1,60 @@
+// EntryPoint.mm
+// dylib constructor — called automatically by dyld when the library is loaded.
+// Checks the MINISIMCAM_PATH environment variable, opens shared memory,
+// and installs AVFoundation hooks.
+
+#import
+#import "CaptureHooks.h"
+#import "SharedFrameReader.hpp"
+#include "MiniCamConstants.h"
+#include
+#include
+
+// Process-global reader (heap-allocated; intentional lifetime until process exit).
+static SharedFrameReader* gGlobalReader = nullptr;
+
+static int32_t parseFPS(void) {
+ const char* envFPS = getenv(MSC_ENV_FPS);
+ if (!envFPS) return MSC_DEFAULT_FPS;
+ int v = atoi(envFPS);
+ return (v > 0 && v <= 120) ? (int32_t)v : MSC_DEFAULT_FPS;
+}
+
+__attribute__((constructor))
+static void MiniCamInjectInit(void) {
+ @autoreleasepool {
+ const char* pathEnv = getenv(MSC_ENV_PATH);
+ if (!pathEnv || pathEnv[0] == '\0') {
+ NSLog(@"[MiniCamInject] %s not set — injector inactive.", MSC_ENV_PATH);
+ return;
+ }
+
+ std::string shmPath(pathEnv);
+ NSLog(@"[MiniCamInject] loading — shm=%s", shmPath.c_str());
+
+ gGlobalReader = new SharedFrameReader(shmPath);
+
+ if (!gGlobalReader->open()) {
+ NSLog(@"[MiniCamInject] ⚠️ Cannot open shared memory at %s. "
+ "Start 'sim cam start' before launching the app.",
+ shmPath.c_str());
+ // Hooks still installed; they will check isOpen() before delivering.
+ }
+
+ int32_t fps = parseFPS();
+ MSCInstallHooks(gGlobalReader, fps);
+
+ NSLog(@"[MiniCamInject] ✅ injector ready (fps=%d, shm=%s)", fps, shmPath.c_str());
+ }
+}
+
+__attribute__((destructor))
+static void MiniCamInjectFini(void) {
+ MSCUninstallHooks();
+ if (gGlobalReader) {
+ gGlobalReader->close();
+ delete gGlobalReader;
+ gGlobalReader = nullptr;
+ }
+ NSLog(@"[MiniCamInject] unloaded.");
+}
diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h
new file mode 100755
index 0000000..706f4e7
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h
@@ -0,0 +1,34 @@
+// SampleBufferFactory.h
+// Objective-C header for MSCSampleBufferFactory.
+// Uses #ifdef __cplusplus so this can be included from both .mm and pure C contexts.
+
+#pragma once
+#import
+#import
+
+#ifdef __cplusplus
+#include "SharedFrameReader.hpp"
+#endif
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface MSCSampleBufferFactory : NSObject
+
+- (instancetype)initWithFPS:(int32_t)fps NS_DESIGNATED_INITIALIZER;
+- (instancetype)init NS_UNAVAILABLE;
+
+#ifdef __cplusplus
+/// Creates a CMSampleBuffer from a FrameSnapshot read via SharedFrameReader.
+/// Returns a +1 CF reference on success; the caller is responsible for CFRelease.
+/// Returns nil if the snapshot is invalid or buffer creation fails.
+- (nullable CMSampleBufferRef)sampleBufferFromSnapshot:(const FrameSnapshot &)snap CF_RETURNS_RETAINED;
+
+/// Optimised single-copy path: reads frame metadata from the shm header, allocates
+/// a CVPixelBuffer, and copies the frame data directly (one copy vs two).
+/// Returns a +1 CF reference on success; the caller is responsible for CFRelease.
+- (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader CF_RETURNS_RETAINED;
+#endif
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm
new file mode 100755
index 0000000..5e52abe
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm
@@ -0,0 +1,174 @@
+// SampleBufferFactory.mm
+// Creates CVPixelBuffers and CMSampleBuffers from shared-memory frames.
+// Two code paths:
+// 1. sampleBufferFromSnapshot: — legacy, takes a pre-copied FrameSnapshot.
+// 2. sampleBufferFromReader: — optimised, single-copy directly into CVPixelBuffer.
+
+#import
+#import
+#import
+#import
+#import "SampleBufferFactory.h"
+#import "SharedFrameReader.hpp"
+
+// ---------------------------------------------------------------------------
+// CVPixelBufferPool wrapper
+// ---------------------------------------------------------------------------
+
+@implementation MSCSampleBufferFactory {
+ CVPixelBufferPoolRef _pool;
+ CMVideoFormatDescriptionRef _formatDesc;
+ uint32_t _poolWidth;
+ uint32_t _poolHeight;
+ int32_t _fps;
+ CMTime _frameDuration;
+ CMTime _startCMTime;
+}
+
+- (instancetype)initWithFPS:(int32_t)fps {
+ if (!(self = [super init])) return nil;
+ _fps = fps;
+ _frameDuration = CMTimeMake(1, fps);
+ _startCMTime = CMClockGetTime(CMClockGetHostTimeClock());
+ return self;
+}
+
+- (void)dealloc {
+ [self teardownPool];
+}
+
+- (void)teardownPool {
+ if (_pool) { CVPixelBufferPoolRelease(_pool); _pool = nullptr; }
+ if (_formatDesc) { CFRelease(_formatDesc); _formatDesc = nullptr; }
+ _poolWidth = 0; _poolHeight = 0;
+}
+
+/// Ensure the pixel-buffer pool matches the current frame dimensions.
+- (BOOL)ensurePoolWidth:(uint32_t)w height:(uint32_t)h {
+ if (_pool && _poolWidth == w && _poolHeight == h) return YES;
+ [self teardownPool];
+ _poolWidth = w;
+ _poolHeight = h;
+
+ NSDictionary *attrs = @{
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
+ (NSString *)kCVPixelBufferWidthKey: @(w),
+ (NSString *)kCVPixelBufferHeightKey: @(h),
+ (NSString *)kCVPixelBufferIOSurfacePropertiesKey: @{},
+ (NSString *)kCVPixelBufferCGImageCompatibilityKey: @YES,
+ (NSString *)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES
+ };
+
+ CVReturn ret = CVPixelBufferPoolCreate(
+ kCFAllocatorDefault, nullptr,
+ (__bridge CFDictionaryRef)attrs,
+ &_pool
+ );
+ return ret == kCVReturnSuccess;
+}
+
+- (nullable CMSampleBufferRef)sampleBufferFromSnapshot:(const FrameSnapshot &)snap {
+ if (!snap.valid || snap.data.empty()) return nil;
+
+ const uint32_t w = snap.width;
+ const uint32_t h = snap.height;
+ const uint32_t bpr = snap.bytesPerRow;
+
+ if (![self ensurePoolWidth:w height:h]) return nil;
+
+ // --- Allocate pixel buffer from pool ---
+ CVPixelBufferRef pixBuf = nullptr;
+ if (CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, _pool, &pixBuf) != kCVReturnSuccess) {
+ return nil;
+ }
+
+ // --- Copy frame data row-by-row (source and destination may have different strides) ---
+ CVPixelBufferLockBaseAddress(pixBuf, 0);
+ uint8_t *dst = (uint8_t *)CVPixelBufferGetBaseAddress(pixBuf);
+ size_t dstBPR = CVPixelBufferGetBytesPerRow(pixBuf);
+ const uint8_t *src = snap.data.data();
+
+ for (uint32_t row = 0; row < h; ++row) {
+ memcpy(dst + row * dstBPR, src + row * bpr, (size_t)w * 4);
+ }
+ CVPixelBufferUnlockBaseAddress(pixBuf, 0);
+
+ return [self wrapPixelBuffer:pixBuf pts:snap.ptsNs];
+}
+
+- (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader {
+ if (!reader || !reader->isOpen()) return nil;
+
+ // Read dimensions directly from the shm header — no pixel copy yet.
+ const MSCStreamHeader* hdr = reader->peekHeader();
+ if (!hdr || hdr->magic != MSC_MAGIC) return nil;
+
+ const uint32_t w = hdr->width;
+ const uint32_t h = hdr->height;
+ if (w == 0 || h == 0) return nil;
+
+ if (![self ensurePoolWidth:w height:h]) return nil;
+
+ CVPixelBufferRef pixBuf = nullptr;
+ if (CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, _pool, &pixBuf) != kCVReturnSuccess) {
+ return nil;
+ }
+
+ // Single pass: shm → CVPixelBuffer (no intermediate vector). The
+ // reader copies per row because its stream stride can differ from the
+ // pixel buffer pool's stride.
+ CVPixelBufferLockBaseAddress(pixBuf, 0);
+ void *dst = CVPixelBufferGetBaseAddress(pixBuf);
+ size_t dstBPR = CVPixelBufferGetBytesPerRow(pixBuf);
+ size_t dstTotal = dstBPR * h;
+
+ FrameInfo info = reader->copyLatestFrameInto(dst, dstBPR, dstTotal);
+ CVPixelBufferUnlockBaseAddress(pixBuf, 0);
+
+ if (!info.valid) {
+ CVPixelBufferRelease(pixBuf);
+ return nil;
+ }
+
+ return [self wrapPixelBuffer:pixBuf pts:info.ptsNs];
+}
+
+/// Internal helper: wraps a CVPixelBuffer into a CMSampleBuffer.
+/// Takes ownership of pixBuf (calls CVPixelBufferRelease).
+/// Returns a +1 CF reference; caller must CFRelease.
+- (nullable CMSampleBufferRef)wrapPixelBuffer:(CVPixelBufferRef)pixBuf pts:(uint64_t)ptsNs {
+ if (!_formatDesc) {
+ CMVideoFormatDescriptionCreateForImageBuffer(
+ kCFAllocatorDefault, pixBuf, &_formatDesc
+ );
+ }
+ if (!_formatDesc) {
+ CVPixelBufferRelease(pixBuf);
+ return nil;
+ }
+
+ CMTime pts = CMTimeMake(ptsNs, 1000000000);
+
+ CMSampleTimingInfo timing = {
+ .duration = _frameDuration,
+ .presentationTimeStamp = pts,
+ .decodeTimeStamp = kCMTimeInvalid
+ };
+
+ CMSampleBufferRef sampleBuf = nullptr;
+ OSStatus status = CMSampleBufferCreateReadyWithImageBuffer(
+ kCFAllocatorDefault,
+ pixBuf,
+ _formatDesc,
+ &timing,
+ &sampleBuf
+ );
+
+ CVPixelBufferRelease(pixBuf);
+
+ if (status != noErr || !sampleBuf) return nil;
+ // Return a +1 CF reference. The caller is responsible for CFRelease.
+ return sampleBuf;
+}
+
+@end
diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp
new file mode 100755
index 0000000..5a31c92
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp
@@ -0,0 +1,215 @@
+// SharedFrameReader.cpp
+// Sequence-lock consumer for the shared-memory triple-buffer.
+// The struct fields are plain integers; we cast their addresses to
+// std::atomic* for correct cross-process atomic semantics (C++20
+// guarantees this is well-defined when the natural alignment matches).
+
+#include "SharedFrameReader.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+static uint64_t monoNs() {
+ static mach_timebase_info_data_t info;
+ static std::once_flag flag;
+ std::call_once(flag, []{ mach_timebase_info(&info); });
+ return mach_absolute_time() * info.numer / info.denom;
+}
+
+// Reinterpret a plain integer field as a lock-free atomic reference.
+template
+static std::atomic* atomicAt(void* base, size_t offset) {
+ return reinterpret_cast*>(
+ static_cast(base) + offset
+ );
+}
+template
+static const std::atomic* atomicAt(const void* base, size_t offset) {
+ return reinterpret_cast*>(
+ static_cast(base) + offset
+ );
+}
+
+// Offsets matching MSCStreamHeader in MiniCamProtocol.h.
+constexpr size_t kOffSequence = MSC_OFF_SEQUENCE; // 32
+constexpr size_t kOffPublishedIndex = MSC_OFF_PUBLISHED_INDEX; // 40
+constexpr size_t kOffFramesProduced = MSC_OFF_FRAMES_PRODUCED; // 56
+
+// ---------------------------------------------------------------------------
+// SharedFrameReader
+// ---------------------------------------------------------------------------
+
+SharedFrameReader::SharedFrameReader(const std::string& path)
+ : path_(path) {}
+
+SharedFrameReader::~SharedFrameReader() {
+ close();
+}
+
+bool SharedFrameReader::open() {
+ close();
+
+ fd_ = ::open(path_.c_str(), O_RDONLY);
+ if (fd_ == -1) return false;
+
+ // Read the header to discover the mapping size.
+ MSCStreamHeader hdr = {};
+ if (::read(fd_, &hdr, sizeof(hdr)) != (ssize_t)sizeof(hdr)) {
+ ::close(fd_); fd_ = -1;
+ return false;
+ }
+
+ if (hdr.magic != MSC_MAGIC || hdr.version != MSC_VERSION) {
+ ::close(fd_); fd_ = -1;
+ return false;
+ }
+
+ size_t total = msc_mapping_size(hdr.bufferSize);
+
+ struct stat st;
+ if (fstat(fd_, &st) != 0 || (size_t)st.st_size < total) {
+ ::close(fd_); fd_ = -1;
+ return false;
+ }
+
+ void* ptr = mmap(nullptr, total, PROT_READ, MAP_SHARED, fd_, 0);
+ if (ptr == MAP_FAILED) {
+ ::close(fd_); fd_ = -1;
+ return false;
+ }
+
+ mapping_ = ptr;
+ mappingSize_ = total;
+ return true;
+}
+
+void SharedFrameReader::close() {
+ if (mapping_) { munmap(mapping_, mappingSize_); mapping_ = nullptr; }
+ if (fd_ != -1) { ::close(fd_); fd_ = -1; }
+ mappingSize_ = 0;
+}
+
+FrameSnapshot SharedFrameReader::copyLatestFrame() {
+ FrameSnapshot snap;
+ if (!mapping_) return snap;
+
+ auto* hdr = header();
+ if (hdr->magic != MSC_MAGIC || hdr->version != MSC_VERSION) return snap;
+
+ const uint32_t bufSize = hdr->bufferSize;
+ const uint32_t bpr = hdr->bytesPerRow;
+ const uint32_t w = hdr->width;
+ const uint32_t h = hdr->height;
+
+ auto* seqAtomic = atomicAt(mapping_, kOffSequence);
+ auto* idxAtomic = atomicAt(mapping_, kOffPublishedIndex);
+ auto* fpAtomic = atomicAt(mapping_, kOffFramesProduced);
+
+ // Sequence-lock read: retry up to 64 times.
+ for (int attempt = 0; attempt < 64; ++attempt) {
+ uint64_t seqA = seqAtomic->load(std::memory_order_acquire);
+ if (seqA & 1u) {
+ sched_yield();
+ continue;
+ }
+
+ uint32_t idx = idxAtomic->load(std::memory_order_acquire);
+ uint64_t pts = hdr->presentationTimeNs;
+ uint64_t fp = fpAtomic->load(std::memory_order_acquire);
+
+ if (idx >= MSC_BUFFER_COUNT) break;
+
+ const uint8_t* src = static_cast(
+ msc_frame_ptr(mapping_, bufSize, idx)
+ );
+
+ std::vector pixels(bufSize);
+ std::memcpy(pixels.data(), src, bufSize);
+
+ uint64_t seqB = seqAtomic->load(std::memory_order_acquire);
+ if (seqA != seqB) continue; // Torn read — retry.
+
+ snap.valid = true;
+ snap.width = w;
+ snap.height = h;
+ snap.bytesPerRow = bpr;
+ snap.ptsNs = pts;
+ snap.framesProduced = fp;
+ snap.data = std::move(pixels);
+ return snap;
+ }
+
+ return snap;
+}
+
+bool SharedFrameReader::isProducerStale() const {
+ if (!mapping_) return true;
+ auto* hdr = header();
+ return (monoNs() - hdr->presentationTimeNs) > MSC_STALE_THRESHOLD_NS;
+}
+
+FrameInfo SharedFrameReader::copyLatestFrameInto(void* dst, size_t dstBytesPerRow, size_t dstSize) {
+ FrameInfo info;
+ if (!mapping_ || !dst) return info;
+
+ auto* hdr = header();
+ if (hdr->magic != MSC_MAGIC || hdr->version != MSC_VERSION) return info;
+
+ const uint32_t bufSize = hdr->bufferSize;
+ const uint32_t bpr = hdr->bytesPerRow;
+ const uint32_t w = hdr->width;
+ const uint32_t h = hdr->height;
+
+ if (dstBytesPerRow < bpr || dstSize < dstBytesPerRow * h) return info;
+
+ auto* seqAtomic = atomicAt(mapping_, kOffSequence);
+ auto* idxAtomic = atomicAt(mapping_, kOffPublishedIndex);
+ auto* fpAtomic = atomicAt(mapping_, kOffFramesProduced);
+
+ // Sequence-lock read: retry up to 64 times.
+ for (int attempt = 0; attempt < 64; ++attempt) {
+ uint64_t seqA = seqAtomic->load(std::memory_order_acquire);
+ if (seqA & 1u) {
+ sched_yield();
+ continue;
+ }
+
+ uint32_t idx = idxAtomic->load(std::memory_order_acquire);
+ uint64_t pts = hdr->presentationTimeNs;
+ uint64_t fp = fpAtomic->load(std::memory_order_acquire);
+
+ if (idx >= MSC_BUFFER_COUNT) break;
+
+ const uint8_t* src = static_cast(
+ msc_frame_ptr(mapping_, bufSize, idx)
+ );
+ uint8_t* out = static_cast(dst);
+ // The stream and CVPixelBuffer may use different strides. Copy each
+ // source row into the destination row rather than treating both as a
+ // contiguous image.
+ for (uint32_t row = 0; row < h; ++row) {
+ std::memcpy(out + row * dstBytesPerRow, src + row * bpr, bpr);
+ }
+
+ uint64_t seqB = seqAtomic->load(std::memory_order_acquire);
+ if (seqA != seqB) continue; // Torn read — retry.
+
+ info.valid = true;
+ info.width = w;
+ info.height = h;
+ info.bytesPerRow = bpr;
+ info.ptsNs = pts;
+ info.framesProduced = fp;
+ return info;
+ }
+
+ return info;
+}
diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp
new file mode 100755
index 0000000..92814ff
--- /dev/null
+++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp
@@ -0,0 +1,81 @@
+// SharedFrameReader.hpp
+// C++ consumer for the shared-memory triple-buffer.
+// Implements the sequence-lock read algorithm defined in MiniCamProtocol.h.
+
+#pragma once
+#include "MiniCamProtocol.h"
+#include "MiniCamConstants.h"
+#include
+#include
+#include
+
+/// Result returned by SharedFrameReader::copyLatestFrame.
+struct FrameSnapshot {
+ bool valid = false;
+ uint32_t width = 0;
+ uint32_t height = 0;
+ uint32_t bytesPerRow = 0;
+ uint64_t ptsNs = 0; ///< Presentation timestamp (monotonic ns)
+ uint64_t framesProduced = 0;
+ std::vector data; ///< Owned BGRA copy; length = bytesPerRow * height
+};
+
+/// Metadata returned by the zero-copy path (no pixel data).
+struct FrameInfo {
+ bool valid = false;
+ uint32_t width = 0;
+ uint32_t height = 0;
+ uint32_t bytesPerRow = 0;
+ uint64_t ptsNs = 0;
+ uint64_t framesProduced = 0;
+};
+
+class SharedFrameReader {
+public:
+ explicit SharedFrameReader(const std::string& path);
+ ~SharedFrameReader();
+
+ // Non-copyable, non-moveable.
+ SharedFrameReader(const SharedFrameReader&) = delete;
+ SharedFrameReader& operator=(const SharedFrameReader&) = delete;
+
+ /// Open and memory-map the shared file.
+ /// Returns false if the file does not exist or the header is invalid.
+ bool open();
+
+ /// Close the mapping (idempotent).
+ void close();
+
+ bool isOpen() const { return mapping_ != nullptr; }
+
+ /// Read-only pointer to the stream header. Returns nullptr if not open.
+ /// Valid only while the mapping is open; do NOT cache across open/close.
+ const MSCStreamHeader* peekHeader() const {
+ return mapping_ ? static_cast(mapping_) : nullptr;
+ }
+
+ /// Copy the latest published frame into a FrameSnapshot.
+ /// Uses the sequence-lock algorithm to ensure a consistent read.
+ /// Returns an invalid snapshot if the producer is stalled or the header is corrupt.
+ FrameSnapshot copyLatestFrame();
+
+ /// Zero-copy path: copy the latest frame directly into `dst` (caller-allocated).
+ /// `dstBytesPerRow` describes the caller's row stride and must be at least
+ /// the stream stride. `dstSize` must cover dstBytesPerRow * height.
+ /// Returns metadata; returns invalid FrameInfo if the header is corrupt or
+ /// the buffer is too small. Eliminates the intermediate std::vector allocation.
+ FrameInfo copyLatestFrameInto(void* dst, size_t dstBytesPerRow, size_t dstSize);
+
+ /// Check whether the producer has gone stale (no update within MSC_STALE_THRESHOLD_NS).
+ bool isProducerStale() const;
+
+private:
+ std::string path_;
+ void* mapping_ = nullptr;
+ size_t mappingSize_ = 0;
+ int fd_ = -1;
+
+ MSCStreamHeader* header() const {
+ return static_cast(mapping_);
+ }
+};
diff --git a/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift b/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift
new file mode 100755
index 0000000..e4f6f28
--- /dev/null
+++ b/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift
@@ -0,0 +1,119 @@
+// FrameTransportTests.swift
+// Integration tests for SharedFrameWriter + SharedFrameReader round-trip.
+// Requires macOS (uses POSIX mmap).
+
+import XCTest
+@testable import FrameHost
+
+final class FrameTransportTests: XCTestCase {
+
+ private var tmpPath: String!
+
+ override func setUp() {
+ super.setUp()
+ tmpPath = NSTemporaryDirectory() + "minisimcam_test_\(UUID().uuidString).frames"
+ }
+
+ override func tearDown() {
+ try? FileManager.default.removeItem(atPath: tmpPath)
+ super.tearDown()
+ }
+
+ // MARK: - Basic round-trip
+
+ func testWriteReadRoundTrip() throws {
+ let writer = SharedFrameWriter(path: tmpPath)
+ try writer.open(width: 320, height: 240)
+ defer { writer.close() }
+
+ // Create a red frame.
+ let bpr = ImageSource.alignedBytesPerRow(320)
+ var pixels = [UInt8](repeating: 0, count: bpr * 240)
+ for y in 0..<240 {
+ for x in 0..<320 {
+ let off = y * bpr + x * 4
+ pixels[off + 0] = 0x00 // B
+ pixels[off + 1] = 0x00 // G
+ pixels[off + 2] = 0xFF // R
+ pixels[off + 3] = 0xFF // A
+ }
+ }
+ let frame = BGRAFrame(width: 320, height: 240, bytesPerRow: bpr, data: Data(pixels))
+ try writer.publish(frame: frame, pts: 1_000_000)
+
+ XCTAssertEqual(writer.framesProduced, 1)
+
+ // Verify the file exists and has the expected size.
+ let fileSize = (try? FileManager.default.attributesOfItem(atPath: tmpPath)[.size] as? Int) ?? 0
+ let expected = 128 + 3 * bpr * 240
+ XCTAssertEqual(fileSize, expected, "File size should match header + 3 × frame")
+ }
+
+ // MARK: - Odd dimensions (catches stride bugs)
+
+ func testOddDimensionFrame() throws {
+ let w = 641, h = 479
+ let writer = SharedFrameWriter(path: tmpPath)
+ try writer.open(width: w, height: h)
+ defer { writer.close() }
+
+ let frame = try ImageSource.colorBars(width: w, height: h)
+ XCTAssertEqual(frame.bytesPerRow % 64, 0, "bytesPerRow must be 64-byte aligned")
+ XCTAssertGreaterThanOrEqual(frame.bytesPerRow, w * 4)
+
+ try writer.publish(frame: frame, pts: 1)
+ XCTAssertEqual(writer.framesProduced, 1)
+ }
+
+ // MARK: - Producer restart detection
+
+ func testProducerRestart() throws {
+ let writer = SharedFrameWriter(path: tmpPath)
+ try writer.open(width: 160, height: 120)
+ let frame = try ImageSource.colorBars(width: 160, height: 120)
+ try writer.publish(frame: frame, pts: 1)
+ writer.close()
+
+ // Simulate producer restart: re-open the same path.
+ let writer2 = SharedFrameWriter(path: tmpPath)
+ try writer2.open(width: 160, height: 120)
+ defer { writer2.close() }
+
+ // framesProduced resets to 0 on a fresh open.
+ XCTAssertEqual(writer2.framesProduced, 0)
+ }
+
+ // MARK: - Monotonic frame count
+
+ func testMonotonicFrameCount() throws {
+ let writer = SharedFrameWriter(path: tmpPath)
+ try writer.open(width: 160, height: 120)
+ defer { writer.close() }
+
+ let frame = try ImageSource.colorBars(width: 160, height: 120)
+ for i in 1...10 {
+ try writer.publish(frame: frame, pts: UInt64(i) * 33_333_333)
+ XCTAssertEqual(writer.framesProduced, UInt64(i))
+ }
+ }
+
+ // MARK: - ImageSource color bars
+
+ func testColorBarsSize() throws {
+ let f = try ImageSource.colorBars(width: 1280, height: 720)
+ XCTAssertEqual(f.width, 1280)
+ XCTAssertEqual(f.height, 720)
+ XCTAssertEqual(f.bytesPerRow, 1280 * 4) // Already aligned
+ XCTAssertEqual(f.data.count, f.bytesPerRow * f.height)
+ }
+
+ // MARK: - Row alignment invariant
+
+ func testRowAlignmentInvariant() {
+ for w in [1, 7, 64, 100, 320, 641, 1280, 1920, 3840] {
+ let bpr = ImageSource.alignedBytesPerRow(w)
+ XCTAssertEqual(bpr % 64, 0, "bytesPerRow for width \(w) must be 64-byte aligned")
+ XCTAssertGreaterThanOrEqual(bpr, w * 4, "bytesPerRow must cover all pixels")
+ }
+ }
+}
diff --git a/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift b/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift
new file mode 100755
index 0000000..3a8d1cb
--- /dev/null
+++ b/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift
@@ -0,0 +1,87 @@
+// ProtocolTests.swift
+// Unit tests for MiniCamProtocol.h calculations.
+// These do NOT require a running FrameHost; they validate pure math.
+
+import XCTest
+@testable import MiniSimCamShared // Swift wrapper around the C header
+
+final class ProtocolTests: XCTestCase {
+
+ // MARK: - Header size
+
+ func testHeaderSize() {
+ // Must remain exactly 128 bytes so existing shared files stay valid.
+ XCTAssertEqual(MemoryLayout.size, 128)
+ }
+
+ // MARK: - Row stride alignment
+
+ func testBytesPerRowExactMultiple() {
+ // Width that already aligns: 64 pixels → 64*4 = 256 B, already 64-byte aligned.
+ XCTAssertEqual(msc_bytes_per_row(64), 256)
+ }
+
+ func testBytesPerRowRoundsUp() {
+ // 1280 pixels → 1280*4 = 5120 B → already aligned (5120 % 64 == 0).
+ XCTAssertEqual(msc_bytes_per_row(1280), 5120)
+ }
+
+ func testBytesPerRowOddWidth() {
+ // 641 pixels → 641*4 = 2564 B → rounded up to next multiple of 64 = 2624.
+ let bpr = msc_bytes_per_row(641)
+ XCTAssertEqual(bpr % 64, 0)
+ XCTAssertGreaterThanOrEqual(bpr, 641 * 4)
+ }
+
+ func testBytesPerRowMinimalWidth() {
+ // Width 1 → 4 bytes → rounded to 64.
+ XCTAssertEqual(msc_bytes_per_row(1), 64)
+ }
+
+ // MARK: - Mapping size
+
+ func testMappingSizeFormula() {
+ let bpr = msc_bytes_per_row(1280)
+ let bufSize = bpr * 720
+ let total = msc_mapping_size(bufSize)
+ XCTAssertEqual(total, UInt64(128) + UInt64(3) * UInt64(bufSize))
+ }
+
+ // MARK: - Frame pointer arithmetic
+
+ func testFramePtrOffsets() {
+ let bufSize: UInt32 = 100
+ var fakeBase = [UInt8](repeating: 0, count: 128 + 3 * 100)
+ fakeBase.withUnsafeMutableBytes { raw in
+ let base = raw.baseAddress!
+ let p0 = msc_frame_ptr(base, bufSize, 0)
+ let p1 = msc_frame_ptr(base, bufSize, 1)
+ let p2 = msc_frame_ptr(base, bufSize, 2)
+
+ XCTAssertEqual(p0, base.advanced(by: 128))
+ XCTAssertEqual(p1, base.advanced(by: 128 + 100))
+ XCTAssertEqual(p2, base.advanced(by: 128 + 200))
+ }
+ }
+
+ // MARK: - Magic / version constants
+
+ func testMagicConstant() {
+ XCTAssertEqual(MSC_MAGIC, 0x4D534343)
+ }
+
+ func testVersionConstant() {
+ XCTAssertEqual(MSC_VERSION, 1)
+ }
+
+ func testBufferCount() {
+ XCTAssertEqual(MSC_BUFFER_COUNT, 3)
+ }
+
+ // MARK: - Pixel format
+
+ func testPixelFormat() {
+ // kCVPixelFormatType_32BGRA = 'BGRA' = 0x42475241
+ XCTAssertEqual(MSC_PIXEL_FORMAT, 0x42475241)
+ }
+}
diff --git a/README.md b/README.md
index 81c32e1..b22f0d1 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ sim last
| `last` | - | Show the last used device. |
| `lts` | - | Start the last used device (short for `sim start lts`). |
| `completion ` | - | Generate shell autocompletion script. |
+| `cam` | - | iOS Simulator camera injection and management. |
| `version` | `-v` | Show the current version. |
| `help` | - | Show help information. |
@@ -227,6 +228,17 @@ sim copy to "iPhone 15 Pro" ~/Pictures/test.png
sim copy from "Pixel_7" /sdcard/Download/test.png ./
```
+## Camera Injection
+
+SIM-CLI supports injecting physical webcams, Continuity Cameras, or static images directly into iOS Simulator applications, bypassing the simulator's lack of native camera support.
+
+To open the camera management dashboard:
+```bash
+sim cam
+```
+
+For detailed technical information on how the shared memory and dylib injection works, please read the [SIM-CLI Camera Architecture](docs/SIM_CAM_ARCHITECTURE.md).
+
## Configuration
SIM-CLI stores its runtime state (e.g., last used device) and user preferences in a JSON file at:
diff --git a/cmd/cam.go b/cmd/cam.go
new file mode 100644
index 0000000..02fc4ad
--- /dev/null
+++ b/cmd/cam.go
@@ -0,0 +1,675 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// ---------------------------------------------------------------------------
+// Paths & helpers
+// ---------------------------------------------------------------------------
+
+// camMscDir is an optional override for the MiniSimCam directory.
+var camMscDir string
+
+func miniSimCamDir() string {
+ if camMscDir != "" {
+ return camMscDir
+ }
+ ex, _ := os.Executable()
+ // Walk up to the project root where MiniSimCam/ lives.
+ dir := filepath.Dir(ex)
+ for i := 0; i < 4; i++ {
+ candidate := filepath.Join(dir, "MiniSimCam")
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ dir = filepath.Dir(dir)
+ }
+ // Fall back to CWD/MiniSimCam.
+ cwd, _ := os.Getwd()
+ return filepath.Join(cwd, "MiniSimCam")
+}
+
+func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) }
+func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) }
+func statusFilePath(udid string) string {
+ primary := fmt.Sprintf("/tmp/minisimcam.%s.status", udid)
+ if _, err := os.Stat(primary); err == nil {
+ return primary
+ }
+ secondary := filepath.Join(os.TempDir(), fmt.Sprintf("minisimcam.%s.status", udid))
+ if _, err := os.Stat(secondary); err == nil {
+ return secondary
+ }
+ return primary
+}
+
+// resolveEmbeddedBinDir returns the directory containing extracted embedded
+// binaries, or "" if the embedded assets are stubs (dev builds without cam).
+// The result is cached after the first successful extraction.
+var (
+ embeddedBinDirOnce sync.Once
+ embeddedBinDirValue string
+)
+
+func resolveEmbeddedBinDir() string {
+ embeddedBinDirOnce.Do(func() {
+ if binDir, err := ensureExtractedAssets(); err == nil {
+ embeddedBinDirValue = binDir
+ }
+ })
+ return embeddedBinDirValue
+}
+
+func frameHostBin(mscDir string) string {
+ if binDir := resolveEmbeddedBinDir(); binDir != "" {
+ extracted := filepath.Join(binDir, "FrameHost")
+ if _, err := os.Stat(extracted); err == nil {
+ return extracted
+ }
+ }
+ return filepath.Join(mscDir, ".build", "release", "FrameHost")
+}
+
+func injectorDylib(mscDir string) string {
+ if binDir := resolveEmbeddedBinDir(); binDir != "" {
+ extracted := filepath.Join(binDir, "MiniCamInject.dylib")
+ if _, err := os.Stat(extracted); err == nil {
+ return extracted
+ }
+ }
+ return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib")
+}
+
+func findRunningIOSSimulator(deviceID string) (udid, name string, err error) {
+ udid, name, isAndroid, err := FindRunningDevice(deviceID)
+ if err != nil || udid == "" {
+ return "", "", err
+ }
+ if isAndroid {
+ return "", "", fmt.Errorf("MiniSimCam only supports booted iOS Simulators, not Android emulators")
+ }
+ return udid, name, nil
+}
+
+// waitForFrameHostReady rejects immediate FrameHost failures (for example an
+// invalid camera ID) instead of printing a misleading successful start.
+func waitForFrameHostReady(c *exec.Cmd, statusPath string) error {
+ exited := make(chan error, 1)
+ go func() { exited <- c.Wait() }()
+
+ deadline := time.NewTimer(5 * time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(50 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case err := <-exited:
+ if err == nil {
+ return fmt.Errorf("FrameHost exited during startup")
+ }
+ return fmt.Errorf("FrameHost failed during startup: %w", err)
+ case <-ticker.C:
+ data, err := os.ReadFile(statusPath)
+ if err != nil {
+ continue
+ }
+ var status camFrameLoopStatus
+ if json.Unmarshal(data, &status) == nil && status.HostPID == int32(c.Process.Pid) {
+ return nil
+ }
+ case <-deadline.C:
+ _ = c.Process.Kill()
+ return fmt.Errorf("FrameHost did not become ready within 5 seconds")
+ }
+ }
+}
+
+func frameHostFPS(udid string) int {
+ data, err := os.ReadFile(statusFilePath(udid))
+ if err != nil {
+ return DefaultCamFPS
+ }
+ var status camFrameLoopStatus
+ if err := json.Unmarshal(data, &status); err != nil || status.FPS < 1 || status.FPS > 120 {
+ return DefaultCamFPS
+ }
+ return status.FPS
+}
+
+// ---------------------------------------------------------------------------
+// cam command group
+// ---------------------------------------------------------------------------
+
+var camCmd = &cobra.Command{
+ Use: "cam",
+ Short: "iOS Simulator camera injector",
+ Long: `MiniSimCam — inject synthetic camera frames into an iOS Simulator app.
+
+The cam group manages a macOS FrameHost process that writes BGRA frames to
+shared memory. A dylib (MiniCamInject) loaded into your simulator app reads
+those frames and delivers them as CMSampleBuffer callbacks.
+
+Quick start:
+ sim cam
+ sim cam start --image test.png
+ sim cam launch --bundle-id com.example.MyApp
+ sim cam status
+ sim cam stop`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam is only supported on macOS")
+ }
+ return runCamDashboard()
+ },
+}
+
+// ---------------------------------------------------------------------------
+// sim cam start
+// ---------------------------------------------------------------------------
+
+var (
+ camStartImage string
+ camStartBars bool
+ camStartCamera bool
+ camStartCameraID string
+ camStartScaleMode string
+ camStartWidth int
+ camStartHeight int
+ camStartFPS int
+ camStartDevice string
+)
+
+var camStartCmd = &cobra.Command{
+ Use: "start",
+ Short: "Start the FrameHost (frame producer)",
+ Long: `Start the FrameHost macOS process that writes BGRA frames to shared memory.
+
+Examples:
+ sim cam start --image ./test-card.png
+ sim cam start --bars --width 1920 --height 1080 --fps 60
+ sim cam start --camera
+ sim cam start --camera --camera-id "iPhone"
+ sim cam start --image qr.png --device `,
+
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam start is only supported on macOS")
+ }
+ sourceCount := 0
+ if camStartImage != "" {
+ sourceCount++
+ }
+ if camStartBars {
+ sourceCount++
+ }
+ if camStartCamera {
+ sourceCount++
+ }
+ if sourceCount != 1 {
+ return fmt.Errorf("provide exactly one of --image , --bars, or --camera")
+ }
+ if camStartWidth <= 0 || camStartHeight <= 0 || camStartWidth > 3840 || camStartHeight > 3840 {
+ return fmt.Errorf("--width and --height must be between 1 and 3840")
+ }
+ if camStartFPS < 1 || camStartFPS > 120 {
+ return fmt.Errorf("--fps must be between 1 and 120")
+ }
+ if camStartCameraID != "" && !camStartCamera {
+ return fmt.Errorf("--camera-id requires --camera")
+ }
+ if cmd.Flags().Changed("scale-mode") && !camStartCamera {
+ return fmt.Errorf("--scale-mode requires --camera")
+ }
+
+ // Resolve UDID.
+ udid, _, err := findRunningIOSSimulator(camStartDevice)
+ if err != nil || udid == "" {
+ return fmt.Errorf("no booted iOS simulator found — boot one first (%w)", err)
+ }
+
+ mscDir := miniSimCamDir()
+ bin := frameHostBin(mscDir)
+ if _, err := os.Stat(bin); err != nil {
+ return fmt.Errorf("FrameHost not found — run 'sim cam build' first (or use a release build with embedded binaries)")
+ }
+
+ // Kill any existing FrameHost for this UDID.
+ if err := stopFrameHost(udid); err != nil {
+ return err
+ }
+ _ = os.Remove(statusFilePath(udid))
+
+ hostArgs := []string{
+ "--udid", udid,
+ "--width", strconv.Itoa(camStartWidth),
+ "--height", strconv.Itoa(camStartHeight),
+ "--fps", strconv.Itoa(camStartFPS),
+ }
+ if camStartBars {
+ hostArgs = append(hostArgs, "--bars")
+ } else if camStartCamera {
+ hostArgs = append(hostArgs, "--camera")
+ if camStartCameraID != "" {
+ hostArgs = append(hostArgs, "--camera-id", camStartCameraID)
+ }
+ if camStartScaleMode != "" {
+ hostArgs = append(hostArgs, "--scale-mode", camStartScaleMode)
+ }
+ } else {
+ absImage, err := filepath.Abs(camStartImage)
+ if err != nil {
+ return fmt.Errorf("cannot resolve image path: %w", err)
+ }
+ hostArgs = append(hostArgs, "--image", absImage)
+ }
+
+ c := exec.Command(bin, hostArgs...)
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ // Detach from the terminal so FrameHost survives terminal close,
+ // EXCEPT when using the camera, as detaching breaks TCC (camera) permission inheritance.
+ if !camStartCamera {
+ c.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ }
+ if err := c.Start(); err != nil {
+ return fmt.Errorf("failed to start FrameHost: %w", err)
+ }
+
+ if err := waitForFrameHostReady(c, statusFilePath(udid)); err != nil {
+ return err
+ }
+
+ source := camStartImage
+ if camStartBars {
+ source = "color-bars"
+ } else if camStartCamera {
+ if camStartCameraID != "" {
+ source = camStartCameraID
+ } else {
+ source = "mac-camera (default)"
+ }
+ }
+ PrintSuccess(fmt.Sprintf(
+ "FrameHost started — source=%s %dx%d @ %d fps (simulator %s)",
+ source, camStartWidth, camStartHeight, camStartFPS, udid,
+ ))
+ return nil
+ },
+}
+
+// ---------------------------------------------------------------------------
+// sim cam list
+// ---------------------------------------------------------------------------
+
+var camListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all available cameras on this Mac",
+ Long: `Enumerate video capture devices available to FrameHost.
+Includes built-in FaceTime camera, Continuity Camera (iPhone/iPad), and external USB cameras.
+
+Requires FrameHost to be built first (sim cam build).
+
+Example:
+ sim cam list`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam list is only supported on macOS")
+ }
+ mscDir := miniSimCamDir()
+ bin := frameHostBin(mscDir)
+ if _, err := os.Stat(bin); err != nil {
+ return fmt.Errorf("FrameHost not found — run 'sim cam build' first (or use a release build with embedded binaries)")
+ }
+ c := exec.Command(bin, "--list-cameras", "--udid", "00000000-0000-0000-0000-000000000000")
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return c.Run()
+ },
+}
+
+// ---------------------------------------------------------------------------
+// sim cam launch
+// ---------------------------------------------------------------------------
+
+var (
+ camLaunchBundle string
+ camLaunchDevice string
+)
+
+var camLaunchCmd = &cobra.Command{
+ Use: "launch",
+ Short: "Launch an app on the simulator with MiniCamInject loaded",
+ Long: `Launches your app on the booted iOS Simulator with MiniCamInject.dylib
+injected via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES.
+
+The FrameHost must be running first (sim cam start).
+
+Example:
+ sim cam launch --bundle-id com.example.CameraPreviewApp`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam launch is only supported on macOS")
+ }
+ if camLaunchBundle == "" {
+ return fmt.Errorf("--bundle-id is required")
+ }
+
+ udid, _, err := findRunningIOSSimulator(camLaunchDevice)
+ if err != nil || udid == "" {
+ return fmt.Errorf("no booted iOS simulator found (%w)", err)
+ }
+
+ mscDir := miniSimCamDir()
+ dylib := injectorDylib(mscDir)
+ if _, err := os.Stat(dylib); err != nil {
+ return fmt.Errorf("MiniCamInject.dylib not found — run 'sim cam build' first (or use a release build with embedded binaries)")
+ }
+
+ shm := shmPath(udid)
+ fps := frameHostFPS(udid)
+
+ PrintInfo(fmt.Sprintf("Launching %s on %s", camLaunchBundle, udid))
+ PrintInfo(fmt.Sprintf(" dylib: %s", dylib))
+ PrintInfo(fmt.Sprintf(" shm: %s", shm))
+ PrintInfo(fmt.Sprintf(" fps: %d", fps))
+
+ // Ensure get-task-allow entitlement is present so DYLD_INSERT_LIBRARIES works.
+ appPathBytes, err := exec.Command("xcrun", "simctl", "get_app_container", udid, camLaunchBundle, "app").Output()
+ if err == nil {
+ appPath := strings.TrimSpace(string(appPathBytes))
+ entOut, _ := exec.Command("codesign", "-d", "--entitlements", ":-", appPath).Output()
+ entXML := string(entOut)
+
+ if !strings.Contains(entXML, "com.apple.security.get-task-allow") {
+ PrintInfo(" Injecting get-task-allow entitlement to permit dylib injection...")
+ if strings.Contains(entXML, "") {
+ entXML = strings.Replace(entXML, "", "\n\tcom.apple.security.get-task-allow\n\t", 1)
+ } else {
+ entXML = `com.apple.security.get-task-allow`
+ }
+ entPath := filepath.Join(os.TempDir(), "minisimcam_entitlements.plist")
+ if writeErr := os.WriteFile(entPath, []byte(entXML), 0o644); writeErr != nil {
+ PrintInfo(fmt.Sprintf(" Warning: cannot write entitlements file: %v", writeErr))
+ } else if signErr := exec.Command("codesign", "-f", "-s", "-", "--entitlements", entPath, appPath).Run(); signErr != nil {
+ PrintInfo(fmt.Sprintf(" Warning: codesign re-sign failed: %v", signErr))
+ } else {
+ PrintInfo(" Re-signed with get-task-allow.")
+ }
+ }
+ }
+
+ c := exec.Command("xcrun", "simctl", "launch", udid, camLaunchBundle)
+ c.Env = append(
+ os.Environ(),
+ "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib,
+ "SIMCTL_CHILD_MINISIMCAM_PATH="+shm,
+ "SIMCTL_CHILD_MINISIMCAM_FPS="+strconv.Itoa(fps),
+ )
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ if err := c.Run(); err != nil {
+ return fmt.Errorf("simctl launch failed: %w", err)
+ }
+
+ PrintSuccess(fmt.Sprintf("Launched %s with MiniCamInject", camLaunchBundle))
+ return nil
+ },
+}
+
+// ---------------------------------------------------------------------------
+// sim cam status
+// ---------------------------------------------------------------------------
+
+var camStatusDevice string
+
+// camFrameLoopStatus mirrors FrameLoopStatus defined in FrameLoop.swift and
+// the richer status written by CameraSource.
+type camFrameLoopStatus struct {
+ UDID string `json:"udid"`
+ Source string `json:"source"`
+ CameraName string `json:"cameraName,omitempty"`
+ CameraType string `json:"cameraType,omitempty"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FPS int `json:"fps"`
+ FramesProduced uint64 `json:"framesProduced"`
+ HostPID int32 `json:"hostPID"`
+ StartedAt string `json:"startedAt"`
+ LastFrameAgeMs float64 `json:"lastFrameAgeMs"`
+ LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
+ Running bool `json:"running"`
+}
+
+var camStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Show FrameHost status and frame statistics",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam status is only supported on macOS")
+ }
+
+ udid, name, err := findRunningIOSSimulator(camStatusDevice)
+ if err != nil || udid == "" {
+ return fmt.Errorf("no booted iOS simulator found (%w)", err)
+ }
+
+ statusPath := statusFilePath(udid)
+ data, err := os.ReadFile(statusPath)
+ if err != nil {
+ PrintInfo(fmt.Sprintf("No status file found at %s", statusPath))
+ PrintInfo("Is 'sim cam start' running?")
+ return nil
+ }
+
+ var st camFrameLoopStatus
+ if err := json.Unmarshal(data, &st); err != nil {
+ return fmt.Errorf("cannot parse status file: %w", err)
+ }
+
+ lines := []string{
+ fmt.Sprintf("Simulator: %s (%s)", name, udid),
+ fmt.Sprintf("Source: %s", st.Source),
+ }
+ if st.CameraName != "" {
+ lines = append(lines, fmt.Sprintf("Camera: %s", st.CameraName))
+ }
+ if st.CameraType != "" {
+ lines = append(lines, fmt.Sprintf("Camera type: %s", st.CameraType))
+ }
+ if st.Source == "disconnected" && st.LastDisconnectedAt != "" {
+ lines = append(lines, fmt.Sprintf("⚠️ Disconnected at: %s", st.LastDisconnectedAt))
+ }
+ lines = append(
+ lines,
+ fmt.Sprintf("Resolution: %dx%d BGRA", st.Width, st.Height),
+ fmt.Sprintf("Frame rate: %d fps", st.FPS),
+ fmt.Sprintf("Frames produced: %d", st.FramesProduced),
+ fmt.Sprintf("Last frame age: %.0f ms", st.LastFrameAgeMs),
+ fmt.Sprintf("Host PID: %d", st.HostPID),
+ fmt.Sprintf("Started at: %s", st.StartedAt),
+ fmt.Sprintf("Running: %v", st.Running),
+ )
+
+ width := 0
+ for _, l := range lines {
+ if len(l) > width {
+ width = len(l)
+ }
+ }
+
+ border := strings.Repeat("─", width+4)
+ fmt.Println("┌" + border + "┐")
+ fmt.Println("│ MiniSimCam Status" + strings.Repeat(" ", width+4-len(" MiniSimCam Status")) + "│")
+ fmt.Println("├" + border + "┤")
+ for _, l := range lines {
+ fmt.Printf("│ %-*s │\n", width, l)
+ }
+ fmt.Println("└" + border + "┘")
+ return nil
+ },
+}
+
+// ---------------------------------------------------------------------------
+// sim cam stop
+// ---------------------------------------------------------------------------
+
+var camStopDevice string
+
+var camStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop the FrameHost process",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runtime.GOOS != DarwinOS {
+ return fmt.Errorf("cam stop is only supported on macOS")
+ }
+
+ udid, _, err := findRunningIOSSimulator(camStopDevice)
+ if err != nil || udid == "" {
+ if camStopDevice == "" {
+ _ = stopFrameHost("")
+ PrintSuccess("All FrameHost processes stopped.")
+ return nil
+ }
+ _ = stopFrameHost(camStopDevice)
+ PrintSuccess("FrameHost stopped.")
+ return nil
+ }
+
+ if err := stopFrameHost(udid); err != nil {
+ return err
+ }
+ PrintSuccess("FrameHost stopped.")
+ return nil
+ },
+}
+
+func setGlobalSimEnv(udid string, fps int) {
+ if udid == "" {
+ return
+ }
+ mscDir := miniSimCamDir()
+ dylib := injectorDylib(mscDir)
+ shm := shmPath(udid)
+
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", dylib).Run()
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_PATH", shm).Run()
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_FPS", strconv.Itoa(fps)).Run()
+}
+
+func unsetGlobalSimEnv(udid string) {
+ if udid == "" {
+ return
+ }
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "DYLD_INSERT_LIBRARIES").Run()
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "MINISIMCAM_PATH").Run()
+ _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "MINISIMCAM_FPS").Run()
+}
+
+// stopFrameHost scans all running processes and terminates ALL FrameHost processes
+// associated with the target UDID immediately.
+func stopFrameHost(udid string) error {
+ pidPath := pidFilePath(udid)
+ statusPath := statusFilePath(udid)
+
+ unsetGlobalSimEnv(udid)
+
+ defer func() {
+ if udid != "" {
+ _ = os.Remove(pidPath)
+ _ = os.Remove(statusPath)
+ _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.pid", udid))
+ _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.status", udid))
+ } else {
+ if files, err := filepath.Glob("/tmp/minisimcam.*.pid"); err == nil {
+ for _, f := range files {
+ _ = os.Remove(f)
+ }
+ }
+ if files, err := filepath.Glob("/tmp/minisimcam.*.status"); err == nil {
+ for _, f := range files {
+ _ = os.Remove(f)
+ }
+ }
+ if files, err := filepath.Glob(filepath.Join(os.TempDir(), "minisimcam.*.status")); err == nil {
+ for _, f := range files {
+ _ = os.Remove(f)
+ }
+ }
+ }
+ }()
+
+ out, err := exec.Command("ps", "-eo", "pid,command").Output()
+ if err != nil {
+ return nil
+ }
+
+ lines := strings.Split(string(out), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if strings.Contains(line, "FrameHost") && (udid == "" || strings.Contains(line, udid)) {
+ fields := strings.Fields(line)
+ if len(fields) > 0 {
+ if pid, err := strconv.Atoi(fields[0]); err == nil {
+ if proc, err := os.FindProcess(pid); err == nil {
+ _ = proc.Kill()
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// init: register sub-commands and flags
+// ---------------------------------------------------------------------------
+
+func init() {
+ // cam-level flag: --msc-dir override (hidden for end users)
+ camCmd.PersistentFlags().StringVar(&camMscDir, "msc-dir", "", "Path to MiniSimCam directory (default: auto-detected)")
+ _ = camCmd.PersistentFlags().MarkHidden("msc-dir")
+
+ // start
+ camStartCmd.Flags().StringVar(&camStartImage, "image", "", "Path to PNG or JPEG source image")
+ camStartCmd.Flags().BoolVar(&camStartBars, "bars", false, "Use synthetic SMPTE color-bar image")
+ camStartCmd.Flags().BoolVar(&camStartCamera, "camera", false, "Use the Mac's physical camera as a live source")
+ camStartCmd.Flags().StringVar(&camStartCameraID, "camera-id", "", "Camera name (substring) or uniqueID to select (requires --camera)")
+ camStartCmd.Flags().StringVar(&camStartScaleMode, "scale-mode", "", "How to scale: 'fill' (crop, fast) or 'fit' (letterbox, no crop). Requires --camera. (default: fill)")
+ camStartCmd.Flags().IntVar(&camStartWidth, "width", DefaultCamWidth, "Frame width in pixels")
+ camStartCmd.Flags().IntVar(&camStartHeight, "height", DefaultCamHeight, "Frame height in pixels")
+ camStartCmd.Flags().IntVar(&camStartFPS, "fps", DefaultCamFPS, "Frames per second (1-120)")
+ camStartCmd.Flags().StringVar(&camStartDevice, "device", "", "Simulator name or UDID (default: booted)")
+ camCmd.AddCommand(camStartCmd)
+
+ // list
+ camCmd.AddCommand(camListCmd)
+
+ // launch
+ camLaunchCmd.Flags().StringVar(&camLaunchBundle, "bundle-id", "", "App bundle identifier (required)")
+ camLaunchCmd.Flags().StringVar(&camLaunchDevice, "device", "", "Simulator name or UDID (default: booted)")
+ _ = camLaunchCmd.MarkFlagRequired("bundle-id")
+ camCmd.AddCommand(camLaunchCmd)
+
+ // status
+ camStatusCmd.Flags().StringVar(&camStatusDevice, "device", "", "Simulator name or UDID (default: booted)")
+ camCmd.AddCommand(camStatusCmd)
+
+ // stop
+ camStopCmd.Flags().StringVar(&camStopDevice, "device", "", "Simulator name or UDID (default: booted)")
+ camCmd.AddCommand(camStopCmd)
+}
diff --git a/cmd/cam_dashboard.go b/cmd/cam_dashboard.go
new file mode 100644
index 0000000..b1d90f3
--- /dev/null
+++ b/cmd/cam_dashboard.go
@@ -0,0 +1,815 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type focusPane int
+
+const (
+ paneSimulators focusPane = iota
+ paneCameras
+ paneApps
+)
+
+type SimCamApp struct {
+ BundleID string `json:"CFBundleIdentifier"`
+ DisplayName string `json:"CFBundleDisplayName"`
+ Name string `json:"CFBundleName"`
+ ApplicationType string `json:"ApplicationType"`
+}
+
+func (a SimCamApp) Title() string {
+ if a.DisplayName != "" {
+ return a.DisplayName
+ }
+ if a.Name != "" {
+ return a.Name
+ }
+ return a.BundleID
+}
+
+type camSimDevice struct {
+ Device
+ CamStatus string
+ CamSource string
+ CamFPS int
+}
+
+type ResolutionPreset struct {
+ title string
+ description string
+ width int
+ height int
+}
+
+func (r ResolutionPreset) Title() string { return r.title }
+func (r ResolutionPreset) Description() string { return r.description }
+func (r ResolutionPreset) FilterValue() string { return r.title }
+
+type camDashboardModel struct {
+ focused focusPane
+ simTable table.Model
+ appTable table.Model
+ cameraTable table.Model
+ presetList list.Model
+ filterInput textinput.Model
+ spinner spinner.Model
+ simulators []camSimDevice
+ allApps []SimCamApp
+ filteredApps []SimCamApp
+ cameras []CameraInfo
+ selectedSimUDID string
+ selectedSimName string
+ showSystemApps bool
+ showPopup bool
+ camWidth int
+ camHeight int
+ msg string
+ loading bool
+ width int
+ height int
+}
+
+type (
+ camRefreshMsg []camSimDevice
+ appsFetchedMsg []SimCamApp
+ camerasFetchedMsg []CameraInfo
+)
+
+type CameraInfo struct {
+ UniqueID string `json:"uniqueID"`
+ LocalizedName string `json:"localizedName"`
+ TypeLabel string `json:"typeLabel"`
+}
+
+func fetchBootedSimulatorsWithCamStatus() []camSimDevice {
+ if runtime.GOOS != DarwinOS {
+ return nil
+ }
+
+ allSims := GetIOSSimulators()
+ var booted []camSimDevice
+
+ for _, sim := range allSims {
+ if sim.State == StateBooted || sim.State == "Booted" {
+ dev := camSimDevice{
+ Device: sim,
+ CamStatus: "Stopped",
+ CamSource: "-",
+ CamFPS: 0,
+ }
+
+ // Read status file if running
+ statusPath := statusFilePath(sim.UDID)
+ if data, err := os.ReadFile(statusPath); err == nil {
+ var st camFrameLoopStatus
+ if json.Unmarshal(data, &st) == nil && st.Running {
+ dev.CamStatus = "Running"
+ dev.CamSource = st.Source
+ dev.CamFPS = st.FPS
+ }
+ }
+
+ booted = append(booted, dev)
+ }
+ }
+
+ sort.Slice(booted, func(i, j int) bool {
+ return booted[i].Name < booted[j].Name
+ })
+
+ return booted
+}
+
+func fetchInstalledApps(udid string) ([]SimCamApp, error) {
+ cmd := fmt.Sprintf("xcrun simctl listapps %s | plutil -convert json - -o -", udid)
+ out, err := exec.Command("sh", "-c", cmd).Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list apps: %w", err)
+ }
+
+ var rawMap map[string]SimCamApp
+ if err := json.Unmarshal(out, &rawMap); err != nil {
+ return nil, fmt.Errorf("failed to parse apps JSON: %w", err)
+ }
+
+ var apps []SimCamApp
+ for bundleID, app := range rawMap {
+ if app.BundleID == "" {
+ app.BundleID = bundleID
+ }
+ if strings.HasPrefix(app.BundleID, "com.apple.Process") || strings.HasPrefix(app.BundleID, "com.apple.CoreSimulator") {
+ continue
+ }
+ apps = append(apps, app)
+ }
+
+ sort.Slice(apps, func(i, j int) bool {
+ return strings.ToLower(apps[i].Title()) < strings.ToLower(apps[j].Title())
+ })
+
+ return apps, nil
+}
+
+func refreshCamDevicesCmd() tea.Cmd {
+ return func() tea.Msg {
+ return camRefreshMsg(fetchBootedSimulatorsWithCamStatus())
+ }
+}
+
+func refreshCamStatusOnlyCmd(sims []camSimDevice) tea.Cmd {
+ return func() tea.Msg {
+ booted := make([]camSimDevice, 0, len(sims))
+ for _, dev := range sims {
+ dev.CamStatus = "Stopped"
+ dev.CamSource = "-"
+ dev.CamFPS = 0
+
+ statusPath := statusFilePath(dev.UDID)
+ if data, err := os.ReadFile(statusPath); err == nil {
+ var st camFrameLoopStatus
+ if json.Unmarshal(data, &st) == nil && st.Running {
+ dev.CamStatus = "Running"
+ dev.CamSource = st.Source
+ dev.CamFPS = st.FPS
+ }
+ }
+ booted = append(booted, dev)
+ }
+ return camRefreshMsg(booted)
+ }
+}
+
+func fetchAppsCmd(udid string) tea.Cmd {
+ return func() tea.Msg {
+ apps, err := fetchInstalledApps(udid)
+ if err != nil {
+ return actionDoneMsg{msg: "Error fetching apps: " + err.Error()}
+ }
+ return appsFetchedMsg(apps)
+ }
+}
+
+func getAvailableCameras() ([]CameraInfo, error) {
+ mscDir := miniSimCamDir()
+ bin := frameHostBin(mscDir)
+ if _, err := os.Stat(bin); err != nil {
+ return nil, fmt.Errorf("FrameHost binary not found")
+ }
+
+ out, err := exec.Command(bin, "--list-cameras", "--json").Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list cameras: %w", err)
+ }
+
+ var cameras []CameraInfo
+ if err := json.Unmarshal(out, &cameras); err != nil {
+ return nil, fmt.Errorf("failed to parse cameras: %w", err)
+ }
+ return cameras, nil
+}
+
+func fetchCamerasCmd() tea.Cmd {
+ return func() tea.Msg {
+ cams, err := getAvailableCameras()
+ if err != nil {
+ return actionDoneMsg{msg: "Error fetching cameras: " + err.Error()}
+ }
+ return camerasFetchedMsg(cams)
+ }
+}
+
+func (m camDashboardModel) Init() tea.Cmd {
+ var cmds []tea.Cmd
+ cmds = append(cmds, m.spinner.Tick, refreshCamDevicesCmd(), camTickCmd())
+ if len(m.simulators) > 0 {
+ cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID))
+ }
+ return tea.Batch(cmds...)
+}
+
+type camTickMsg time.Time
+
+func camTickCmd() tea.Cmd {
+ return tea.Tick(time.Second*5, func(t time.Time) tea.Msg {
+ return camTickMsg(t)
+ })
+}
+
+func updateTableFocus(m *camDashboardModel) {
+ m.simTable.Focus()
+ m.cameraTable.Focus()
+ m.appTable.Focus()
+
+ if m.focused != paneSimulators {
+ m.simTable.Blur()
+ }
+ if m.focused != paneCameras {
+ m.cameraTable.Blur()
+ }
+ if m.focused != paneApps {
+ m.appTable.Blur()
+ }
+}
+
+func (m *camDashboardModel) updateFilteredApps() {
+ var filtered []SimCamApp
+ query := strings.ToLower(m.filterInput.Value())
+
+ for _, app := range m.allApps {
+ if !m.showSystemApps && app.ApplicationType == "System" {
+ continue
+ }
+ if query != "" {
+ titleMatch := strings.Contains(strings.ToLower(app.Title()), query)
+ bundleMatch := strings.Contains(strings.ToLower(app.BundleID), query)
+ if !titleMatch && !bundleMatch {
+ continue
+ }
+ }
+ filtered = append(filtered, app)
+ }
+ m.filteredApps = filtered
+
+ rows := make([]table.Row, 0, len(filtered))
+ for _, app := range filtered {
+ appType := app.ApplicationType
+ if appType == "" {
+ appType = "User"
+ }
+ rows = append(rows, table.Row{
+ app.Title(),
+ app.BundleID,
+ appType,
+ })
+ }
+ m.appTable.SetRows(rows)
+}
+
+func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.MouseMsg:
+ if m.showPopup {
+ break
+ }
+ if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
+ availWidth := m.width - 8
+ if availWidth < 90 {
+ availWidth = 90
+ }
+ w1 := int(float64(availWidth)*0.35) + 2
+ w2 := int(float64(availWidth)*0.30) + 2
+
+ oldFocus := m.focused
+ if msg.X < w1 {
+ m.focused = paneSimulators
+ } else if msg.X < w1+w2 {
+ m.focused = paneCameras
+ } else {
+ m.focused = paneApps
+ }
+ if oldFocus != m.focused {
+ updateTableFocus(&m)
+ }
+ }
+
+ var cmd tea.Cmd
+ switch m.focused {
+ case paneSimulators:
+ m.simTable, cmd = m.simTable.Update(msg)
+ case paneCameras:
+ m.cameraTable, cmd = m.cameraTable.Update(msg)
+ case paneApps:
+ m.appTable, cmd = m.appTable.Update(msg)
+ }
+ cmds = append(cmds, cmd)
+
+ case tea.KeyMsg:
+ if m.showPopup {
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "esc":
+ m.showPopup = false
+ return m, nil
+ case "enter":
+ if i, ok := m.presetList.SelectedItem().(ResolutionPreset); ok {
+ m.camWidth = i.width
+ m.camHeight = i.height
+ m.msg = fmt.Sprintf("Resolution set to %dx%d", i.width, i.height)
+ }
+ m.showPopup = false
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.presetList, cmd = m.presetList.Update(msg)
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+ }
+
+ if m.focused == paneApps && m.filterInput.Focused() {
+ if msg.String() == "enter" || msg.String() == "esc" {
+ m.filterInput.Blur()
+ } else {
+ var inputCmd tea.Cmd
+ m.filterInput, inputCmd = m.filterInput.Update(msg)
+ m.updateFilteredApps()
+ cmds = append(cmds, inputCmd)
+ return m, tea.Batch(cmds...)
+ }
+ } else {
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "tab", "l", "right":
+ m.focused = (m.focused + 1) % 3
+ updateTableFocus(&m)
+ return m, nil
+ case "shift+tab", "h", "left":
+ m.focused = (m.focused + 2) % 3
+ updateTableFocus(&m)
+ return m, nil
+ case "o":
+ m.showPopup = true
+ return m, nil
+ case "x":
+ if m.selectedSimUDID != "" {
+ m.loading = true
+ m.msg = "Stopping camera feed for " + m.selectedSimName + "..."
+ cmds = append(cmds, doActionCmd(func() error {
+ return stopFrameHost(m.selectedSimUDID)
+ }, "Stopped camera feed for "+m.selectedSimName))
+ }
+ case "r":
+ m.loading = true
+ m.msg = "Refreshing..."
+ cmds = append(cmds, refreshCamDevicesCmd())
+ case "t":
+ if m.focused == paneApps {
+ m.showSystemApps = !m.showSystemApps
+ m.updateFilteredApps()
+ }
+ case "/":
+ if m.focused == paneApps {
+ m.filterInput.Focus()
+ }
+ case "enter":
+ switch m.focused {
+ case paneSimulators:
+ m.focused = paneCameras
+ updateTableFocus(&m)
+ case paneCameras:
+ idx := m.cameraTable.Cursor()
+ if idx >= 0 && idx < len(m.cameras) {
+ camID := m.cameras[idx].UniqueID
+ m.loading = true
+ m.msg = "Starting camera feed for " + m.selectedSimName + "..."
+ cmds = append(cmds, doActionCmd(func() error {
+ return startCameraForDevice(m.selectedSimUDID, true, "", camID, m.camWidth, m.camHeight)
+ }, "Started camera feed for "+m.selectedSimName))
+ }
+ case paneApps:
+ idx := m.appTable.Cursor()
+ if idx >= 0 && idx < len(m.filteredApps) {
+ bundleID := m.filteredApps[idx].BundleID
+ appName := m.filteredApps[idx].Title()
+ m.loading = true
+ m.msg = fmt.Sprintf("Launching %s...", appName)
+ udid := m.selectedSimUDID
+ cmds = append(cmds, doActionCmd(func() error {
+ return launchAppWithCam(udid, bundleID)
+ }, fmt.Sprintf("Launched %s!", appName)))
+ }
+ }
+ }
+ }
+
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+
+ tHeight := m.height - 8
+ if tHeight < 5 {
+ tHeight = 5
+ }
+
+ availWidth := m.width - 8
+ if availWidth < 90 {
+ availWidth = 90
+ }
+
+ w1 := int(float64(availWidth) * 0.35)
+ w2 := int(float64(availWidth) * 0.30)
+ w3 := availWidth - (w1 + w2)
+
+ m.simTable.SetHeight(tHeight - 2)
+ m.cameraTable.SetHeight(tHeight - 2)
+ m.appTable.SetHeight(tHeight - 3)
+
+ sw1 := int(float64(w1) * 0.5)
+ sw2 := int(float64(w1) * 0.25)
+ sw3 := w1 - (sw1 + sw2) - 4
+ m.simTable.SetColumns([]table.Column{
+ {Title: "Name", Width: sw1},
+ {Title: "Status", Width: sw2},
+ {Title: "Source", Width: sw3},
+ })
+
+ cw1 := int(float64(w2) * 0.5)
+ cw2 := int(float64(w2) * 0.3)
+ cw3 := w2 - (cw1 + cw2) - 4
+ m.cameraTable.SetColumns([]table.Column{
+ {Title: "Camera", Width: cw1},
+ {Title: "Type", Width: cw2},
+ {Title: "ID", Width: cw3},
+ })
+
+ aw1 := int(float64(w3) * 0.4)
+ aw2 := int(float64(w3) * 0.4)
+ aw3 := w3 - (aw1 + aw2) - 4
+ m.appTable.SetColumns([]table.Column{
+ {Title: "App Name", Width: aw1},
+ {Title: "Bundle ID", Width: aw2},
+ {Title: "Type", Width: aw3},
+ })
+
+ m.presetList.SetSize(60, 20)
+
+ case camRefreshMsg:
+ rows := make([]table.Row, 0, len(msg))
+ for _, d := range msg {
+ rows = append(rows, table.Row{
+ d.Name,
+ d.CamStatus,
+ d.CamSource,
+ })
+ }
+ m.simTable.SetRows(rows)
+ m.simulators = msg
+ m.loading = false
+ if m.msg == "Refreshing..." {
+ m.msg = "Refreshed list."
+ }
+
+ if len(m.simulators) > 0 && m.selectedSimUDID == "" {
+ m.selectedSimUDID = m.simulators[0].UDID
+ m.selectedSimName = m.simulators[0].Name
+ cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID))
+ }
+
+ case appsFetchedMsg:
+ m.allApps = msg
+ m.updateFilteredApps()
+ m.loading = false
+ m.msg = fmt.Sprintf("Loaded %d apps.", len(msg))
+
+ case camerasFetchedMsg:
+ m.cameras = msg
+ rows := make([]table.Row, 0, len(msg))
+ for _, cam := range msg {
+ displayID := cam.UniqueID
+ if len(displayID) > 8 {
+ displayID = displayID[:8] + "…"
+ }
+ rows = append(rows, table.Row{
+ cam.LocalizedName,
+ cam.TypeLabel,
+ displayID,
+ })
+ }
+ m.cameraTable.SetRows(rows)
+ m.loading = false
+ m.msg = fmt.Sprintf("Found %d cameras.", len(msg))
+
+ case actionDoneMsg:
+ m.msg = msg.msg
+ m.loading = false
+ cmds = append(cmds, refreshCamStatusOnlyCmd(m.simulators))
+
+ case camTickMsg:
+ cmds = append(cmds, camTickCmd(), refreshCamDevicesCmd())
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+ }
+
+ oldSimUDID := m.selectedSimUDID
+
+ if m.focused == paneSimulators && !m.showPopup {
+ var tableCmd tea.Cmd
+ m.simTable, tableCmd = m.simTable.Update(msg)
+ cmds = append(cmds, tableCmd)
+
+ idx := m.simTable.Cursor()
+ if idx >= 0 && idx < len(m.simulators) {
+ m.selectedSimUDID = m.simulators[idx].UDID
+ m.selectedSimName = m.simulators[idx].Name
+ }
+ } else if m.focused == paneCameras && !m.showPopup {
+ var camTableCmd tea.Cmd
+ m.cameraTable, camTableCmd = m.cameraTable.Update(msg)
+ cmds = append(cmds, camTableCmd)
+ } else if m.focused == paneApps && !m.showPopup {
+ var appTableCmd tea.Cmd
+ m.appTable, appTableCmd = m.appTable.Update(msg)
+ cmds = append(cmds, appTableCmd)
+ }
+
+ if oldSimUDID != m.selectedSimUDID && m.selectedSimUDID != "" {
+ m.loading = true
+ m.msg = "Loading details for " + m.selectedSimName + "..."
+ cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID))
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+func (m camDashboardModel) View() string {
+ activeColor := lipgloss.Color("62") // Purple highlight
+ inactiveColor := lipgloss.Color("240") // Gray
+
+ borderStyle := func(pane focusPane) lipgloss.Style {
+ c := inactiveColor
+ if m.focused == pane {
+ c = activeColor
+ }
+ return lipgloss.NewStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(c).
+ Height(m.height - 4)
+ }
+
+ pane1 := borderStyle(paneSimulators).Render(
+ lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render("Booted Simulators") + "\n\n" + m.simTable.View(),
+ )
+
+ pane2 := borderStyle(paneCameras).Render(
+ lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render("Cameras") + "\n\n" + m.cameraTable.View(),
+ )
+
+ appHeader := "Apps"
+ if m.showSystemApps {
+ appHeader += " [All]"
+ } else {
+ appHeader += " [User]"
+ }
+ pane3 := borderStyle(paneApps).Render(
+ lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render(appHeader) + "\n" +
+ " Search (/): " + m.filterInput.View() + "\n" +
+ m.appTable.View(),
+ )
+
+ mainContent := lipgloss.JoinHorizontal(lipgloss.Top, pane1, pane2, pane3)
+
+ footer := " Controls: [tab/h/l] Switch Pane • [j/k] Navigate • [enter] Action • [x] Stop Cam • [o] Resolution • [t] Toggle SysApps • [r] Refresh • [q] Quit\n "
+ if m.loading {
+ footer += m.spinner.View() + " " + lipgloss.NewStyle().Foreground(ColorIOS).Render(m.msg)
+ } else if m.msg != "" {
+ footer += "ℹ " + lipgloss.NewStyle().Foreground(ColorIOS).Render(m.msg)
+ }
+
+ ui := lipgloss.JoinVertical(lipgloss.Left, mainContent, footer)
+
+ if m.showPopup {
+ popupStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("62")).
+ Padding(1, 2).
+ Background(lipgloss.Color("236")).
+ Foreground(lipgloss.Color("252"))
+
+ popupView := popupStyle.Render(m.presetList.View())
+
+ // Create a full screen container and place the popup in the center
+ ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popupView, lipgloss.WithWhitespaceChars(" "))
+ }
+
+ return ui
+}
+
+func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraID string, width, height int) error {
+ mscDir := miniSimCamDir()
+ bin := frameHostBin(mscDir)
+ if _, err := os.Stat(bin); err != nil {
+ return fmt.Errorf("FrameHost binary not found")
+ }
+
+ _ = stopFrameHost(udid)
+ _ = os.Remove(statusFilePath(udid))
+
+ args := []string{
+ "--udid", udid,
+ "--width", fmt.Sprintf("%d", width),
+ "--height", fmt.Sprintf("%d", height),
+ "--fps", fmt.Sprintf("%d", DefaultCamFPS),
+ }
+
+ if useCamera {
+ args = append(args, "--camera")
+ if cameraID != "" {
+ args = append(args, "--camera-id", cameraID)
+ }
+ } else if imagePath != "" {
+ args = append(args, "--image", imagePath)
+ } else {
+ args = append(args, "--bars")
+ }
+
+ c := exec.Command(bin, args...)
+ if !useCamera {
+ c.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ }
+ if err := c.Start(); err != nil {
+ return err
+ }
+
+ setGlobalSimEnv(udid, DefaultCamFPS)
+
+ return waitForFrameHostReady(c, statusFilePath(udid))
+}
+
+func launchAppWithCam(udid, bundleID string) error {
+ mscDir := miniSimCamDir()
+ dylib := injectorDylib(mscDir)
+ if _, err := os.Stat(dylib); err != nil {
+ return fmt.Errorf("MiniCamInject.dylib not found")
+ }
+
+ shm := shmPath(udid)
+ fps := frameHostFPS(udid)
+
+ c := exec.Command("xcrun", "simctl", "launch", udid, bundleID)
+ c.Env = append(os.Environ(),
+ "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib,
+ "SIMCTL_CHILD_MINISIMCAM_PATH="+shm,
+ "SIMCTL_CHILD_MINISIMCAM_FPS="+fmt.Sprintf("%d", fps),
+ )
+ return c.Run()
+}
+
+func runCamDashboard() error {
+ initialSims := fetchBootedSimulatorsWithCamStatus()
+
+ simCols := []table.Column{
+ {Title: "Name", Width: 20},
+ {Title: "Status", Width: 15},
+ {Title: "Source", Width: 15},
+ }
+
+ simRows := make([]table.Row, 0, len(initialSims))
+ for _, d := range initialSims {
+ simRows = append(simRows, table.Row{
+ d.Name,
+ d.CamStatus,
+ d.CamSource,
+ })
+ }
+
+ tSim := table.New(
+ table.WithColumns(simCols),
+ table.WithRows(simRows),
+ table.WithFocused(true),
+ table.WithHeight(12),
+ )
+
+ tApp := table.New(
+ table.WithColumns([]table.Column{
+ {Title: "App Name", Width: 20},
+ {Title: "Bundle ID", Width: 20},
+ {Title: "Type", Width: 10},
+ }),
+ table.WithFocused(false),
+ table.WithHeight(10),
+ )
+
+ tCam := table.New(
+ table.WithColumns([]table.Column{
+ {Title: "Camera Name", Width: 20},
+ {Title: "Type", Width: 15},
+ {Title: "Unique ID", Width: 15},
+ }),
+ table.WithFocused(false),
+ table.WithHeight(10),
+ )
+
+ s := table.DefaultStyles()
+ s.Header = s.Header.
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240")).
+ BorderBottom(true).
+ Bold(false)
+ s.Selected = s.Selected.
+ Foreground(lipgloss.Color("229")).
+ Background(lipgloss.Color("57")).
+ Bold(false)
+
+ tSim.SetStyles(s)
+ tApp.SetStyles(s)
+ tCam.SetStyles(s)
+
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ sp.Style = lipgloss.NewStyle().Foreground(ColorHeader)
+
+ ti := textinput.New()
+ ti.Placeholder = "Search..."
+ ti.CharLimit = 156
+ ti.Width = 20
+
+ presetItems := []list.Item{
+ ResolutionPreset{title: "720p (16:9)", description: "1280 x 720 (Default)", width: 1280, height: 720},
+ ResolutionPreset{title: "1080p (16:9)", description: "1920 x 1080", width: 1920, height: 1080},
+ ResolutionPreset{title: "4K (16:9)", description: "3840 x 2160", width: 3840, height: 2160},
+ ResolutionPreset{title: "Vertical HD (9:16)", description: "1080 x 1920", width: 1080, height: 1920},
+ ResolutionPreset{title: "Square (1:1)", description: "1080 x 1080", width: 1080, height: 1080},
+ }
+ presetList := list.New(presetItems, list.NewDefaultDelegate(), 60, 20)
+ presetList.Title = "Select Camera Resolution & Aspect Ratio"
+ presetList.SetShowHelp(false)
+ presetList.SetShowStatusBar(false)
+
+ m := camDashboardModel{
+ focused: paneSimulators,
+ simTable: tSim,
+ appTable: tApp,
+ cameraTable: tCam,
+ presetList: presetList,
+ filterInput: ti,
+ spinner: sp,
+ msg: "Ready.",
+ simulators: initialSims,
+ camWidth: DefaultCamWidth,
+ camHeight: DefaultCamHeight,
+ }
+
+ if len(initialSims) > 0 {
+ m.selectedSimUDID = initialSims[0].UDID
+ m.selectedSimName = initialSims[0].Name
+ }
+
+ if _, err := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/cmd/cam_test.go b/cmd/cam_test.go
new file mode 100644
index 0000000..350d265
--- /dev/null
+++ b/cmd/cam_test.go
@@ -0,0 +1,103 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// TestCamShmPaths verifies that shmPath / statusFilePath / pidFilePath produce
+// the expected string patterns without touching the filesystem.
+func TestCamShmPaths(t *testing.T) {
+ udid := "ABC-123"
+
+ tests := []struct {
+ fn func(string) string
+ want string
+ }{
+ {shmPath, "/tmp/minisimcam.ABC-123.frames"},
+ {statusFilePath, "/tmp/minisimcam.ABC-123.status"},
+ {pidFilePath, "/tmp/minisimcam.ABC-123.pid"},
+ }
+
+ for _, tt := range tests {
+ if got := tt.fn(udid); got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ }
+}
+
+// TestCamStatusParsing verifies that a status JSON file written by FrameHost
+// can be round-tripped through camFrameLoopStatus.
+func TestCamStatusParsing(t *testing.T) {
+ sample := camFrameLoopStatus{
+ UDID: "TEST-UDID",
+ Source: "test-card.png",
+ Width: 1280,
+ Height: 720,
+ FPS: 30,
+ FramesProduced: 900,
+ HostPID: 12345,
+ StartedAt: "2026-01-01T00:00:00Z",
+ LastFrameAgeMs: 12.5,
+ Running: true,
+ }
+
+ data, err := json.Marshal(sample)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var parsed camFrameLoopStatus
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if parsed.FramesProduced != 900 {
+ t.Errorf("FramesProduced: got %d, want 900", parsed.FramesProduced)
+ }
+ if parsed.Width != 1280 {
+ t.Errorf("Width: got %d, want 1280", parsed.Width)
+ }
+ if !parsed.Running {
+ t.Error("Running should be true")
+ }
+}
+
+func TestFrameHostFPS(t *testing.T) {
+ udid := fmt.Sprintf("frame-host-fps-test-%d", time.Now().UnixNano())
+ path := filepath.Join(os.TempDir(), fmt.Sprintf("minisimcam.%s.status", udid))
+ t.Cleanup(func() { _ = os.Remove(path) })
+
+ status := camFrameLoopStatus{FPS: 60}
+ data, err := json.Marshal(status)
+ if err != nil {
+ t.Fatalf("marshal status: %v", err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatalf("write status: %v", err)
+ }
+ if got := frameHostFPS(udid); got != 60 {
+ t.Errorf("frameHostFPS() = %d, want 60", got)
+ }
+}
+
+// TestStopFrameHostNoFile verifies stopFrameHost returns nil when no PID file exists.
+func TestStopFrameHostNoFile(t *testing.T) {
+ // Use a non-existent UDID — no PID file should exist.
+ err := stopFrameHost("non-existent-udid-xyz")
+ if err != nil {
+ t.Errorf("expected nil error, got: %v", err)
+ }
+}
+
+// TestMiniSimCamDirFallback verifies that miniSimCamDir() returns a path ending in "MiniSimCam".
+func TestMiniSimCamDirFallback(t *testing.T) {
+ dir := miniSimCamDir()
+ if filepath.Base(dir) != "MiniSimCam" {
+ t.Errorf("expected base to be MiniSimCam, got %q", filepath.Base(dir))
+ }
+}
diff --git a/cmd/constants.go b/cmd/constants.go
index 094e11e..9ee04c9 100644
--- a/cmd/constants.go
+++ b/cmd/constants.go
@@ -27,4 +27,9 @@ const (
CmdXclip = "xclip"
PrefixScreenshot = "screenshot"
PrefixRecording = "recording"
+
+ // Camera injector (MiniSimCam) defaults.
+ DefaultCamWidth = 1280
+ DefaultCamHeight = 720
+ DefaultCamFPS = 30
)
diff --git a/cmd/embed_cam.go b/cmd/embed_cam.go
new file mode 100644
index 0000000..a3274a1
--- /dev/null
+++ b/cmd/embed_cam.go
@@ -0,0 +1,66 @@
+//go:build cam_embed
+
+package cmd
+
+import (
+ "crypto/sha256"
+ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+//go:embed assets/FrameHost assets/MiniCamInject.dylib
+var embeddedAssets embed.FS
+
+// ensureExtractedAssets extracts embedded assets to ~/.sim-cli/bin/ if valid.
+// Returns the directory containing the extracted binaries.
+func ensureExtractedAssets() (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get user home directory: %w", err)
+ }
+
+ targetDir := filepath.Join(homeDir, ".sim-cli", "bin")
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create target directory %s: %w", targetDir, err)
+ }
+
+ files := []struct {
+ embeddedName string
+ fileName string
+ mode os.FileMode
+ }{
+ {"assets/FrameHost", "FrameHost", 0755},
+ {"assets/MiniCamInject.dylib", "MiniCamInject.dylib", 0644},
+ }
+
+ for _, f := range files {
+ data, err := embeddedAssets.ReadFile(f.embeddedName)
+ if err != nil {
+ return "", fmt.Errorf("failed to read embedded asset %s: %w", f.embeddedName, err)
+ }
+
+ targetPath := filepath.Join(targetDir, f.fileName)
+
+ // Check if file already exists and matches hash
+ if existingData, err := os.ReadFile(targetPath); err == nil {
+ if sha256.Sum256(existingData) == sha256.Sum256(data) {
+ continue // File already exists and is up to date
+ }
+ }
+
+ // Write extracted binary
+ tmpPath := targetPath + ".tmp"
+ if err := os.WriteFile(tmpPath, data, f.mode); err != nil {
+ return "", fmt.Errorf("failed to write asset to %s: %w", tmpPath, err)
+ }
+ if err := os.Rename(tmpPath, targetPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to replace asset at %s: %w", targetPath, err)
+ }
+ _ = os.Chmod(targetPath, f.mode)
+ }
+
+ return targetDir, nil
+}
diff --git a/cmd/embed_stub.go b/cmd/embed_stub.go
new file mode 100644
index 0000000..4f50b2d
--- /dev/null
+++ b/cmd/embed_stub.go
@@ -0,0 +1,9 @@
+//go:build !cam_embed
+
+package cmd
+
+// ensureExtractedAssets is a no-op when built without cam_embed tag.
+// The CLI falls back to local MiniSimCam/.build/ paths.
+func ensureExtractedAssets() (string, error) {
+ return "", nil
+}
diff --git a/cmd/root.go b/cmd/root.go
index f26ed7d..8c4aa87 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -171,6 +171,7 @@ func init() {
rootCmd.AddCommand(pairCmd)
rootCmd.AddCommand(openCmd)
rootCmd.AddCommand(doctorCmd)
+ rootCmd.AddCommand(camCmd)
// deleteCmd flags
deleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
diff --git a/docs/SIM_CAM_ARCHITECTURE.md b/docs/SIM_CAM_ARCHITECTURE.md
new file mode 100644
index 0000000..e4ef71b
--- /dev/null
+++ b/docs/SIM_CAM_ARCHITECTURE.md
@@ -0,0 +1,257 @@
+# SIM-CLI Camera Architecture
+
+This document explains the technical architecture of the `sim cam` feature. It details how `sim-cli` injects macOS cameras into iOS Simulator apps, a capability Apple does not natively support.
+
+## The Problem
+The iOS Simulator lacks hardware passthrough for the host Mac's physical cameras. When an app running in the simulator uses `AVFoundation` to request camera access, the simulator returns a mock black screen, provides a static placeholder, or crashes the app.
+
+## The Solution
+`sim-cli` solves this using a distributed, two-process architecture communicating over lock-free shared memory.
+
+```mermaid
+flowchart TD
+ subgraph Host["macOS Host"]
+ CLI["sim-cli"]
+ WebCam["WebCam (Hardware)"]
+
+ subgraph FrameHost["FrameHost (macOS Process)"]
+ CameraSource["CameraSource"]
+ SharedFrameWriter["SharedFrameWriter"]
+ end
+
+ SHM[/"/tmp/minisimcam..frames (Shared Memory)"/]
+ end
+
+ subgraph Sim["iOS Simulator"]
+ subgraph App["Target iOS App"]
+ AVFoundation["AVFoundation"]
+
+ subgraph Injector["MiniCamInject.dylib"]
+ Swizzler["Swizzler"]
+ SharedFrameReader["SharedFrameReader"]
+ SampleBufferFactory["SampleBufferFactory"]
+ end
+ end
+ end
+
+ CLI -. "Spawns" .-> FrameHost
+ CLI -. "Injects via launchctl" .-> App
+ WebCam -- "Frames" --> CameraSource
+ CameraSource -- "BGRA" --> SharedFrameWriter
+ SharedFrameWriter -- "Seq-locked write" --> SHM
+ SHM -- "Polling read" --> SharedFrameReader
+ SharedFrameReader -- "BGRA" --> SampleBufferFactory
+ SampleBufferFactory -- "CMSampleBuffer" --> Swizzler
+ Swizzler -- "Spoofs feed" --> AVFoundation
+```
+
+---
+
+## 1. Inter-Process Communication (IPC)
+
+Passing uncompressed 1080p or 4K video at 60 frames per second between two separate processes requires moving up to 500 MB/s of data.
+
+**The Design Decision:**
+Standard IPC mechanisms like XPC, Mach ports, or local UNIX sockets require serializing the data, copying it into the operating system kernel space, and then copying it back out to the receiving process. This double-copy creates CPU bottlenecks and latency. To fix this, `sim-cli` uses **Memory-Mapped Files (`mmap`)**.
+
+Memory mapping maps a file directly into the virtual memory space of both the macOS `FrameHost` process and the iOS Simulator app process. When `FrameHost` writes a pixel to this memory region, the iOS app can read that exact byte instantly. This enables zero-copy data transfer.
+
+### Memory Layout
+The shared memory file consists of a 128-byte header (`MSCStreamHeader`), immediately followed by a triple-buffer of raw BGRA pixel data.
+
+```c
+typedef struct {
+ uint32_t magic; // "MSCC"
+ uint32_t version; // Version schema
+ uint32_t width; // Frame width
+ uint32_t height; // Frame height
+ uint32_t bytesPerRow; // Memory stride (aligned to 64 bytes)
+ uint32_t pixelFormat; // 'BGRA'
+ uint32_t bufferCount; // 3 (Triple buffered)
+ uint32_t bufferSize; // bytesPerRow * height
+ uint64_t sequence; // ATOMIC: Sequence lock for writers
+ uint32_t publishedIndex; // ATOMIC: Current readable buffer index
+ uint32_t _pad0; // Alignment padding
+ uint64_t presentationTimeNs; // Frame PTS (Nanoseconds)
+ uint64_t framesProduced; // ATOMIC: Total frames produced
+ uint8_t reserved[64]; // Future expansion padding
+} MSCStreamHeader; // Exactly 128 bytes
+```
+
+### The Lock-Free Algorithm
+Because the producer (`FrameHost`) and consumer (`MiniCamInject`) run in separate processes, they need a way to coordinate. If `FrameHost` writes data while the app is reading it, the video frame tears (the top half of the old frame mixed with the bottom half of the new frame).
+
+**The Design Decision:**
+We cannot use standard POSIX mutexes (`pthread_mutex_t`). If the user stops the camera and `sim-cli` kills `FrameHost` via `SIGKILL` while it holds a shared mutex, the mutex is permanently locked. The iOS app would wait on that lock forever and freeze.
+
+Instead, `sim-cli` uses a **Sequence Lock** (SeqLock) implementation with C++20 standard atomic memory orders (`std::memory_order_acquire` / `std::memory_order_release`). Sequence locks eliminate deadlocks because the reader never blocks. It only retries.
+
+```mermaid
+sequenceDiagram
+ participant P as FrameHost
(Producer)
+ participant SHM as Shared Memory
+ participant C as MiniCamInject
(Consumer)
+
+ %% Producer Write
+ Note over P, C: Producer Write
+ P->>SHM: load sequence
+ P->>SHM: sequence = sequence + 1 (Odd: Locked)
+ P->>SHM: write pixel data to buffer[writeIndex]
+ P->>SHM: update publishedIndex = writeIndex
+ P->>SHM: sequence = sequence + 1 (Even: Unlocked)
+
+ %% Consumer Read
+ Note over P, C: Consumer Read
+ loop until valid frame
+ C->>SHM: seqA = load sequence
+ alt seqA is Odd
+ C->>C: sched_yield() (retry)
+ else seqA is Even
+ C->>SHM: idx = load publishedIndex
+ C->>SHM: copy pixels from buffer[idx]
+ C->>SHM: seqB = load sequence
+ alt seqA == seqB
+ C->>C: Valid frame! Break loop.
+ else Torn Read
+ C->>C: Frame modified during read. Retry.
+ end
+ end
+ end
+```
+
+1. **Producer writes:** `FrameHost` extracts raw BGRA pointers from the Mac's hardware camera. It atomically increments the `sequence` integer to an **odd** number, signaling a write is in progress. It copies the frame into an inactive buffer slot, updates `publishedIndex`, and increments `sequence` to an **even** number.
+2. **Consumer reads:** `MiniCamInject` polls the `sequence` integer. If it is even, it begins reading the frame from `publishedIndex`. After the memory copy finishes, it checks `sequence` again. If `sequence` changed during the read, a torn read occurred. The consumer discards the bad frame and retries instantly.
+
+---
+
+## 2. Initialization and Injection
+
+To force the iOS app to read from our custom camera feed instead of the simulator's hardware abstraction, the system uses dynamic library injection.
+
+**The Design Decision:**
+We could patch the source code of the iOS app, but that requires modifying user code and re-compiling, which slows down development. By injecting a dynamic library (`.dylib`) at runtime, the developer does not need to change a single line of their application code.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant CLI as sim-cli
+ participant LaunchCtl as launchctl
+ participant Host as FrameHost
+ participant App as iOS App
+ participant Inject as MiniCamInject
+ participant AV as AVCaptureSession
+
+ User->>CLI: sim cam start
+ CLI->>Host: spawn(udid)
+ activate Host
+ Host->>Host: allocate /tmp/minisimcam..frames
+ Host->>Host: start AVFoundation capture
+ CLI->>LaunchCtl: setenv DYLD_INSERT_LIBRARIES
+ CLI->>LaunchCtl: setenv MINISIMCAM_PATH
+
+ User->>App: launches app
+ activate App
+ App->>Inject: dyld loads injected library
+ activate Inject
+ Inject->>Inject: __attribute__((constructor)) init()
+ Inject->>AV: method_exchangeImplementations(startRunning, msc_startRunning)
+ deactivate Inject
+
+ App->>AV: [session startRunning]
+ AV->>Inject: msc_startRunning() (intercepted)
+ activate Inject
+ Inject->>Inject: setup GCD background queue
+ Inject->>Inject: mmap(/tmp/minisimcam..frames)
+ deactivate Inject
+```
+
+### Global Injection Mechanics
+When you start a camera via `sim-cli cam start`, the CLI executes `xcrun simctl spawn launchctl setenv`. This modifies the global environment of the booted iOS Simulator.
+- `DYLD_INSERT_LIBRARIES=/path/to/MiniCamInject.dylib`: Forces the Apple dynamic linker (`dyld`) to load our library into every app launched on the simulator before the app's `main()` function executes.
+- `MINISIMCAM_PATH`: Passes the path of the shared memory file so the dylib knows where to read frames.
+
+Because this is set globally via `launchctl`, *any* app launched on the simulator automatically receives the injection.
+
+### Objective-C Method Swizzling
+Objective-C allows developers to change the mapping between a method name (selector) and its underlying C function (implementation) at runtime. This technique is known as Method Swizzling.
+
+Inside `MiniCamInject.dylib`, a C constructor function (`__attribute__((constructor))`) runs immediately upon load. It uses the Objective-C runtime function `method_exchangeImplementations` to swap the memory addresses of Apple's internal `AVCaptureSession` methods with our custom implementations.
+
+When the iOS app calls `[session startRunning]`, execution jumps to our code. The code bypasses Apple's hardware initialization, creates a Grand Central Dispatch (GCD) background thread, and begins reading frames from the shared memory.
+
+---
+
+## 3. Frame Delivery
+
+When the iOS app's GCD queue successfully copies an un-torn frame from shared memory, it must convert the raw data into a format `AVFoundation` understands.
+
+```mermaid
+flowchart LR
+ SHM[("Shared Memory
(Raw BGRA Bytes)")]
+ Factory["SampleBufferFactory
(Obj-C++)"]
+ CVPixelBuffer["CoreVideo
CVPixelBuffer"]
+ CMTime["CMTime
(Hardware Clock)"]
+ CMSampleBuffer["CMSampleBuffer"]
+ Delegate["AVCaptureVideoDataOutput
SampleBufferDelegate"]
+
+ SHM -- "Lock-Free Copy" --> Factory
+ Factory -- "Wraps Memory" --> CVPixelBuffer
+ Factory -- "Attaches" --> CMTime
+ CVPixelBuffer --> CMSampleBuffer
+ CMTime --> CMSampleBuffer
+ CMSampleBuffer -- "Pushes to App" --> Delegate
+```
+
+1. `SampleBufferFactory` (written in Objective-C++) takes the raw BGRA memory array.
+2. It wraps the memory into a CoreVideo `CVPixelBuffer`.
+3. It attaches hardware timing data (`CMTime`) to match the simulator's internal clock.
+4. It packages the `CVPixelBuffer` into a `CMSampleBuffer`.
+5. It pushes the `CMSampleBuffer` to the `AVCaptureVideoDataOutputSampleBufferDelegate` of the app.
+
+The host app receives the delegate callbacks exactly as it would from a hardware camera.
+
+---
+
+## 4. Camera Switching Logic
+`sim-cli` supports hot-swapping cameras without crashing or restarting the iOS app.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant CLI as sim-cli
+ participant OldHost as FrameHost (Old)
+ participant NewHost as FrameHost (New)
+ participant SHM as Shared Memory (.frames)
+ participant App as iOS App
+
+ User->>CLI: sim cam start
+ CLI->>OldHost: SIGKILL
+ Note over OldHost, SHM: OldHost dies immediately.
May leave sequence lock as ODD.
+
+ CLI->>NewHost: spawn(new_camera)
+ activate NewHost
+ NewHost->>SHM: fstat() to check file size
+ alt Resolution Matches
+ NewHost->>SHM: mmap() existing file
+ NewHost->>SHM: Check sequence lock
+ alt Sequence is Odd
+ NewHost->>SHM: Force sequence to Even (Repair)
+ end
+ else Resolution Changed
+ NewHost->>SHM: Delete old file
+ NewHost->>SHM: Create new file & mmap()
+ end
+
+ NewHost->>SHM: Resume writing frames
+ Note over SHM, App: App polling never stopped
+ SHM->>App: App reads new frames instantly
+ deactivate NewHost
+```
+
+When the active camera is changed:
+1. `sim-cli` sends a termination signal (SIGKILL) to the running `FrameHost` daemon.
+2. `sim-cli` spins up a new `FrameHost` daemon for the new camera.
+3. The shared memory `.frames` file on disk is kept intact unless the requested resolution changes.
+4. The new `FrameHost` uses `fstat` to verify the file size. If it matches, it memory maps the existing file instead of recreating it.
+5. If the previous `FrameHost` was killed mid-write (leaving `sequence` as an odd number), the new `FrameHost` detects this and forces the atomic `sequence` back to an even number to repair the lock state.
+6. The iOS app, which is polling the memory map continuously, sees the `sequence` advance and resumes rendering new frames.
diff --git a/go.mod b/go.mod
index b9d52ee..59e643a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,8 @@ go 1.24.4
require (
github.com/atotto/clipboard v0.1.4
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b
github.com/charmbracelet/lipgloss v1.1.0
@@ -13,8 +15,6 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
- github.com/charmbracelet/bubbles v1.0.0 // indirect
- github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
@@ -35,6 +35,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
diff --git a/go.sum b/go.sum
index 6a769ea..d7b2c36 100644
--- a/go.sum
+++ b/go.sum
@@ -53,6 +53,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -72,6 +74,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=