diff --git a/.gitignore b/.gitignore index adb7f8c..13bbe01 100644 --- a/.gitignore +++ b/.gitignore @@ -44,14 +44,23 @@ recording_*.mp4 /tmp/coverage.out coverage.html -# MiniSimCam -MiniSimCam/.build/ -MiniSimCam/Example/CameraPreviewApp/build/ -MiniSimCam/Example/CameraPreviewApp/module_cache/ -MiniSimCam/module_cache/ +# Iris +Iris/.build/ +Iris/Example/CameraPreviewApp/build/ +Iris/Example/CameraPreviewApp/module_cache/ +Iris/module_cache/ module_cache/ cmd/assets/ +# Iris +Iris/.build/ +Iris/Example/CameraPreviewApp/build/ +Iris/Example/CameraPreviewApp/module_cache/ +Iris/module_cache/ +module_cache/ +cmd/assets/ +Iris/.tmp/ + # Xcode user state files (never commit) **/*.xcuserstate diff --git a/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Iris/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata similarity index 100% rename from MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata rename to Iris/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist b/Iris/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 64% rename from MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist rename to Iris/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist index cf7ea32..256dee9 100755 --- a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Iris/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,17 +9,32 @@ orderHint 3 - MiniCamInject.xcscheme_^#shared#^_ + Iris-Package.xcscheme_^#shared#^_ + + orderHint + 0 + + IrisInject.xcscheme_^#shared#^_ + + orderHint + 1 + + IrisShared.xcscheme_^#shared#^_ + + orderHint + 2 + + IrisInject.xcscheme_^#shared#^_ orderHint 1 - MiniSimCam-Package.xcscheme_^#shared#^_ + Iris-Package.xcscheme_^#shared#^_ orderHint 0 - MiniSimCamShared.xcscheme_^#shared#^_ + IrisShared.xcscheme_^#shared#^_ orderHint 2 @@ -37,12 +52,12 @@ primary - MiniCamInject + IrisInject primary - MiniSimCamShared + IrisShared primary diff --git a/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift b/Iris/Example/CameraPreviewApp/CameraPreviewApp.swift similarity index 95% rename from MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift rename to Iris/Example/CameraPreviewApp/CameraPreviewApp.swift index 5cf958a..a35f485 100755 --- a/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift +++ b/Iris/Example/CameraPreviewApp/CameraPreviewApp.swift @@ -1,8 +1,8 @@ // CameraPreviewApp.swift // Minimal SwiftUI example app demonstrating both Stage A (cooperative) and -// Stage B (transparent injection) modes of MiniSimCam. +// Stage B (transparent injection) modes of Iris. // -// Stage A: Compile with MINISIMCAM_STAGE_A=1 — uses SharedMemoryCameraSource +// Stage A: Compile with IRIS_STAGE_A=1 — uses SharedMemoryCameraSource // Stage B: Use normal AVFoundation; the injector dylib intercepts it. import SwiftUI @@ -35,7 +35,7 @@ struct ContentView: View { HStack { Image(systemName: "camera.fill") .foregroundStyle(.white) - Text("MiniSimCam Preview") + Text("Iris Preview") .foregroundStyle(.white) .font(.footnote.bold()) Spacer() diff --git a/MiniSimCam/Example/CameraPreviewApp/build_app.sh b/Iris/Example/CameraPreviewApp/build_app.sh similarity index 100% rename from MiniSimCam/Example/CameraPreviewApp/build_app.sh rename to Iris/Example/CameraPreviewApp/build_app.sh diff --git a/MiniSimCam/Example/CameraPreviewApp/entitlements.plist b/Iris/Example/CameraPreviewApp/entitlements.plist similarity index 100% rename from MiniSimCam/Example/CameraPreviewApp/entitlements.plist rename to Iris/Example/CameraPreviewApp/entitlements.plist diff --git a/MiniSimCam/Package.resolved b/Iris/Package.resolved similarity index 100% rename from MiniSimCam/Package.resolved rename to Iris/Package.resolved diff --git a/MiniSimCam/Package.swift b/Iris/Package.swift similarity index 50% rename from MiniSimCam/Package.swift rename to Iris/Package.swift index d9bf77b..7612609 100755 --- a/MiniSimCam/Package.swift +++ b/Iris/Package.swift @@ -1,24 +1,18 @@ // swift-tools-version: 5.9 -// Package.swift — MiniSimCam monorepo SPM manifest. +// Package.swift — Iris monorepo SPM manifest. import PackageDescription let package = Package( - name: "MiniSimCam", + name: "Iris", 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"]), + .library(name: "IrisShared", targets: ["IrisShared"]), + .library(name: "IrisInject", type: .dynamic, targets: ["IrisInject"]), ], dependencies: [ .package( @@ -27,24 +21,16 @@ let package = Package( ), ], targets: [ - - // ------------------------------------------------------------------ - // Shared C headers (system module map style). - // ------------------------------------------------------------------ .target( - name: "MiniSimCamShared", + name: "IrisShared", path: "Shared", sources: ["AtomicHelpers.c"], publicHeadersPath: "include" ), - - // ------------------------------------------------------------------ - // FrameHost — macOS executable. - // ------------------------------------------------------------------ .executableTarget( name: "FrameHost", dependencies: [ - "MiniSimCamShared", + "IrisShared", .product(name: "ArgumentParser", package: "swift-argument-parser"), ], path: "Sources/FrameHost", @@ -58,16 +44,10 @@ let package = Package( ]) ] ), - - // ------------------------------------------------------------------ - // 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", + name: "IrisInject", + dependencies: ["IrisShared"], + path: "Sources/IrisInject", publicHeadersPath: ".", cxxSettings: [ .headerSearchPath("../../Shared/include"), @@ -77,21 +57,22 @@ let package = Package( .linkedFramework("AVFoundation"), .linkedFramework("CoreMedia"), .linkedFramework("CoreVideo"), + .linkedFramework("Foundation"), + .linkedFramework("CoreGraphics"), + .linkedFramework("VideoToolbox"), + .linkedFramework("QuartzCore"), + .linkedFramework("IOSurface") ] ), - - // ------------------------------------------------------------------ - // Tests - // ------------------------------------------------------------------ .testTarget( name: "ProtocolTests", - dependencies: ["MiniSimCamShared"], + dependencies: ["IrisShared"], path: "Tests/ProtocolTests" ), .testTarget( name: "FrameTransportTests", - dependencies: ["FrameHost", "MiniSimCamShared"], + dependencies: ["FrameHost", "IrisShared"], path: "Tests/FrameTransportTests" ), ], diff --git a/Iris/Scripts/build.sh b/Iris/Scripts/build.sh new file mode 100755 index 0000000..da5f2f3 --- /dev/null +++ b/Iris/Scripts/build.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# build.sh -- Builds FrameHost (macOS) and IrisInject.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/.."}" # Iris 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" + +if [[ -t 1 ]]; then + BOLD='\033[1m' + DIM='\033[2m' + RESET='\033[0m' + GREEN='\033[32m' + CYAN='\033[36m' + YELLOW='\033[33m' + CHECK='✓' + ARROW='›' +else + BOLD='' DIM='' RESET='' GREEN='' CYAN='' YELLOW='' + CHECK='OK' ARROW='>' +fi + +step() { + printf " ${DIM}${ARROW}${RESET} %s" "$1" +} + +done_step() { + local elapsed="$1" + printf "\r ${GREEN}${CHECK}${RESET} %s ${DIM}(%s)${RESET}\n" "$2" "$elapsed" +} + +elapsed_since() { + local start="$1" + local end + end=$(date +%s) + local secs=$(( end - start )) + if (( secs == 0 )); then + printf "<1s" + elif (( secs < 60 )); then + printf "%ds" "$secs" + else + printf "%dm %ds" $(( secs / 60 )) $(( secs % 60 )) + fi +} + +BUILD_START=$(date +%s) + +printf "\n" +printf " ${BOLD}Iris${RESET} ${DIM}build${RESET}\n" +printf " ${DIM}──────────────────────────────────${RESET}\n" + +step "FrameHost (macOS)..." +STEP_START=$(date +%s) + +swift build --disable-sandbox -c release --product FrameHost > /dev/null 2>&1 + +FRAME_HOST_BIN="$(swift build --disable-sandbox -c release --product FrameHost --show-bin-path 2>/dev/null)/FrameHost" +done_step "$(elapsed_since $STEP_START)" "FrameHost (macOS)" + +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/IrisInject" + +compile_arch() { + local ARCH="$1" + local TARGET="${ARCH}-apple-ios16.0-simulator" + + clang++ \ + -target "${TARGET}" \ + -isysroot "${SIM_SDK}" \ + ${INCLUDE_FLAGS} \ + -std=c++17 \ + -fPIC -g \ + -c "${PROJECT_DIR}/Sources/IrisInject/SharedFrameReader.cpp" \ + -o "${BUILD_DIR}/SharedFrameReader_${ARCH}.o" + + clang++ \ + -target "${TARGET}" \ + -isysroot "${SIM_SDK}" \ + ${INCLUDE_FLAGS} \ + -std=c++17 \ + -fPIC -g \ + -fobjc-arc \ + -c "${PROJECT_DIR}/Sources/IrisInject/SampleBufferFactory.mm" \ + -o "${BUILD_DIR}/SampleBufferFactory_${ARCH}.o" + + clang++ \ + -target "${TARGET}" \ + -isysroot "${SIM_SDK}" \ + ${INCLUDE_FLAGS} \ + -std=c++17 \ + -fPIC -g \ + -fobjc-arc \ + -c "${PROJECT_DIR}/Sources/IrisInject/CaptureHooks.mm" \ + -o "${BUILD_DIR}/CaptureHooks_${ARCH}.o" + + clang++ \ + -target "${TARGET}" \ + -isysroot "${SIM_SDK}" \ + ${INCLUDE_FLAGS} \ + -std=c++17 \ + -fPIC -g \ + -fobjc-arc \ + -c "${PROJECT_DIR}/Sources/IrisInject/FakeCaptureObjects.mm" \ + -o "${BUILD_DIR}/FakeCaptureObjects_${ARCH}.o" + + clang++ \ + -target "${TARGET}" \ + -isysroot "${SIM_SDK}" \ + ${INCLUDE_FLAGS} \ + -std=c++17 \ + -fPIC -g \ + -fobjc-arc \ + -c "${PROJECT_DIR}/Sources/IrisInject/EntryPoint.mm" \ + -o "${BUILD_DIR}/EntryPoint_${ARCH}.o" + + clang++ \ + -target "${TARGET}" \ + -dynamiclib \ + -isysroot "${SIM_SDK}" \ + -framework AVFoundation \ + -framework CoreMedia \ + -framework CoreVideo \ + -framework Foundation \ + -framework QuartzCore \ + -framework VideoToolbox \ + -framework CoreGraphics \ + -framework IOSurface \ + "${BUILD_DIR}/SharedFrameReader_${ARCH}.o" \ + "${BUILD_DIR}/SampleBufferFactory_${ARCH}.o" \ + "${BUILD_DIR}/CaptureHooks_${ARCH}.o" \ + "${BUILD_DIR}/FakeCaptureObjects_${ARCH}.o" \ + "${BUILD_DIR}/EntryPoint_${ARCH}.o" \ + -o "${BUILD_DIR}/IrisInject_${ARCH}.dylib" +} + +step "IrisInject (arm64)..." +STEP_START=$(date +%s) +compile_arch arm64 +done_step "$(elapsed_since $STEP_START)" "IrisInject (arm64)" + +step "IrisInject (x86_64)..." +STEP_START=$(date +%s) +compile_arch x86_64 +done_step "$(elapsed_since $STEP_START)" "IrisInject (x86_64)" + +step "Universal binary (lipo)..." +STEP_START=$(date +%s) +lipo -create \ + "${BUILD_DIR}/IrisInject_arm64.dylib" \ + "${BUILD_DIR}/IrisInject_x86_64.dylib" \ + -output "${BUILD_DIR}/IrisInject.dylib" +done_step "$(elapsed_since $STEP_START)" "Universal binary (lipo)" + +ASSETS_DIR="${REPO_ROOT}/cmd/assets" +mkdir -p "${ASSETS_DIR}" +cp "${FRAME_HOST_BIN}" "${ASSETS_DIR}/FrameHost" +cp "${BUILD_DIR}/IrisInject.dylib" "${ASSETS_DIR}/IrisInject.dylib" + +printf " ${GREEN}${CHECK}${RESET} Assets copied\n" + +TOTAL_ELAPSED="$(elapsed_since $BUILD_START)" +printf " ${DIM}──────────────────────────────────${RESET}\n" +printf " ${GREEN}${BOLD}Done${RESET} ${DIM}in ${TOTAL_ELAPSED}${RESET}\n\n" diff --git a/MiniSimCam/Scripts/launch.sh b/Iris/Scripts/launch.sh similarity index 63% rename from MiniSimCam/Scripts/launch.sh rename to Iris/Scripts/launch.sh index 10d741c..785f53e 100755 --- a/MiniSimCam/Scripts/launch.sh +++ b/Iris/Scripts/launch.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# launch.sh — Launches an app on the booted simulator with MiniCamInject loaded. +# launch.sh — Launches an app on the booted simulator with IrisInject loaded. # Usage: ./launch.sh [udid] set -euo pipefail @@ -13,11 +13,11 @@ 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" +DYLIB="$PROJECT_DIR/.build/injector/IrisInject.dylib" +SHM_PATH="/tmp/iris.${UDID}.frames" if [[ ! -f "$DYLIB" ]]; then - echo "⚠️ MiniCamInject.dylib not found. Run 'sim cam build' first." >&2 + echo "⚠️ IrisInject.dylib not found. Run 'sim cam build' first." >&2 exit 1 fi @@ -26,5 +26,5 @@ echo " dylib: $DYLIB" echo " shm: $SHM_PATH" SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$DYLIB" \ -SIMCTL_CHILD_MINISIMCAM_PATH="$SHM_PATH" \ +SIMCTL_CHILD_IRIS_PATH="$SHM_PATH" \ xcrun simctl launch "$UDID" "$BUNDLE_ID" diff --git a/Iris/Shared/AtomicHelpers.c b/Iris/Shared/AtomicHelpers.c new file mode 100755 index 0000000..2752fc4 --- /dev/null +++ b/Iris/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 "IrisProtocol.h" +#include + +// Byte-pointer helpers using the offsets defined in IrisProtocol.h. +#define SEQ_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + IRIS_OFF_SEQUENCE)) +#define IOSFC_PTR(h) ((volatile uint32_t *)((uint8_t *)(h) + IRIS_OFF_IOSURFACE_ID)) +#define FP_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + IRIS_OFF_FRAMES_PRODUCED)) + +// Sequence + +uint64_t iris_seq_load_acquire(const void *header) { + return __atomic_load_n(SEQ_PTR(header), __ATOMIC_ACQUIRE); +} + +void iris_seq_store_release(void *header, uint64_t value) { + __atomic_store_n(SEQ_PTR(header), value, __ATOMIC_RELEASE); +} + +// IOSurfaceID + +uint32_t iris_iosfc_load_acquire(const void *header) { + return __atomic_load_n(IOSFC_PTR(header), __ATOMIC_ACQUIRE); +} + +void iris_iosfc_store_relaxed(void *header, uint32_t value) { + __atomic_store_n(IOSFC_PTR(header), value, __ATOMIC_RELAXED); +} + +// FramesProduced + +uint64_t iris_fp_load_relaxed(const void *header) { + return __atomic_load_n(FP_PTR(header), __ATOMIC_RELAXED); +} + +uint64_t iris_fp_fetch_add(void *header, uint64_t delta) { + return __atomic_fetch_add(FP_PTR(header), delta, __ATOMIC_RELAXED); +} diff --git a/Iris/Shared/include/AtomicHelpers.h b/Iris/Shared/include/AtomicHelpers.h new file mode 100755 index 0000000..36097ce --- /dev/null +++ b/Iris/Shared/include/AtomicHelpers.h @@ -0,0 +1,45 @@ +// AtomicHelpers.h +// Thin C wrappers around stdatomic operations on the IRISStreamHeader 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 IRISStreamHeader exactly. +// Verified by the static assertion in IrisProtocol.h. + +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ---- Sequence (offset 32 in IRISStreamHeader, uint64_t _Atomic) ------------ +// See IRIS_OFF_SEQUENCE in IrisProtocol.h. + +/// Acquire-load the sequence field. +uint64_t iris_seq_load_acquire(const void *header); + +/// Store to the sequence field with release ordering. +void iris_seq_store_release(void *header, uint64_t value); + +// ---- IOSurfaceID (offset 40, uint32_t _Atomic) ------------------------- +// See IRIS_OFF_IOSURFACE_ID in IrisProtocol.h. + +/// Acquire-load the ioSurfaceID field. +uint32_t iris_iosfc_load_acquire(const void *header); + +/// Relaxed-store to the ioSurfaceID field. +void iris_iosfc_store_relaxed(void *header, uint32_t value); + +// ---- FramesProduced (offset 56, uint64_t _Atomic) ------------------------- +// See IRIS_OFF_FRAMES_PRODUCED in IrisProtocol.h. + +/// Relaxed-load the framesProduced field. +uint64_t iris_fp_load_relaxed(const void *header); + +/// Relaxed fetch-and-add on framesProduced; returns the old value. +uint64_t iris_fp_fetch_add(void *header, uint64_t delta); + +#ifdef __cplusplus +} +#endif diff --git a/Iris/Shared/include/IrisConstants.h b/Iris/Shared/include/IrisConstants.h new file mode 100755 index 0000000..460a351 --- /dev/null +++ b/Iris/Shared/include/IrisConstants.h @@ -0,0 +1,44 @@ +#pragma once +// IrisConstants.h +// Shared path conventions and runtime constants. + +#include + +// Shared-memory file path +// Full path: IRIS_SHM_DIR "/" IRIS_SHM_PREFIX IRIS_SHM_SUFFIX +#define IRIS_SHM_DIR "/tmp" +#define IRIS_SHM_PREFIX "iris." +#define IRIS_SHM_SUFFIX ".frames" + +// Status JSON file written by the FrameHost. +#define IRIS_STATUS_SUFFIX ".status" + +// PID file for the FrameHost process. +#define IRIS_PID_SUFFIX ".pid" + +// Environment variables (passed via SIMCTL_CHILD_*) +// Full path to the shared-memory file injected by the CLI. +#define IRIS_ENV_PATH "IRIS_PATH" + +// Optional: override frames-per-second for the injector's delivery timer. +#define IRIS_ENV_FPS "IRIS_FPS" + +// Delivery defaults +#define IRIS_DEFAULT_WIDTH 1280u +#define IRIS_DEFAULT_HEIGHT 720u +#define IRIS_DEFAULT_FPS 30u + +// Delivery timer tolerance: 10% of frame duration is acceptable jitter. +#define IRIS_TIMER_LEEWAY_RATIO 0.1 + +// Safety limits +// Maximum frame dimension supported by this version. +#define IRIS_MAX_DIMENSION 3840u + +// Maximum time (ms) the consumer waits when a sequence is odd before giving up. +#define IRIS_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 IRIS_STALE_THRESHOLD_NS (500 * 1000 * 1000ull) // 500 ms diff --git a/MiniSimCam/Shared/include/MiniCamProtocol.h b/Iris/Shared/include/IrisProtocol.h similarity index 50% rename from MiniSimCam/Shared/include/MiniCamProtocol.h rename to Iris/Shared/include/IrisProtocol.h index e119e32..98774f8 100755 --- a/MiniSimCam/Shared/include/MiniCamProtocol.h +++ b/Iris/Shared/include/IrisProtocol.h @@ -1,5 +1,5 @@ #pragma once -// MiniCamProtocol.h +// IrisProtocol.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++. // @@ -16,19 +16,18 @@ #include -#define MSC_MAGIC 0x4D534343u // "MSCC" -#define MSC_VERSION 1u -#define MSC_BUFFER_COUNT 3u +#define IRIS_MAGIC 0x4D534343u // "MSCC" +#define IRIS_VERSION 1u // kCVPixelFormatType_32BGRA = 0x42475241 = 'BGRA' -#define MSC_PIXEL_FORMAT 0x42475241u +#define IRIS_PIXEL_FORMAT 0x42475241u // Align frame rows to 64 bytes (cache-line sized). -#define MSC_ROW_ALIGNMENT 64u +#define IRIS_ROW_ALIGNMENT 64u // ---------------------------------------------------------------------------- -// Stream header — lives at offset 0 in the shared-memory file. -// The three frame buffers follow immediately after sizeof(MSCStreamHeader). +// Stream header lives at offset 0 in the shared-memory file. +// The three frame buffers follow immediately after sizeof(IRISStreamHeader). // // Field layout (all offsets from struct start, verified by static assert): // [ 0] uint32_t magic @@ -37,13 +36,13 @@ // [ 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_*() +// [ 24] uint32_t _pad1 ← explicit padding for alignment +// [ 28] uint32_t _pad2 ← explicit padding for alignment +// [ 32] uint64_t sequence ← ATOMIC, use iris_seq_*() +// [ 40] uint32_t ioSurfaceID ← ATOMIC, use iris_iosfc_*() // [ 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_*() +// [ 56] uint64_t framesProduced ← ATOMIC, use iris_fp_*() // [ 64] uint8_t reserved[64] // [128] = total // ---------------------------------------------------------------------------- @@ -54,43 +53,35 @@ typedef struct { 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 _pad1; // [ 24] + uint32_t _pad2; // [ 28] + uint64_t sequence; // [ 32] ATOMIC — use iris_seq_*() + uint32_t ioSurfaceID; // [ 40] ATOMIC — use iris_iosfc_*() uint32_t _pad0; // [ 44] explicit pad uint64_t presentationTimeNs; // [ 48] - uint64_t framesProduced; // [ 56] ATOMIC — use msc_fp_*() + uint64_t framesProduced; // [ 56] ATOMIC — use iris_fp_*() uint8_t reserved[64]; // [ 64] -} MSCStreamHeader; // [128] total +} IRISStreamHeader; // [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 +// Byte offsets used by AtomicHelpers.c must match the layout above. +#define IRIS_OFF_SEQUENCE 32u +#define IRIS_OFF_IOSURFACE_ID 40u +#define IRIS_OFF_FRAMES_PRODUCED 56u // Compile-time size check (C11 _Static_assert). -_Static_assert(sizeof(MSCStreamHeader) == 128, "MSCStreamHeader must be exactly 128 bytes"); +_Static_assert(sizeof(IRISStreamHeader) == 128, "IRISStreamHeader must be exactly 128 bytes"); -#define MSC_HEADER_EXPECTED_SIZE 128u +#define IRIS_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) { +static inline uint32_t iris_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; + return (raw + IRIS_ROW_ALIGNMENT - 1u) & ~(IRIS_ROW_ALIGNMENT - 1u); } -// 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; +// Total mmap size: just the header. +static inline uint64_t iris_mapping_size(void) { + return (uint64_t)IRIS_HEADER_EXPECTED_SIZE; } diff --git a/Iris/Shared/include/module.modulemap b/Iris/Shared/include/module.modulemap new file mode 100755 index 0000000..dc01727 --- /dev/null +++ b/Iris/Shared/include/module.modulemap @@ -0,0 +1,6 @@ +module IrisShared { + header "IrisProtocol.h" + header "IrisConstants.h" + header "AtomicHelpers.h" + export * +} diff --git a/Iris/Sources/FrameHost/CVPixelBuffer+Copying.swift b/Iris/Sources/FrameHost/CVPixelBuffer+Copying.swift new file mode 100644 index 0000000..f3ca0f4 --- /dev/null +++ b/Iris/Sources/FrameHost/CVPixelBuffer+Copying.swift @@ -0,0 +1,46 @@ +// CVPixelBuffer+Copying.swift +import Foundation +import CoreVideo + +extension CVPixelBuffer { + + /// Deep copies the contents of `self` into `destinationBuffer`. + /// Handles both planar and non-planar pixel buffers. + func copy(to destinationBuffer: CVPixelBuffer) { + CVPixelBufferLockBaseAddress(self, .readOnly) + CVPixelBufferLockBaseAddress(destinationBuffer, []) + defer { + CVPixelBufferUnlockBaseAddress(destinationBuffer, []) + CVPixelBufferUnlockBaseAddress(self, .readOnly) + } + + let planeCount = CVPixelBufferGetPlaneCount(self) + if planeCount > 0 { + for plane in 0.. [CameraInfo] { let session = AVCaptureDevice.DiscoverySession( deviceTypes: deviceTypes, @@ -61,8 +55,6 @@ enum CameraDiscovery { } } - // 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. @@ -87,8 +79,6 @@ enum CameraDiscovery { } } - // MARK: - Print Table - /// Prints a formatted table of all discovered cameras to stdout. static func printTable() { let all = allDevices() @@ -98,13 +88,10 @@ enum CameraDiscovery { 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) + return s.padding(toLength: width, withPad: " ", startingAt: 0) } - // 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) diff --git a/Iris/Sources/FrameHost/CameraSource+HotSwap.swift b/Iris/Sources/FrameHost/CameraSource+HotSwap.swift new file mode 100644 index 0000000..20f5a8b --- /dev/null +++ b/Iris/Sources/FrameHost/CameraSource+HotSwap.swift @@ -0,0 +1,113 @@ +// CameraSource+HotSwap.swift +import Foundation +import AVFoundation + +extension CameraSource { + + 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) } + } + } + + 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 } + + let metadata = activeCameraMetadata() + let isOurDevice = device.uniqueID == activeDeviceUniqueID() + || 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 + session.stopRunning() + writeStatus(running: false) + } + + 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 } + guard candidate.hasMediaType(.video) else { return } + + let isMatch: Bool + let metadata = activeCameraMetadata() + if let id = activeDeviceUniqueID() { // fallback if cameraID wasn't used + let nameLower = id.lowercased() + isMatch = candidate.uniqueID == id + || candidate.localizedName.lowercased().contains(nameLower) + || candidate.localizedName == metadata.name + } else { + isMatch = candidate.localizedName == metadata.name + } + + guard isMatch else { return } + + 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)'.") + writeStatus(running: true) + } catch { + print("[CameraSource] Reconnect failed: \(error)") + let backoff = min(4.0, Double(reconnectAttempts)) + scheduleReconnect(candidate: nil, delay: backoff) + } + } + + private func reconnectCandidate() throws -> AVCaptureDevice { + let name = activeCameraMetadata().name + guard let device = CameraDiscovery.allDevices().first(where: { $0.localizedName == name })?.device else { + throw CameraError.noCameraFound + } + return device + } +} diff --git a/Iris/Sources/FrameHost/CameraSource.swift b/Iris/Sources/FrameHost/CameraSource.swift new file mode 100755 index 0000000..efcf887 --- /dev/null +++ b/Iris/Sources/FrameHost/CameraSource.swift @@ -0,0 +1,262 @@ +// 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 + +final class CameraSource: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + // MARK: - Configuration + + private let writer: SharedFrameWriter + private var actualWidth: Int = 0 + private var actualHeight: Int = 0 + private let fps: Int + private let udid: String + private let statusPath: String + private let cameraID: String? + + // MARK: - Session state + + let session = AVCaptureSession() + let sessionQueue = DispatchQueue(label: "com.iris.cameraSession") + private let captureQueue = DispatchQueue(label: "com.iris.cameraCapture") + private let stateLock = NSLock() + private let statusLock = NSLock() + + private var activeDevice: AVCaptureDevice? + private var activeCameraName: String = "mac-camera" + private var activeCameraType: String = "" + + // MARK: - Hot-swap state + + var isConnected: Bool = false + var isStopped: Bool = false + var reconnectAttempts: Int = 0 + let maxReconnectAttempts: Int = 5 + var lastDisconnectedAt: Date? + var disconnectObserver: NSObjectProtocol? + var connectObserver: NSObjectProtocol? + + // MARK: - Status tracking + + private let startedAt = Date() + private var lastStatusWrite = Date.distantPast + private var lastPublishNs: UInt64 = 0 + + // MARK: - Init + + init(writer: SharedFrameWriter, + fps: Int, + udid: String, + statusPath: String, + cameraID: String? = nil) { + self.writer = writer + self.fps = fps + self.udid = udid + self.statusPath = statusPath + self.cameraID = cameraID + 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(running: true) + 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() + } + 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 } + + stateLock.lock() + actualWidth = CVPixelBufferGetWidth(pixelBuffer) + actualHeight = CVPixelBufferGetHeight(pixelBuffer) + stateLock.unlock() + + let nowNs = currentMonotonicNs() + + do { + try writer.publish(pixelBuffer: pixelBuffer, pts: nowNs) + lastPublishNs = nowNs + writeStatus(running: true) + } catch { + print("[CameraSource] Error publishing frame: \(error)") + } + } + + // MARK: - 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 + } + + func configureSession(with device: AVCaptureDevice) throws { + session.beginConfiguration() + defer { session.commitConfiguration() } + session.inputs.forEach { session.removeInput($0) } + session.outputs.forEach { session.removeOutput($0) } + + if session.canSetSessionPreset(.photo) { + session.sessionPreset = .photo + } else if session.canSetSessionPreset(.high) { + session.sessionPreset = .high + } else if session.canSetSessionPreset(.hd1280x720) { + session.sessionPreset = .hd1280x720 + } + + 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_420YpCbCr8BiPlanarVideoRange), + kCVPixelBufferIOSurfacePropertiesKey as String: ["IOSurfaceIsGlobal": true] + ] + videoOutput.setSampleBufferDelegate(self, queue: captureQueue) + guard session.canAddOutput(videoOutput) else { throw CameraError.cannotAddOutput } + session.addOutput(videoOutput) + + 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: - Status + + 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() + } + + func activeCameraMetadata() -> (name: String, type: String) { + stateLock.lock() + defer { stateLock.unlock() } + return (activeCameraName, activeCameraType) + } + + func activeDeviceUniqueID() -> String? { + stateLock.lock() + defer { stateLock.unlock() } + return activeDevice?.uniqueID + } + + func writeStatus(running: Bool) { + statusLock.lock() + defer { statusLock.unlock() } + + let now = Date() + // Throttle status writes unless we are signaling a stopped state + if running && now.timeIntervalSince(lastStatusWrite) < 1.0 { return } + lastStatusWrite = now + + let metadata = activeCameraMetadata() + let isoFormatter = ISO8601DateFormatter() + let disconnectedAt = lastDisconnectedAt.map { isoFormatter.string(from: $0) } + let ageMs = (lastPublishNs == 0 || !running) ? 0 : ageInMs(fromMonotonicNs: lastPublishNs) + + stateLock.lock() + let currentWidth = actualWidth + let currentHeight = actualHeight + stateLock.unlock() + + let status = HostStatus( + udid: udid, + source: running ? metadata.name : "disconnected", + cameraName: metadata.name, + cameraType: metadata.type, + width: currentWidth, + height: currentHeight, + fps: fps, + framesProduced: writer.framesProduced, + hostPID: ProcessInfo.processInfo.processIdentifier, + startedAt: isoFormatter.string(from: startedAt), + lastFrameAgeMs: ageMs, + lastDisconnectedAt: disconnectedAt, + running: running + ) + + guard let data = try? JSONEncoder().encode(status) else { return } + try? data.write(to: URL(fileURLWithPath: statusPath), options: .atomic) + } +} + +// MARK: - Errors + +enum CameraError: Error, CustomStringConvertible { + case noCameraFound + case cannotAddInput + case cannotAddOutput + case deviceNotFound(String) + case ambiguousDevice(String) + + 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 + } + } +} diff --git a/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h b/Iris/Sources/FrameHost/FrameHost-Bridging-Header.h similarity index 64% rename from MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h rename to Iris/Sources/FrameHost/FrameHost-Bridging-Header.h index 1e0f864..3553f3a 100755 --- a/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h +++ b/Iris/Sources/FrameHost/FrameHost-Bridging-Header.h @@ -1,6 +1,6 @@ // FrameHost-Bridging-Header.h // Imports the shared C protocol header into Swift. -#include "MiniCamProtocol.h" -#include "MiniCamConstants.h" +#include "IrisProtocol.h" +#include "IrisConstants.h" #include diff --git a/MiniSimCam/Sources/FrameHost/FrameLoop.swift b/Iris/Sources/FrameHost/FrameLoop.swift similarity index 73% rename from MiniSimCam/Sources/FrameHost/FrameLoop.swift rename to Iris/Sources/FrameHost/FrameLoop.swift index 26afb16..a306b6b 100755 --- a/MiniSimCam/Sources/FrameHost/FrameLoop.swift +++ b/Iris/Sources/FrameHost/FrameLoop.swift @@ -4,34 +4,6 @@ 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 @@ -47,7 +19,7 @@ final class FrameLoop { 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) + private let queue = DispatchQueue(label: "com.iris.frameloop", qos: .userInteractive) // Status throttle: write at most once per second. private var lastStatusWriteNs: UInt64 = 0 @@ -113,10 +85,9 @@ final class FrameLoop { guard force || (now &- lastStatusWriteNs) >= statusWriteIntervalNs else { return } lastStatusWriteNs = now - let ageMs: Double = lastPublishNs == 0 ? 0 - : Double(now &- lastPublishNs) / 1_000_000.0 + let ageMs: Double = lastPublishNs == 0 ? 0 : ageInMs(fromMonotonicNs: lastPublishNs) - let status = FrameLoopStatus( + let status = HostStatus( udid: udid, source: sourceName, width: frame.width, @@ -134,5 +105,3 @@ final class FrameLoop { try? data.write(to: URL(fileURLWithPath: statusPath), options: .atomic) } } - -// currentMonotonicNs() is defined above, before FrameLoop, using a cached timebase. diff --git a/Iris/Sources/FrameHost/HostStatus.swift b/Iris/Sources/FrameHost/HostStatus.swift new file mode 100644 index 0000000..012befd --- /dev/null +++ b/Iris/Sources/FrameHost/HostStatus.swift @@ -0,0 +1,18 @@ +// HostStatus.swift +import Foundation + +struct HostStatus: Codable { + var udid: String + var source: String + var cameraName: String? + var cameraType: 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 lastDisconnectedAt: String? // ISO-8601 + var running: Bool +} diff --git a/MiniSimCam/Sources/FrameHost/ImageSource.swift b/Iris/Sources/FrameHost/ImageSource.swift similarity index 96% rename from MiniSimCam/Sources/FrameHost/ImageSource.swift rename to Iris/Sources/FrameHost/ImageSource.swift index 42687d6..cec026a 100755 --- a/MiniSimCam/Sources/FrameHost/ImageSource.swift +++ b/Iris/Sources/FrameHost/ImageSource.swift @@ -1,5 +1,5 @@ // ImageSource.swift -// Loads a PNG or JPEG and produces raw BGRA frames aligned to MSC_ROW_ALIGNMENT. +// Loads a PNG or JPEG and produces raw BGRA frames aligned to IRIS_ROW_ALIGNMENT. import CoreGraphics import ImageIO @@ -105,7 +105,7 @@ struct ImageSource { ) } - /// Round `width * 4` up to the nearest multiple of `MSC_ROW_ALIGNMENT` (64). + /// Round `width * 4` up to the nearest multiple of `IRIS_ROW_ALIGNMENT` (64). static func alignedBytesPerRow(_ width: Int) -> Int { let raw = width * 4 return (raw + 63) & ~63 diff --git a/MiniSimCam/Sources/FrameHost/Info.plist b/Iris/Sources/FrameHost/Info.plist similarity index 73% rename from MiniSimCam/Sources/FrameHost/Info.plist rename to Iris/Sources/FrameHost/Info.plist index 9b96054..76a2405 100755 --- a/MiniSimCam/Sources/FrameHost/Info.plist +++ b/Iris/Sources/FrameHost/Info.plist @@ -3,7 +3,7 @@ NSCameraUsageDescription - MiniSimCam needs access to your camera to inject live video into the iOS Simulator. + Iris needs access to your camera to inject live video into the iOS Simulator. NSCameraUseContinuityCameraDeviceType diff --git a/Iris/Sources/FrameHost/SharedFrameWriter.swift b/Iris/Sources/FrameHost/SharedFrameWriter.swift new file mode 100755 index 0000000..a51fb03 --- /dev/null +++ b/Iris/Sources/FrameHost/SharedFrameWriter.swift @@ -0,0 +1,236 @@ +// SharedFrameWriter.swift +// Creates and manages the shared-memory file and IOSurface-backed pixel buffer pool. + +import Foundation +import Darwin +import CoreVideo +import IOSurface +import IrisShared + +/// Writes IOSurfaceIDs into a memory-mapped header. +final class SharedFrameWriter { + + // MARK: - State + + private let path: String + private var mapping: UnsafeMutableRawPointer? + private var mappingSize: Int = 0 + + private var pool: CVPixelBufferPool? + private var poolWidth: Int = 0 + private var poolHeight: Int = 0 + + // Retain buffers we create so they stay alive long enough to be read. + private var ourRetainedBuffers: [CVPixelBuffer] = [] + + // MARK: - Init / Teardown + + init(path: String) { + self.path = path + } + + deinit { + close() + } + + func open(width: Int, height: Int) throws { + let totalSize = Int(IRIS_HEADER_EXPECTED_SIZE) // Just 128 bytes + + 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 { + 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 + + let hdr = ptr.bindMemory(to: IRISStreamHeader.self, capacity: 1) + if needsInit { + hdr.pointee.magic = IRIS_MAGIC + hdr.pointee.version = IRIS_VERSION + hdr.pointee.width = UInt32(width) + hdr.pointee.height = UInt32(height) + let bytesPerRow = ImageSource.alignedBytesPerRow(width) + hdr.pointee.bytesPerRow = UInt32(bytesPerRow) + hdr.pointee.pixelFormat = IRIS_PIXEL_FORMAT + } else { + // If we reused the file, repair sequence lock if odd + let seq = iris_seq_load_acquire(ptr) + if seq % 2 != 0 { + iris_seq_store_release(ptr, seq &+ 1) + } + } + ensurePool(width: width, height: height, pixelFormat: IRIS_PIXEL_FORMAT) + } + + private var poolPixelFormat: OSType = 0 + + private func ensurePool(width: Int, height: Int, pixelFormat: OSType) { + if pool != nil && poolWidth == width && poolHeight == height && poolPixelFormat == pixelFormat { return } + pool = nil + ourRetainedBuffers.removeAll() + poolWidth = width + poolHeight = height + poolPixelFormat = pixelFormat + + let poolAttributes: [String: Any] = [ + kCVPixelBufferPoolMinimumBufferCountKey as String: 6 + ] + + let bufferAttributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(pixelFormat), + kCVPixelBufferWidthKey as String: width, + kCVPixelBufferHeightKey as String: height, + kCVPixelBufferBytesPerRowAlignmentKey as String: Int(IRIS_ROW_ALIGNMENT), + kCVPixelBufferIOSurfacePropertiesKey as String: ["IOSurfaceIsGlobal": true] + ] + + var newPool: CVPixelBufferPool? + CVPixelBufferPoolCreate(kCFAllocatorDefault, poolAttributes as CFDictionary, bufferAttributes as CFDictionary, &newPool) + self.pool = newPool + } + + /// Closes the mapping and removes the shared file. + func close() { + if let mapping { + munmap(mapping, mappingSize) + } + mapping = nil + pool = nil + ourRetainedBuffers.removeAll() + try? FileManager.default.removeItem(atPath: path) + } + + // MARK: - Publishing + + /// Publishes one BGRA frame using the sequence-lock algorithm. + func publish(frame: BGRAFrame, pts: UInt64) throws { + guard let _ = mapping, let pool = pool else { throw WriterError.notOpen } + + var pixelBuffer: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer) == kCVReturnSuccess, + let pixBuf = pixelBuffer else { return } + + CVPixelBufferLockBaseAddress(pixBuf, []) + if let dest = CVPixelBufferGetBaseAddress(pixBuf) { + _ = frame.data.withUnsafeBytes { src in + memcpy(dest, src.baseAddress!, frame.data.count) + } + } + CVPixelBufferUnlockBaseAddress(pixBuf, []) + + try publish(poolBuffer: pixBuf, pts: pts) + + ourRetainedBuffers.append(pixBuf) + if ourRetainedBuffers.count > 3 { + ourRetainedBuffers.removeFirst() + } + } + + /// Publishes one frame directly from a CVPixelBuffer by copying it into our global pool. + func publish(pixelBuffer: CVPixelBuffer, pts: UInt64) throws { + // We MUST copy incoming AVFoundation buffers into our pool because AVFoundation's + // buffers are not marked as global IOSurfaces, so the simulator cannot map them! + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let fmt = CVPixelBufferGetPixelFormatType(pixelBuffer) + + ensurePool(width: width, height: height, pixelFormat: fmt) + guard let pool = pool else { throw WriterError.notOpen } + + var newBuffer: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &newBuffer) == kCVReturnSuccess, + let pixBuf = newBuffer else { return } + + pixelBuffer.copy(to: pixBuf) + + try publish(poolBuffer: pixBuf, pts: pts) + + ourRetainedBuffers.append(pixBuf) + if ourRetainedBuffers.count > 3 { + ourRetainedBuffers.removeFirst() + } + } + + private func publish(poolBuffer: CVPixelBuffer, pts: UInt64) throws { + guard let base = mapping else { throw WriterError.notOpen } + + guard let ioSurface = CVPixelBufferGetIOSurface(poolBuffer) else { return } + let surfaceID = IOSurfaceGetID(ioSurface.takeUnretainedValue()) + + let seqBefore = iris_seq_load_acquire(base) + iris_seq_store_release(base, seqBefore &+ 1) + + let hdr = base.bindMemory(to: IRISStreamHeader.self, capacity: 1) + hdr.pointee.presentationTimeNs = pts + hdr.pointee.width = UInt32(CVPixelBufferGetWidth(poolBuffer)) + hdr.pointee.height = UInt32(CVPixelBufferGetHeight(poolBuffer)) + hdr.pointee.bytesPerRow = UInt32(CVPixelBufferGetBytesPerRow(poolBuffer)) + hdr.pointee.pixelFormat = CVPixelBufferGetPixelFormatType(poolBuffer) + + iris_iosfc_store_relaxed(base, surfaceID) + + iris_seq_store_release(base, seqBefore &+ 2) + _ = iris_fp_fetch_add(base, 1) + } + + /// Returns the number of frames successfully published so far. + var framesProduced: UInt64 { + guard let base = mapping else { return 0 } + return iris_fp_load_relaxed(base) + } +} + +// MARK: - Errors + +enum WriterError: Error, CustomStringConvertible { + case cannotCreateFile(String) + case openFailed(String, errno: Int32) + case truncateFailed(errno: Int32) + case mmapFailed(errno: Int32) + case notOpen + + var description: String { + switch self { + case .cannotCreateFile(let p): return "Cannot create file at \(p)" + case .openFailed(let p, let e): return "open(\(p)) failed: \(String(cString: strerror(e)))" + case .truncateFailed(let e): return "ftruncate failed: \(String(cString: strerror(e)))" + case .mmapFailed(let e): return "mmap failed: \(String(cString: strerror(e)))" + case .notOpen: return "Writer is not open" + } + } +} diff --git a/Iris/Sources/FrameHost/TimeUtils.swift b/Iris/Sources/FrameHost/TimeUtils.swift new file mode 100644 index 0000000..740496c --- /dev/null +++ b/Iris/Sources/FrameHost/TimeUtils.swift @@ -0,0 +1,23 @@ +// TimeUtils.swift + +import Foundation + +/// 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) +} + +/// Converts a monotonic nanosecond timestamp age into milliseconds. +func ageInMs(fromMonotonicNs pastNs: UInt64) -> Double { + let nowNs = currentMonotonicNs() + if nowNs <= pastNs { return 0 } + return Double(nowNs - pastNs) / 1_000_000.0 +} diff --git a/MiniSimCam/Sources/FrameHost/main.swift b/Iris/Sources/FrameHost/main.swift similarity index 71% rename from MiniSimCam/Sources/FrameHost/main.swift rename to Iris/Sources/FrameHost/main.swift index eb6e6fa..c36511d 100755 --- a/MiniSimCam/Sources/FrameHost/main.swift +++ b/Iris/Sources/FrameHost/main.swift @@ -3,13 +3,13 @@ // We cannot use @main here — instead call FrameHostCommand.main() directly. import Foundation import ArgumentParser -import MiniSimCamShared +import IrisShared struct FrameHostCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "FrameHost", - abstract: "MiniSimCam frame producer — writes BGRA frames to a shared-memory triple buffer.", + abstract: "Iris frame producer — writes BGRA frames to a shared-memory triple buffer.", version: "1.0.0" ) @@ -30,20 +30,12 @@ struct FrameHostCommand: ParsableCommand { @Option(name: .long, help: "Camera name (substring) or uniqueID to select. Use 'sim cam list' to enumerate devices. Only valid with --camera.") var cameraID: String? - @Option(name: .long, help: "How to scale the camera frame to fit the target resolution: 'fill' (crop to fill, fast) or 'fit' (letterbox, preserves full frame). Only valid with --camera. (default: fill)") - var scaleMode: ScaleMode = .fill - @Flag(name: .long, help: "List all available cameras and exit. Does not require --udid.") var listCameras: Bool = false @Flag(name: .long, help: "Output camera list as JSON (use with --list-cameras).") var json: Bool = false - @Option(name: .long, help: "Output frame width in pixels.") - var width: Int = 1280 - - @Option(name: .long, help: "Output frame height in pixels.") - var height: Int = 720 @Option(name: .long, help: "Frames per second.") var fps: Int = 30 @@ -51,7 +43,7 @@ struct FrameHostCommand: ParsableCommand { // MARK: - Validation func validate() throws { - // --list-cameras is a standalone mode — skip all other checks. + // --list-cameras is a standalone mode skip all other checks. if listCameras { return } // Require exactly one source. @@ -63,11 +55,6 @@ struct FrameHostCommand: ParsableCommand { 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)") } @@ -86,9 +73,9 @@ struct FrameHostCommand: ParsableCommand { return } - let shmPath = "/tmp/minisimcam.\(udid).frames" - let statusPath = "/tmp/minisimcam.\(udid).status" - let pidPath = "/tmp/minisimcam.\(udid).pid" + let shmPath = "/tmp/iris.\(udid).frames" + let statusPath = "/tmp/iris.\(udid).status" + let pidPath = "/tmp/iris.\(udid).pid" // Never let a previous crashed host satisfy the CLI's readiness check. try? FileManager.default.removeItem(atPath: statusPath) @@ -100,7 +87,7 @@ struct FrameHostCommand: ParsableCommand { // Open shared memory. let writer = SharedFrameWriter(path: shmPath) - try writer.open(width: width, height: height) + try writer.open(width: 1280, height: 720) defer { writer.close() } // Start the appropriate source. @@ -113,24 +100,20 @@ struct FrameHostCommand: ParsableCommand { loop = nil camSource = CameraSource( writer: writer, - width: width, - height: height, fps: fps, udid: udid, statusPath: statusPath, - cameraID: cameraID, - scaleMode: scaleMode - ) + cameraID: cameraID ) try camSource?.start() } else { let frame: BGRAFrame if bars { sourceName = "color-bars" - frame = try ImageSource.colorBars(width: width, height: height) + frame = try ImageSource.colorBars(width: 1280, height: 720) } else { let url = URL(fileURLWithPath: image!) sourceName = url.lastPathComponent - frame = try ImageSource.load(url: url, targetWidth: width, targetHeight: height) + frame = try ImageSource.load(url: url, targetWidth: 1280, targetHeight: 720) } camSource = nil loop = FrameLoop( @@ -144,7 +127,7 @@ struct FrameHostCommand: ParsableCommand { loop?.start() } - print("[FrameHost] started — source=\(sourceName) \(width)×\(height) @ \(fps) fps") + print("[FrameHost] started — source=\(sourceName) @ \(fps) fps") print("[FrameHost] shared memory: \(shmPath)") print("[FrameHost] status file: \(statusPath)") print("[FrameHost] PID: \(pid)") @@ -153,22 +136,20 @@ struct FrameHostCommand: ParsableCommand { signal(SIGTERM, SIG_IGN) signal(SIGINT, SIG_IGN) - let sigTerm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) - sigTerm.setEventHandler { - print("[FrameHost] received SIGTERM — shutting down.") + let shutdown: (Int32) -> Void = { sig in + let sigName = sig == SIGTERM ? "SIGTERM" : "SIGINT" + print("[FrameHost] received \(sigName) — shutting down.") loop?.stop() camSource?.stop() Foundation.exit(0) } + + let sigTerm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + sigTerm.setEventHandler { shutdown(SIGTERM) } 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.setEventHandler { shutdown(SIGINT) } sigInt.resume() // Park the main thread. @@ -181,6 +162,3 @@ FrameHostCommand.main() // MARK: - ArgumentParser conformance -extension ScaleMode: ExpressibleByArgument { - init?(argument: String) { self.init(rawValue: argument) } -} diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h b/Iris/Sources/IrisInject/CaptureHooks.h similarity index 100% rename from MiniSimCam/Sources/MiniCamInject/CaptureHooks.h rename to Iris/Sources/IrisInject/CaptureHooks.h diff --git a/Iris/Sources/IrisInject/CaptureHooks.mm b/Iris/Sources/IrisInject/CaptureHooks.mm new file mode 100755 index 0000000..d202f29 --- /dev/null +++ b/Iris/Sources/IrisInject/CaptureHooks.mm @@ -0,0 +1,508 @@ +// CaptureHooks.mm +#import +#import +#import +#import +#import +#import + +#import "CaptureHooks.h" +#import "FakeCaptureObjects.h" +#import "SampleBufferFactory.h" +#import "SharedFrameReader.hpp" + +namespace { +struct InjectorState { + os_unfair_lock lock = OS_UNFAIR_LOCK_INIT; + dispatch_queue_t deliveryQueue = nullptr; + dispatch_source_t deliveryTimer = nullptr; + SharedFrameReader *reader = nullptr; + MSCSampleBufferFactory *factory = nullptr; + + id delegate = nil; + dispatch_queue_t delegateQueue = nullptr; + AVCaptureVideoDataOutput *output = nil; + + NSHashTable *previewLayers = nil; + + int32_t fps = 30; + bool running = false; + bool avSessionStarted = false; +}; + +InjectorState gState; + +void startDelivery(void); +void stopDelivery(void); +void deliverFrame(void); + +void MSCAutoStartSessionIfNeeded(AVCaptureSession *session) { + if (!session) + return; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)), + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + os_unfair_lock_lock(&gState.lock); + bool alreadyStarted = gState.avSessionStarted; + os_unfair_lock_unlock(&gState.lock); + + if (!alreadyStarted) { + NSLog(@"[IrisInject] Auto-starting AVCaptureSession"); + @try { + [session startRunning]; + } @catch (NSException *e) { + NSLog(@"[IrisInject] Auto-start failed: %@", e.reason); + } + } + }); +} + +void swizzleInstance(Class cls, SEL orig, SEL repl) { + Method origMethod = class_getInstanceMethod(cls, orig); + Method replMethod = class_getInstanceMethod(cls, repl); + if (!origMethod || !replMethod) { + NSLog(@"[IrisInject] ⚠️ Cannot swizzle %@.%@", NSStringFromClass(cls), + NSStringFromSelector(orig)); + return; + } + method_exchangeImplementations(origMethod, replMethod); +} + +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(@"[IrisInject] ⚠️ Cannot swizzle class method %@.%@", + NSStringFromClass(cls), NSStringFromSelector(orig)); + return; + } + method_exchangeImplementations(origMethod, replMethod); +} +} // namespace + +// MARK: - AVCaptureSession (IrisHook) + +@implementation AVCaptureSession (IrisHook) + +- (void)iris_startRunning { + NSLog(@"[IrisInject] AVCaptureSession startRunning intercepted"); + os_unfair_lock_lock(&gState.lock); + gState.avSessionStarted = true; + os_unfair_lock_unlock(&gState.lock); + + [self iris_startRunning]; + startDelivery(); +} + +- (void)iris_stopRunning { + [self iris_stopRunning]; + stopDelivery(); +} + +- (BOOL)iris_canAddInput:(AVCaptureInput *)input { + return YES; +} + +- (void)iris_addInput:(AVCaptureInput *)input { + NSLog(@"[IrisInject] AVCaptureSession addInput: silenced (no hardware)"); +} + +- (BOOL)iris_canAddOutput:(AVCaptureOutput *)output { + return YES; +} + +- (void)iris_addOutput:(AVCaptureOutput *)output { + [self iris_addOutput:output]; +} + +- (BOOL)iris_isRunning { + os_unfair_lock_lock(&gState.lock); + BOOL isRunning = gState.running; + os_unfair_lock_unlock(&gState.lock); + return isRunning; +} + +@end + +// MARK: - AVCaptureVideoDataOutput (IrisHook) + +@implementation AVCaptureVideoDataOutput (IrisHook) + +- (void)iris_setSampleBufferDelegate: + (id)delegate + queue:(dispatch_queue_t)queue { + os_unfair_lock_lock(&gState.lock); + gState.delegate = delegate; + gState.delegateQueue = queue ?: dispatch_get_main_queue(); + gState.output = self; + os_unfair_lock_unlock(&gState.lock); + + NSLog(@"[IrisInject] Captured delegate=%@ queue=%@", delegate, queue); + [self iris_setSampleBufferDelegate:delegate queue:queue]; +} + +@end + +// Fix for AVCapturePhotoOutput crash on iOS Simulator +@interface AVCaptureOutput (IrisHookFix) +@end +@implementation AVCaptureOutput (IrisHookFix) ++ (NSArray *)availableVideoCodecTypesForSourceDevice:(id)arg1 + sourceFormat:(id)arg2 + outputDimensions:(CMVideoDimensions)arg3 + fileType:(id)arg4 + videoCodecTypesAllowList:(id)arg5 { + return @[]; +} +@end + +// MARK: - AVCaptureDevice (IrisHook) + +@implementation AVCaptureDevice (IrisHook) + ++ (nullable AVCaptureDevice *)iris_defaultDeviceWithMediaType: + (AVMediaType)mediaType { + if ([mediaType isEqualToString:AVMediaTypeVideo]) { + static MSCFakeCaptureDevice *fakeDevice = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeDevice = (MSCFakeCaptureDevice *)class_createInstance( + [MSCFakeCaptureDevice class], 0); + }); + return fakeDevice; + } + return [self iris_defaultDeviceWithMediaType:mediaType]; +} + ++ (nullable AVCaptureDevice *) + iris_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( + [MSCFakeCaptureDevice class], 0); + }); + return fakeDevice; + } + return [self iris_defaultDeviceWithDeviceType:deviceType + mediaType:mediaType + position:position]; +} + ++ (AVAuthorizationStatus)iris_authorizationStatusForMediaType: + (AVMediaType)mediaType { + if ([mediaType isEqualToString:AVMediaTypeVideo]) { + return AVAuthorizationStatusAuthorized; + } + return [self iris_authorizationStatusForMediaType:mediaType]; +} + ++ (void)iris_requestAccessForMediaType:(AVMediaType)mediaType + completionHandler:(void (^)(BOOL granted))handler { + if ([mediaType isEqualToString:AVMediaTypeVideo]) { + if (handler) { + dispatch_async(dispatch_get_main_queue(), ^{ + handler(YES); + }); + } + return; + } + [self iris_requestAccessForMediaType:mediaType completionHandler:handler]; +} + +@end + +// MARK: - AVCaptureDeviceDiscoverySession (IrisHook) + +@implementation AVCaptureDeviceDiscoverySession (IrisHook) + +- (NSArray *)iris_devices { + static MSCFakeCaptureDevice *fakeDevice = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeDevice = (MSCFakeCaptureDevice *)class_createInstance( + [MSCFakeCaptureDevice class], 0); + }); + return @[ fakeDevice ]; +} + +@end + +// MARK: - AVCaptureDeviceInput (IrisHook) + +@implementation AVCaptureDeviceInput (IrisHook) + ++ (instancetype)iris_deviceInputWithDevice:(AVCaptureDevice *)device + error:(NSError **)outError { + if ([device isKindOfClass:[MSCFakeCaptureDevice class]]) { + static MSCFakeCaptureInput *fakeInput = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeInput = (MSCFakeCaptureInput *)class_createInstance( + [MSCFakeCaptureInput class], 0); + }); + return fakeInput; + } + return [self iris_deviceInputWithDevice:device error:outError]; +} + +- (instancetype)init_irisWithDevice:(AVCaptureDevice *)device + error:(NSError **)outError { + if ([device isKindOfClass:[MSCFakeCaptureDevice class]]) { + static MSCFakeCaptureInput *fakeInput = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeInput = (MSCFakeCaptureInput *)class_createInstance( + [MSCFakeCaptureInput class], 0); + }); + return fakeInput; // Return directly as ARC handles +1/-1 lifecycle + } + return [self init_irisWithDevice:device error:outError]; +} + +@end + +// MARK: - AVCaptureVideoPreviewLayer (IrisHook) + +@implementation AVCaptureVideoPreviewLayer (IrisHook) + +- (instancetype)init_irisWithSession:(AVCaptureSession *)session { + id instance = [self init_irisWithSession:session]; + if (instance) { + os_unfair_lock_lock(&gState.lock); + [gState.previewLayers addObject:instance]; + os_unfair_lock_unlock(&gState.lock); + + ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = + kCAGravityResizeAspectFill; + startDelivery(); + MSCAutoStartSessionIfNeeded(session); + } + return instance; +} + +- (instancetype)init_irisWithSessionWithNoConnection: + (AVCaptureSession *)session { + id instance = [self init_irisWithSessionWithNoConnection:session]; + if (instance) { + os_unfair_lock_lock(&gState.lock); + [gState.previewLayers addObject:instance]; + os_unfair_lock_unlock(&gState.lock); + + ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = + kCAGravityResizeAspectFill; + startDelivery(); + MSCAutoStartSessionIfNeeded(session); + } + return instance; +} + +- (void)iris_setSession:(AVCaptureSession *)session { + os_unfair_lock_lock(&gState.lock); + if (session) { + [gState.previewLayers addObject:self]; + } else { + [gState.previewLayers removeObject:self]; + } + os_unfair_lock_unlock(&gState.lock); + + self.contentsGravity = kCAGravityResizeAspectFill; + + if (session) { + startDelivery(); + MSCAutoStartSessionIfNeeded(session); + } + + [self iris_setSession:session]; +} + +@end + +// MARK: - Delivery Engine + +namespace { +void startDelivery(void) { + os_unfair_lock_lock(&gState.lock); + if (gState.running) { + os_unfair_lock_unlock(&gState.lock); + return; + } + gState.running = true; + os_unfair_lock_unlock(&gState.lock); + + uint64_t intervalNs = 1'000'000'000ULL / (uint64_t)gState.fps; + uint64_t leewayNs = intervalNs / 10; + + gState.deliveryTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, + 0, gState.deliveryQueue); + dispatch_source_set_timer(gState.deliveryTimer, DISPATCH_TIME_NOW, intervalNs, + leewayNs); + dispatch_source_set_event_handler(gState.deliveryTimer, ^{ + deliverFrame(); + }); + dispatch_resume(gState.deliveryTimer); + NSLog(@"[IrisInject] Delivery started @ %d fps", gState.fps); +} + +void stopDelivery(void) { + os_unfair_lock_lock(&gState.lock); + if (!gState.running) { + os_unfair_lock_unlock(&gState.lock); + return; + } + gState.running = false; + dispatch_source_t t = gState.deliveryTimer; + gState.deliveryTimer = nullptr; + os_unfair_lock_unlock(&gState.lock); + + if (t) { + dispatch_source_cancel(t); + } + NSLog(@"[IrisInject] Delivery stopped"); +} + +void deliverFrame(void) { + if (!gState.reader) + return; + + if (!gState.reader->isOpen()) { + if (!gState.reader->open()) { + return; + } + } + + CMSampleBufferRef rawBuf = + [gState.factory sampleBufferFromReader:gState.reader]; + if (!rawBuf) + return; + + id arcSampleBuf = CFBridgingRelease(rawBuf); // ARC now owns the sample buffer + + CVPixelBufferRef pixelBuffer = + CMSampleBufferGetImageBuffer((__bridge CMSampleBufferRef)arcSampleBuf); + if (pixelBuffer) { + CGImageRef cgImage = NULL; + if (VTCreateCGImageFromCVPixelBuffer(pixelBuffer, NULL, &cgImage) == + noErr && + cgImage) { + id arcImage = (__bridge_transfer id)cgImage; + + os_unfair_lock_lock(&gState.lock); + NSArray *layers = [gState.previewLayers allObjects]; + os_unfair_lock_unlock(&gState.lock); + + if (layers.count > 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + for (AVCaptureVideoPreviewLayer *layer in layers) { + layer.contents = arcImage; + } + }); + } + } + } + + os_unfair_lock_lock(&gState.lock); + id del = gState.delegate; + dispatch_queue_t q = gState.delegateQueue; + AVCaptureVideoDataOutput *outObj = gState.output; + os_unfair_lock_unlock(&gState.lock); + + if (!del || !q) + return; + + dispatch_async(q, ^{ + if ([del respondsToSelector:@selector(captureOutput: + didOutputSampleBuffer:fromConnection:)]) { + CMSampleBufferRef sampleBuf = (__bridge CMSampleBufferRef)arcSampleBuf; + AVCaptureConnection *conn = outObj.connections.firstObject; + if (!conn) { + static MSCFakeConnection *fakeConn = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeConn = (MSCFakeConnection *)class_createInstance( + [MSCFakeConnection class], 0); + }); + conn = fakeConn; + } + [del captureOutput:outObj + didOutputSampleBuffer:sampleBuf + fromConnection:conn]; + } + }); +} +} // namespace + +// MARK: - Lifecycle Setup + +void MSCInstallHooks(SharedFrameReader *reader, int32_t fps) { + gState.deliveryQueue = + dispatch_queue_create("com.iris.delivery", DISPATCH_QUEUE_SERIAL); + gState.reader = reader; + gState.fps = fps; + gState.factory = [[MSCSampleBufferFactory alloc] initWithFPS:fps]; + gState.previewLayers = [NSHashTable weakObjectsHashTable]; + + swizzleInstance([AVCaptureSession class], @selector(startRunning), + @selector(iris_startRunning)); + swizzleInstance([AVCaptureSession class], @selector(stopRunning), + @selector(iris_stopRunning)); + swizzleInstance([AVCaptureSession class], @selector(canAddInput:), + @selector(iris_canAddInput:)); + swizzleInstance([AVCaptureSession class], @selector(addInput:), + @selector(iris_addInput:)); + swizzleInstance([AVCaptureSession class], @selector(canAddOutput:), + @selector(iris_canAddOutput:)); + swizzleInstance([AVCaptureSession class], @selector(addOutput:), + @selector(iris_addOutput:)); + swizzleInstance([AVCaptureSession class], @selector(isRunning), + @selector(iris_isRunning)); + + swizzleInstance([AVCaptureVideoDataOutput class], + @selector(setSampleBufferDelegate:queue:), + @selector(iris_setSampleBufferDelegate:queue:)); + + Class layerCls = NSClassFromString(@"AVCaptureVideoPreviewLayer"); + if (layerCls) { + swizzleInstance(layerCls, @selector(setSession:), + @selector(iris_setSession:)); + swizzleInstance(layerCls, @selector(initWithSession:), + @selector(init_irisWithSession:)); + swizzleInstance(layerCls, @selector(initWithSessionWithNoConnection:), + @selector(init_irisWithSessionWithNoConnection:)); + } + + swizzleClass([AVCaptureDevice class], @selector(defaultDeviceWithMediaType:), + @selector(iris_defaultDeviceWithMediaType:)); + swizzleClass([AVCaptureDevice class], + @selector(defaultDeviceWithDeviceType:mediaType:position:), + @selector(iris_defaultDeviceWithDeviceType:mediaType:position:)); + swizzleClass([AVCaptureDevice class], + @selector(authorizationStatusForMediaType:), + @selector(iris_authorizationStatusForMediaType:)); + swizzleClass([AVCaptureDevice class], + @selector(requestAccessForMediaType:completionHandler:), + @selector(iris_requestAccessForMediaType:completionHandler:)); + + swizzleClass([AVCaptureDeviceInput class], + @selector(deviceInputWithDevice:error:), + @selector(iris_deviceInputWithDevice:error:)); + swizzleInstance([AVCaptureDeviceInput class], + @selector(initWithDevice:error:), + @selector(init_irisWithDevice:error:)); + + Class discoverySessionCls = + NSClassFromString(@"AVCaptureDeviceDiscoverySession"); + if (discoverySessionCls) { + swizzleInstance(discoverySessionCls, @selector(devices), + @selector(iris_devices)); + } + + NSLog(@"[IrisInject] Hooks installed — fps=%d", fps); +} + +void MSCUninstallHooks(void) { stopDelivery(); } diff --git a/Iris/Sources/IrisInject/EntryPoint.mm b/Iris/Sources/IrisInject/EntryPoint.mm new file mode 100755 index 0000000..cae8a8f --- /dev/null +++ b/Iris/Sources/IrisInject/EntryPoint.mm @@ -0,0 +1,56 @@ +// EntryPoint.mm +// Initializes the injector, parses settings, and installs AVFoundation hooks. + +#import +#import "CaptureHooks.h" +#import "SharedFrameReader.hpp" +#include "IrisConstants.h" +#include +#include + +static SharedFrameReader* gGlobalReader = nullptr; + +static int32_t parseFPS(void) { + const char* envFPS = getenv(IRIS_ENV_FPS); + if (!envFPS) return IRIS_DEFAULT_FPS; + int v = atoi(envFPS); + return (v > 0 && v <= 120) ? (int32_t)v : IRIS_DEFAULT_FPS; +} + +__attribute__((constructor)) +static void IrisInjectInit(void) { + @autoreleasepool { + const char* pathEnv = getenv(IRIS_ENV_PATH); + if (!pathEnv || pathEnv[0] == '\0') { + NSLog(@"[IrisInject] %s not set — injector inactive.", IRIS_ENV_PATH); + return; + } + + std::string shmPath(pathEnv); + NSLog(@"[IrisInject] loading — shm=%s", shmPath.c_str()); + + gGlobalReader = new SharedFrameReader(shmPath); + + if (!gGlobalReader->open()) { + NSLog(@"[IrisInject] ⚠️ Cannot open shared memory at %s. " + "Start 'sim cam start' before launching the app.", + shmPath.c_str()); + } + + int32_t fps = parseFPS(); + MSCInstallHooks(gGlobalReader, fps); + + NSLog(@"[IrisInject] ✅ injector ready (fps=%d, shm=%s)", fps, shmPath.c_str()); + } +} + +__attribute__((destructor)) +static void IrisInjectFini(void) { + MSCUninstallHooks(); + if (gGlobalReader) { + gGlobalReader->close(); + delete gGlobalReader; + gGlobalReader = nullptr; + } + NSLog(@"[IrisInject] unloaded."); +} diff --git a/Iris/Sources/IrisInject/FakeCaptureObjects.h b/Iris/Sources/IrisInject/FakeCaptureObjects.h new file mode 100644 index 0000000..7d69f23 --- /dev/null +++ b/Iris/Sources/IrisInject/FakeCaptureObjects.h @@ -0,0 +1,18 @@ +// FakeCaptureObjects.h +#import + +@interface MSCFakeConnection : AVCaptureConnection +@end + +@interface MSCFakeCaptureDeviceFormat : NSObject +- (CMFormatDescriptionRef)formatDescription; +@end + +@interface MSCFakeCaptureDevice : AVCaptureDevice +@end + +@interface MSCFakeCaptureInputPort : AVCaptureInputPort +@end + +@interface MSCFakeCaptureInput : AVCaptureDeviceInput +@end diff --git a/Iris/Sources/IrisInject/FakeCaptureObjects.mm b/Iris/Sources/IrisInject/FakeCaptureObjects.mm new file mode 100644 index 0000000..2e9c290 --- /dev/null +++ b/Iris/Sources/IrisInject/FakeCaptureObjects.mm @@ -0,0 +1,90 @@ +// FakeCaptureObjects.mm +#import "FakeCaptureObjects.h" +#import + +@implementation MSCFakeConnection +@end + +@implementation MSCFakeCaptureDeviceFormat +- (CMFormatDescriptionRef)formatDescription { + CMVideoFormatDescriptionRef formatDesc = NULL; + CMVideoFormatDescriptionCreate(kCFAllocatorDefault, kCVPixelFormatType_32BGRA, 1280, 720, NULL, &formatDesc); + return formatDesc; +} +@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 {} + +- (NSString *)uniqueID { return @"MSCFakeCamera_001"; } +- (NSString *)localizedName { return @"Iris Fake Device"; } +- (AVCaptureDevicePosition)position { return AVCaptureDevicePositionBack; } +- (BOOL)isConnected { return YES; } +- (BOOL)isSuspended { return NO; } + +- (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); } + +- (AVCaptureDeviceType)deviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; } + +- (id)activeFormat { + static MSCFakeCaptureDeviceFormat *fakeFormat = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeFormat = [[MSCFakeCaptureDeviceFormat alloc] init]; + }); + return fakeFormat; +} +@end + +@implementation MSCFakeCaptureInputPort +- (AVMediaType)mediaType { return AVMediaTypeVideo; } +- (CMFormatDescriptionRef)formatDescription { + 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 + +@implementation MSCFakeCaptureInput +- (AVCaptureDevice *)device { + static MSCFakeCaptureDevice *fakeDevice = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakeDevice = (MSCFakeCaptureDevice *)class_createInstance([MSCFakeCaptureDevice class], 0); + }); + return fakeDevice; +} +- (NSArray *)ports { + static MSCFakeCaptureInputPort *fakePort = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fakePort = (MSCFakeCaptureInputPort *)class_createInstance([MSCFakeCaptureInputPort class], 0); + }); + return @[fakePort]; +} +@end diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h b/Iris/Sources/IrisInject/SampleBufferFactory.h similarity index 59% rename from MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h rename to Iris/Sources/IrisInject/SampleBufferFactory.h index 706f4e7..b971a16 100755 --- a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h +++ b/Iris/Sources/IrisInject/SampleBufferFactory.h @@ -18,13 +18,8 @@ NS_ASSUME_NONNULL_BEGIN - (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). +/// Optimised zero-copy path: reads frame metadata from the shm header, looks up the IOSurface, +/// and wraps it in a CVPixelBuffer. /// Returns a +1 CF reference on success; the caller is responsible for CFRelease. - (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader CF_RETURNS_RETAINED; #endif diff --git a/Iris/Sources/IrisInject/SampleBufferFactory.mm b/Iris/Sources/IrisInject/SampleBufferFactory.mm new file mode 100755 index 0000000..100fdba --- /dev/null +++ b/Iris/Sources/IrisInject/SampleBufferFactory.mm @@ -0,0 +1,108 @@ +// SampleBufferFactory.mm +#import +#import +#import +#import +#import +#import "SampleBufferFactory.h" +#import "SharedFrameReader.hpp" + +@implementation MSCSampleBufferFactory { + CMVideoFormatDescriptionRef _formatDesc; + uint32_t _descWidth; + uint32_t _descHeight; + 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 { + if (_formatDesc) { + CFRelease(_formatDesc); + _formatDesc = nullptr; + } +} + +- (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader { + if (!reader || !reader->isOpen()) return nil; + + FrameSnapshot snap = reader->copyLatestFrame(); + if (!snap.valid || snap.ioSurfaceID == 0) return nil; + + IOSurfaceRef ioSurface = IOSurfaceLookup(snap.ioSurfaceID); + if (!ioSurface) return nil; + + CVPixelBufferRef pixBuf = nullptr; + NSDictionary *attrs = @{ + (NSString *)kCVPixelBufferPixelFormatTypeKey: @(snap.pixelFormat), + (NSString *)kCVPixelBufferWidthKey: @(snap.width), + (NSString *)kCVPixelBufferHeightKey: @(snap.height), + }; + + CVReturn ret = CVPixelBufferCreateWithIOSurface( + kCFAllocatorDefault, + ioSurface, + (__bridge CFDictionaryRef)attrs, + &pixBuf + ); + + CFRelease(ioSurface); + + if (ret != kCVReturnSuccess || !pixBuf) { + if (pixBuf) CVPixelBufferRelease(pixBuf); + return nil; + } + + return [self wrapPixelBuffer:pixBuf pts:snap.ptsNs width:snap.width height:snap.height]; +} + +- (nullable CMSampleBufferRef)wrapPixelBuffer:(CVPixelBufferRef)pixBuf pts:(uint64_t)ptsNs width:(uint32_t)w height:(uint32_t)h { + if (!_formatDesc || _descWidth != w || _descHeight != h) { + if (_formatDesc) { + CFRelease(_formatDesc); + _formatDesc = nullptr; + } + CMVideoFormatDescriptionCreateForImageBuffer( + kCFAllocatorDefault, pixBuf, &_formatDesc + ); + _descWidth = w; + _descHeight = h; + } + + 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 sampleBuf; +} + +@end diff --git a/Iris/Sources/IrisInject/SharedFrameReader.cpp b/Iris/Sources/IrisInject/SharedFrameReader.cpp new file mode 100755 index 0000000..5f6f5d3 --- /dev/null +++ b/Iris/Sources/IrisInject/SharedFrameReader.cpp @@ -0,0 +1,139 @@ +// SharedFrameReader.cpp +#include "SharedFrameReader.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} + +template +static std::atomic* atomicAt(void* base, size_t offset) { + return reinterpret_cast*>( + static_cast(base) + offset + ); +} + +constexpr size_t kOffSequence = IRIS_OFF_SEQUENCE; +constexpr size_t kOffIOSurfaceID = IRIS_OFF_IOSURFACE_ID; +constexpr size_t kOffFramesProduced = IRIS_OFF_FRAMES_PRODUCED; + +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; + + IRISStreamHeader hdr = {}; + if (::read(fd_, &hdr, sizeof(hdr)) != (ssize_t)sizeof(hdr)) { + ::close(fd_); fd_ = -1; + return false; + } + + if (hdr.magic != IRIS_MAGIC || hdr.version != IRIS_VERSION) { + ::close(fd_); fd_ = -1; + return false; + } + + size_t total = iris_mapping_size(); + + 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 != IRIS_MAGIC || hdr->version != IRIS_VERSION) return snap; + + const uint32_t bpr = hdr->bytesPerRow; + const uint32_t fmt = hdr->pixelFormat; + const uint32_t w = hdr->width; + const uint32_t h = hdr->height; + + auto* seqAtomic = atomicAt(mapping_, kOffSequence); + auto* sfcAtomic = atomicAt(mapping_, kOffIOSurfaceID); + auto* fpAtomic = atomicAt(mapping_, kOffFramesProduced); + + for (int attempt = 0; attempt < 64; ++attempt) { + uint64_t seqA = seqAtomic->load(std::memory_order_acquire); + if (seqA & 1u) { +#if defined(__aarch64__) || defined(__arm64__) + __builtin_arm_yield(); +#elif defined(__x86_64__) || defined(__i386__) + __asm__ volatile("pause" ::: "memory"); +#else + sched_yield(); +#endif + continue; + } + + uint32_t sfcID = sfcAtomic->load(std::memory_order_acquire); + uint64_t pts = hdr->presentationTimeNs; + uint64_t fp = fpAtomic->load(std::memory_order_acquire); + + uint64_t seqB = seqAtomic->load(std::memory_order_acquire); + if (seqA != seqB) continue; + + snap.valid = true; + snap.width = w; + snap.height = h; + snap.bytesPerRow = bpr; + snap.pixelFormat = fmt; + snap.ptsNs = pts; + snap.framesProduced = fp; + snap.ioSurfaceID = sfcID; + return snap; + } + + return snap; +} + +bool SharedFrameReader::isProducerStale() const { + if (!mapping_) return true; + auto* hdr = header(); + return (monoNs() - hdr->presentationTimeNs) > IRIS_STALE_THRESHOLD_NS; +} diff --git a/Iris/Sources/IrisInject/SharedFrameReader.hpp b/Iris/Sources/IrisInject/SharedFrameReader.hpp new file mode 100755 index 0000000..f6a2f08 --- /dev/null +++ b/Iris/Sources/IrisInject/SharedFrameReader.hpp @@ -0,0 +1,57 @@ +// SharedFrameReader.hpp + +#pragma once +#include "IrisProtocol.h" +#include "IrisConstants.h" +#include +#include + +/// Result returned by SharedFrameReader::copyLatestFrame. +struct FrameSnapshot { + bool valid = false; + uint32_t width = 0; + uint32_t height = 0; + uint32_t bytesPerRow = 0; + uint32_t pixelFormat = 0; + uint64_t ptsNs = 0; ///< Presentation timestamp (monotonic ns) + uint64_t framesProduced = 0; + uint32_t ioSurfaceID = 0; ///< The zero-copy IOSurface ID +}; + +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. + bool open(); + + /// Close the mapping (idempotent). + void close(); + + bool isOpen() const { return mapping_ != nullptr; } + + const IRISStreamHeader* peekHeader() const { + return mapping_ ? static_cast(mapping_) : nullptr; + } + + /// Read the latest frame metadata and IOSurfaceID using the sequence-lock. + FrameSnapshot copyLatestFrame(); + + /// Check whether the producer has gone stale (no update within IRIS_STALE_THRESHOLD_NS). + bool isProducerStale() const; + +private: + std::string path_; + void* mapping_ = nullptr; + size_t mappingSize_ = 0; + int fd_ = -1; + + IRISStreamHeader* header() const { + return static_cast(mapping_); + } +}; diff --git a/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift b/Iris/Tests/FrameTransportTests/FrameTransportTests.swift similarity index 97% rename from MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift rename to Iris/Tests/FrameTransportTests/FrameTransportTests.swift index e4f6f28..c096616 100755 --- a/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift +++ b/Iris/Tests/FrameTransportTests/FrameTransportTests.swift @@ -11,7 +11,7 @@ final class FrameTransportTests: XCTestCase { override func setUp() { super.setUp() - tmpPath = NSTemporaryDirectory() + "minisimcam_test_\(UUID().uuidString).frames" + tmpPath = NSTemporaryDirectory() + "iris_test_\(UUID().uuidString).frames" } override func tearDown() { diff --git a/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift b/Iris/Tests/ProtocolTests/ProtocolTests.swift similarity index 68% rename from MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift rename to Iris/Tests/ProtocolTests/ProtocolTests.swift index 3a8d1cb..5a7c9c1 100755 --- a/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift +++ b/Iris/Tests/ProtocolTests/ProtocolTests.swift @@ -1,9 +1,9 @@ // ProtocolTests.swift -// Unit tests for MiniCamProtocol.h calculations. +// Unit tests for IrisProtocol.h calculations. // These do NOT require a running FrameHost; they validate pure math. import XCTest -@testable import MiniSimCamShared // Swift wrapper around the C header +@testable import IrisShared // Swift wrapper around the C header final class ProtocolTests: XCTestCase { @@ -11,39 +11,39 @@ final class ProtocolTests: XCTestCase { func testHeaderSize() { // Must remain exactly 128 bytes so existing shared files stay valid. - XCTAssertEqual(MemoryLayout.size, 128) + 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) + XCTAssertEqual(iris_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) + XCTAssertEqual(iris_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) + let bpr = iris_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) + XCTAssertEqual(iris_bytes_per_row(1), 64) } // MARK: - Mapping size func testMappingSizeFormula() { - let bpr = msc_bytes_per_row(1280) + let bpr = iris_bytes_per_row(1280) let bufSize = bpr * 720 - let total = msc_mapping_size(bufSize) + let total = iris_mapping_size(bufSize) XCTAssertEqual(total, UInt64(128) + UInt64(3) * UInt64(bufSize)) } @@ -54,9 +54,9 @@ final class ProtocolTests: XCTestCase { 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) + let p0 = iris_frame_ptr(base, bufSize, 0) + let p1 = iris_frame_ptr(base, bufSize, 1) + let p2 = iris_frame_ptr(base, bufSize, 2) XCTAssertEqual(p0, base.advanced(by: 128)) XCTAssertEqual(p1, base.advanced(by: 128 + 100)) @@ -67,21 +67,21 @@ final class ProtocolTests: XCTestCase { // MARK: - Magic / version constants func testMagicConstant() { - XCTAssertEqual(MSC_MAGIC, 0x4D534343) + XCTAssertEqual(IRIS_MAGIC, 0x4D534343) } func testVersionConstant() { - XCTAssertEqual(MSC_VERSION, 1) + XCTAssertEqual(IRIS_VERSION, 1) } func testBufferCount() { - XCTAssertEqual(MSC_BUFFER_COUNT, 3) + XCTAssertEqual(IRIS_BUFFER_COUNT, 3) } // MARK: - Pixel format func testPixelFormat() { // kCVPixelFormatType_32BGRA = 'BGRA' = 0x42475241 - XCTAssertEqual(MSC_PIXEL_FORMAT, 0x42475241) + XCTAssertEqual(IRIS_PIXEL_FORMAT, 0x42475241) } } diff --git a/Iris/test_iosfc.swift b/Iris/test_iosfc.swift new file mode 100644 index 0000000..71466f2 --- /dev/null +++ b/Iris/test_iosfc.swift @@ -0,0 +1,29 @@ +import Foundation +import CoreVideo +import IOSurface + +let iosurfaceProps: [String: Any] = [ + "IOSurfaceIsGlobal": true +] + +let attrs: [String: Any] = [ + kCVPixelBufferWidthKey as String: 1920, + kCVPixelBufferHeightKey as String: 1080, + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferIOSurfacePropertiesKey as String: iosurfaceProps +] + +var pixBuf: CVPixelBuffer? +CVPixelBufferCreate(kCFAllocatorDefault, 1920, 1080, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixBuf) + +if let pixBuf = pixBuf { + let ioSurface = CVPixelBufferGetIOSurface(pixBuf)! + let id = IOSurfaceGetID(ioSurface.takeUnretainedValue()) + print("Created IOSurface ID: \(id)") + + let task = Process() + task.executableURL = URL(fileURLWithPath: "./test_lookup") + task.arguments = ["\(id)"] + try! task.run() + task.waitUntilExit() +} diff --git a/Iris/test_lookup b/Iris/test_lookup new file mode 100755 index 0000000..bc9ef46 Binary files /dev/null and b/Iris/test_lookup differ diff --git a/Iris/test_lookup.c b/Iris/test_lookup.c new file mode 100644 index 0000000..a5b2bd6 --- /dev/null +++ b/Iris/test_lookup.c @@ -0,0 +1,16 @@ +#include +#include +#include + +int main(int argc, char** argv) { + if (argc < 2) return 1; + uint32_t id = atoi(argv[1]); + IOSurfaceRef sfc = IOSurfaceLookup(id); + if (sfc) { + printf("Lookup success for %d\n", id); + CFRelease(sfc); + } else { + printf("Lookup failed for %d\n", id); + } + return 0; +} diff --git a/Makefile b/Makefile index fc5e11b..bee1806 100644 --- a/Makefile +++ b/Makefile @@ -12,25 +12,31 @@ 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 + @cd Iris && ./Scripts/build.sh + @printf " \033[2m›\033[0m Compiling sim..." + @go build -tags cam_embed $(LDFLAGS) -o sim + @printf "\r \033[32m✓\033[0m Compiling sim \n" + @printf "\n \033[1m\033[32msim\033[0m built successfully.\n\n" else - go build $(LDFLAGS) -o sim + @printf " \033[2m›\033[0m Compiling sim..." + @go build $(LDFLAGS) -o sim + @printf "\r \033[32m✓\033[0m Compiling sim \n" + @printf "\n \033[1m\033[32msim\033[0m built successfully.\n\n" endif # Build for multiple platforms build-all: - mkdir -p dist + @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 + @cd Iris && ./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 + @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 + @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 # Clean build artifacts clean: @@ -39,14 +45,14 @@ clean: rm -rf cmd/assets/ rm -f coverage.out coverage.html -# Build MiniSimCam (FrameHost + MiniCamInject dylib) +# Build Iris (FrameHost + IrisInject dylib) cam-build: - cd MiniSimCam && ./Scripts/build.sh + @cd Iris && ./Scripts/build.sh -# Clean MiniSimCam build artifacts +# Clean Iris build artifacts cam-clean: - cd MiniSimCam && swift package clean - rm -rf MiniSimCam/.build/injector + cd Iris && swift package clean + rm -rf Iris/.build/injector rm -rf cmd/assets/ # Install dependencies @@ -103,6 +109,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 " cam-build - Build Iris (FrameHost + IrisInject dylib)" + @echo " cam-clean - Clean Iris build artifacts" @echo " help - Show this help" \ No newline at end of file diff --git a/MiniSimCam/Scripts/build.sh b/MiniSimCam/Scripts/build.sh deleted file mode 100755 index 0eab3bf..0000000 --- a/MiniSimCam/Scripts/build.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/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/Shared/AtomicHelpers.c b/MiniSimCam/Shared/AtomicHelpers.c deleted file mode 100755 index 09b5791..0000000 --- a/MiniSimCam/Shared/AtomicHelpers.c +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100755 index 35717b3..0000000 --- a/MiniSimCam/Shared/include/AtomicHelpers.h +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100755 index ae005e1..0000000 --- a/MiniSimCam/Shared/include/MiniCamConstants.h +++ /dev/null @@ -1,44 +0,0 @@ -#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/module.modulemap b/MiniSimCam/Shared/include/module.modulemap deleted file mode 100755 index c8317cb..0000000 --- a/MiniSimCam/Shared/include/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -module MiniSimCamShared { - header "MiniCamProtocol.h" - header "MiniCamConstants.h" - header "AtomicHelpers.h" - export * -} diff --git a/MiniSimCam/Sources/FrameHost/CameraSource.swift b/MiniSimCam/Sources/FrameHost/CameraSource.swift deleted file mode 100755 index 2b50c19..0000000 --- a/MiniSimCam/Sources/FrameHost/CameraSource.swift +++ /dev/null @@ -1,530 +0,0 @@ -// 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/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift deleted file mode 100755 index a863946..0000000 --- a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift +++ /dev/null @@ -1,234 +0,0 @@ -// 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.. -#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 deleted file mode 100755 index 03d7878..0000000 --- a/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm +++ /dev/null @@ -1,60 +0,0 @@ -// 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.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm deleted file mode 100755 index 5e52abe..0000000 --- a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm +++ /dev/null @@ -1,174 +0,0 @@ -// 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 deleted file mode 100755 index 5a31c92..0000000 --- a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp +++ /dev/null @@ -1,215 +0,0 @@ -// 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 deleted file mode 100755 index 92814ff..0000000 --- a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp +++ /dev/null @@ -1,81 +0,0 @@ -// 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/cmd/cam.go b/cmd/cam.go index 36689a5..560640b 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -18,40 +18,36 @@ import ( "github.com/spf13/cobra" ) -// --------------------------------------------------------------------------- -// Paths & helpers -// --------------------------------------------------------------------------- +// camIrisDirFlag is an optional override for the Iris directory. +var camIrisDirFlag string -// camMscDir is an optional override for the MiniSimCam directory. -var camMscDir string - -func miniSimCamDir() string { - if camMscDir != "" { - return camMscDir +func getIrisDir() string { + if camIrisDirFlag != "" { + return camIrisDirFlag } ex, _ := os.Executable() - // Walk up to the project root where MiniSimCam/ lives. + // Walk up to the project root where Iris/ lives. dir := filepath.Dir(ex) for i := 0; i < 4; i++ { - candidate := filepath.Join(dir, "MiniSimCam") + candidate := filepath.Join(dir, "Iris") if _, err := os.Stat(candidate); err == nil { return candidate } dir = filepath.Dir(dir) } - // Fall back to CWD/MiniSimCam. + // Fall back to CWD/Iris. cwd, _ := os.Getwd() - return filepath.Join(cwd, "MiniSimCam") + return filepath.Join(cwd, "Iris") } -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 shmPath(udid string) string { return fmt.Sprintf("/tmp/iris.%s.frames", udid) } +func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/iris.%s.pid", udid) } func statusFilePath(udid string) string { - primary := fmt.Sprintf("/tmp/minisimcam.%s.status", udid) + primary := fmt.Sprintf("/tmp/iris.%s.status", udid) if _, err := os.Stat(primary); err == nil { return primary } - secondary := filepath.Join(os.TempDir(), fmt.Sprintf("minisimcam.%s.status", udid)) + secondary := filepath.Join(os.TempDir(), fmt.Sprintf("iris.%s.status", udid)) if _, err := os.Stat(secondary); err == nil { return secondary } @@ -75,24 +71,24 @@ func resolveEmbeddedBinDir() string { return embeddedBinDirValue } -func frameHostBin(mscDir string) string { +func frameHostBin(irisDirVal 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") + return filepath.Join(irisDirVal, ".build", "release", "FrameHost") } -func injectorDylib(mscDir string) string { +func injectorDylib(irisDirVal string) string { if binDir := resolveEmbeddedBinDir(); binDir != "" { - extracted := filepath.Join(binDir, "MiniCamInject.dylib") + extracted := filepath.Join(binDir, "IrisInject.dylib") if _, err := os.Stat(extracted); err == nil { return extracted } } - return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") + return filepath.Join(irisDirVal, ".build", "injector", "IrisInject.dylib") } func findRunningIOSSimulator(deviceID string) (udid, name string, err error) { @@ -101,7 +97,7 @@ func findRunningIOSSimulator(deviceID string) (udid, name string, err error) { return "", "", err } if isAndroid { - return "", "", fmt.Errorf("MiniSimCam only supports booted iOS Simulators, not Android emulators") + return "", "", fmt.Errorf("iris only supports booted iOS Simulators, not Android emulators") } return udid, name, nil } @@ -152,17 +148,13 @@ func frameHostFPS(udid string) int { 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. + Long: `Iris — 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 +shared memory. A dylib (IrisInject) loaded into your simulator app reads those frames and delivers them as CMSampleBuffer callbacks. Quick start: @@ -179,20 +171,13 @@ Quick start: }, } -// --------------------------------------------------------------------------- -// sim cam start -// --------------------------------------------------------------------------- - var ( - camStartImage string - camStartBars bool - camStartCamera bool - camStartCameraID string - camStartScaleMode string - camStartWidth int - camStartHeight int - camStartFPS int - camStartDevice string + camStartImage string + camStartBars bool + camStartCamera bool + camStartCameraID string + camStartFPS int + camStartDevice string ) var camStartCmd = &cobra.Command{ @@ -224,18 +209,12 @@ Examples: 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) @@ -243,8 +222,8 @@ Examples: return fmt.Errorf("no booted iOS simulator found — boot one first (%w)", err) } - mscDir := miniSimCamDir() - bin := frameHostBin(mscDir) + irisDirVal := getIrisDir() + bin := frameHostBin(irisDirVal) 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)") } @@ -257,8 +236,6 @@ Examples: hostArgs := []string{ "--udid", udid, - "--width", strconv.Itoa(camStartWidth), - "--height", strconv.Itoa(camStartHeight), "--fps", strconv.Itoa(camStartFPS), } if camStartBars { @@ -268,9 +245,6 @@ Examples: 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 { @@ -306,17 +280,13 @@ Examples: } } PrintSuccess(fmt.Sprintf( - "FrameHost started — source=%s %dx%d @ %d fps (simulator %s)", - source, camStartWidth, camStartHeight, camStartFPS, udid, + "FrameHost started — source=%s @ %d fps (simulator %s)", + source, camStartFPS, udid, )) return nil }, } -// --------------------------------------------------------------------------- -// sim cam list -// --------------------------------------------------------------------------- - var camListCmd = &cobra.Command{ Use: "list", Short: "List all available cameras on this Mac", @@ -331,8 +301,8 @@ Example: if runtime.GOOS != DarwinOS { return fmt.Errorf("cam list is only supported on macOS") } - mscDir := miniSimCamDir() - bin := frameHostBin(mscDir) + irisDirVal := getIrisDir() + bin := frameHostBin(irisDirVal) 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)") } @@ -343,10 +313,6 @@ Example: }, } -// --------------------------------------------------------------------------- -// sim cam launch -// --------------------------------------------------------------------------- - var ( camLaunchBundle string camLaunchDevice string @@ -354,8 +320,8 @@ var ( 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 + Short: "Launch an app on the simulator with IrisInject loaded", + Long: `Launches your app on the booted iOS Simulator with IrisInject.dylib injected via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES. The FrameHost must be running first (sim cam start). @@ -375,10 +341,10 @@ Example: return fmt.Errorf("no booted iOS simulator found (%w)", err) } - mscDir := miniSimCamDir() - dylib := injectorDylib(mscDir) + irisDirVal := getIrisDir() + dylib := injectorDylib(irisDirVal) 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)") + return fmt.Errorf("IrisInject.dylib not found — run 'sim cam build' first (or use a release build with embedded binaries)") } shm := shmPath(udid) @@ -403,7 +369,7 @@ Example: } else { entXML = `com.apple.security.get-task-allow` } - entPath := filepath.Join(os.TempDir(), "minisimcam_entitlements.plist") + entPath := filepath.Join(os.TempDir(), "iris_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 { @@ -418,8 +384,8 @@ Example: c.Env = append( os.Environ(), "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib, - "SIMCTL_CHILD_MINISIMCAM_PATH="+shm, - "SIMCTL_CHILD_MINISIMCAM_FPS="+strconv.Itoa(fps), + "SIMCTL_CHILD_IRIS_PATH="+shm, + "SIMCTL_CHILD_IRIS_FPS="+strconv.Itoa(fps), ) c.Stdout = os.Stdout c.Stderr = os.Stderr @@ -427,15 +393,11 @@ Example: return fmt.Errorf("simctl launch failed: %w", err) } - PrintSuccess(fmt.Sprintf("Launched %s with MiniCamInject", camLaunchBundle)) + PrintSuccess(fmt.Sprintf("Launched %s with IrisInject", camLaunchBundle)) return nil }, } -// --------------------------------------------------------------------------- -// sim cam status -// --------------------------------------------------------------------------- - var camStatusDevice string // camFrameLoopStatus mirrors FrameLoopStatus defined in FrameLoop.swift and @@ -515,7 +477,7 @@ var camStatusCmd = &cobra.Command{ border := strings.Repeat("─", width+4) fmt.Println("┌" + border + "┐") - fmt.Println("│ MiniSimCam Status" + strings.Repeat(" ", width+4-len(" MiniSimCam Status")) + "│") + fmt.Println("│ Iris Status" + strings.Repeat(" ", width+4-len(" Iris Status")) + "│") fmt.Println("├" + border + "┤") for _, l := range lines { fmt.Printf("│ %-*s │\n", width, l) @@ -525,10 +487,6 @@ var camStatusCmd = &cobra.Command{ }, } -// --------------------------------------------------------------------------- -// sim cam stop -// --------------------------------------------------------------------------- - var camStopDevice string var camStopCmd = &cobra.Command{ @@ -563,13 +521,13 @@ func setGlobalSimEnv(udid string, fps int) { if udid == "" { return } - mscDir := miniSimCamDir() - dylib := injectorDylib(mscDir) + irisDirVal := getIrisDir() + dylib := injectorDylib(irisDirVal) 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() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "IRIS_PATH", shm).Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "IRIS_FPS", strconv.Itoa(fps)).Run() } func unsetGlobalSimEnv(udid string) { @@ -577,8 +535,8 @@ func unsetGlobalSimEnv(udid string) { 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() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "IRIS_PATH").Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "IRIS_FPS").Run() } // stopFrameHost scans all running processes and terminates ALL FrameHost processes @@ -593,20 +551,20 @@ func stopFrameHost(udid string) error { 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)) + _ = os.Remove(fmt.Sprintf("/tmp/iris.%s.pid", udid)) + _ = os.Remove(fmt.Sprintf("/tmp/iris.%s.status", udid)) } else { - if files, err := filepath.Glob("/tmp/minisimcam.*.pid"); err == nil { + if files, err := filepath.Glob("/tmp/iris.*.pid"); err == nil { for _, f := range files { _ = os.Remove(f) } } - if files, err := filepath.Glob("/tmp/minisimcam.*.status"); err == nil { + if files, err := filepath.Glob("/tmp/iris.*.status"); err == nil { for _, f := range files { _ = os.Remove(f) } } - if files, err := filepath.Glob(filepath.Join(os.TempDir(), "minisimcam.*.status")); err == nil { + if files, err := filepath.Glob(filepath.Join(os.TempDir(), "iris.*.status")); err == nil { for _, f := range files { _ = os.Remove(f) } @@ -637,23 +595,16 @@ func stopFrameHost(udid string) error { 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") + // cam-level flag: --iris-dir override (hidden for end users) + camCmd.PersistentFlags().StringVar(&camIrisDirFlag, "iris-dir", "", "Path to Iris directory (default: auto-detected)") + _ = camCmd.PersistentFlags().MarkHidden("iris-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) diff --git a/cmd/cam_dashboard.go b/cmd/cam_dashboard.go index 58202db..b5ee46b 100644 --- a/cmd/cam_dashboard.go +++ b/cmd/cam_dashboard.go @@ -13,7 +13,6 @@ import ( "syscall" "time" - "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" @@ -53,23 +52,11 @@ type camSimDevice struct { 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 @@ -79,9 +66,6 @@ type camDashboardModel struct { selectedSimUDID string selectedSimName string showSystemApps bool - showPopup bool - camWidth int - camHeight int msg string loading bool width int @@ -209,8 +193,8 @@ func fetchAppsCmd(udid string) tea.Cmd { } func getAvailableCameras() ([]CameraInfo, error) { - mscDir := miniSimCamDir() - bin := frameHostBin(mscDir) + irisDirVal := getIrisDir() + bin := frameHostBin(irisDirVal) if _, err := os.Stat(bin); err != nil { return nil, fmt.Errorf("FrameHost binary not found") } @@ -309,9 +293,6 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, 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 { @@ -345,28 +326,6 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() @@ -389,9 +348,6 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -425,7 +381,7 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) + return startCameraForDevice(m.selectedSimUDID, true, "", camID) }, "Started camera feed for "+m.selectedSimName)) } case paneApps: @@ -493,8 +449,6 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { {Title: "Type", Width: aw3}, }) - m.presetList.SetSize(60, 20) - case camRefreshMsg: rows := make([]table.Row, 0, len(msg)) for _, d := range msg { @@ -557,7 +511,8 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { oldSimUDID := m.selectedSimUDID - if m.focused == paneSimulators && !m.showPopup { + switch m.focused { + case paneSimulators: var tableCmd tea.Cmd m.simTable, tableCmd = m.simTable.Update(msg) cmds = append(cmds, tableCmd) @@ -567,11 +522,11 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedSimUDID = m.simulators[idx].UDID m.selectedSimName = m.simulators[idx].Name } - } else if m.focused == paneCameras && !m.showPopup { + case paneCameras: var camTableCmd tea.Cmd m.cameraTable, camTableCmd = m.cameraTable.Update(msg) cmds = append(cmds, camTableCmd) - } else if m.focused == paneApps && !m.showPopup { + case paneApps: var appTableCmd tea.Cmd m.appTable, appTableCmd = m.appTable.Update(msg) cmds = append(cmds, appTableCmd) @@ -623,7 +578,7 @@ func (m camDashboardModel) View() string { 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 " + footer := " Controls: [tab/h/l] Switch Pane • [j/k] Navigate • [enter] Action • [x] Stop Cam • [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 != "" { @@ -632,26 +587,12 @@ func (m camDashboardModel) View() string { 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) +func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraID string) error { + irisDirVal := getIrisDir() + bin := frameHostBin(irisDirVal) if _, err := os.Stat(bin); err != nil { return fmt.Errorf("FrameHost binary not found") } @@ -661,8 +602,6 @@ func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraI args := []string{ "--udid", udid, - "--width", fmt.Sprintf("%d", width), - "--height", fmt.Sprintf("%d", height), "--fps", fmt.Sprintf("%d", DefaultCamFPS), } @@ -691,10 +630,10 @@ func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraI } func launchAppWithCam(udid, bundleID string) error { - mscDir := miniSimCamDir() - dylib := injectorDylib(mscDir) + irisDirVal := getIrisDir() + dylib := injectorDylib(irisDirVal) if _, err := os.Stat(dylib); err != nil { - return fmt.Errorf("MiniCamInject.dylib not found") + return fmt.Errorf("IrisInject.dylib not found") } shm := shmPath(udid) @@ -703,8 +642,8 @@ func launchAppWithCam(udid, bundleID string) error { 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), + "SIMCTL_CHILD_IRIS_PATH="+shm, + "SIMCTL_CHILD_IRIS_FPS="+fmt.Sprintf("%d", fps), ) return c.Run() } @@ -778,30 +717,15 @@ func runCamDashboard() error { 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 { diff --git a/cmd/cam_test.go b/cmd/cam_test.go index 675c035..cba91d2 100644 --- a/cmd/cam_test.go +++ b/cmd/cam_test.go @@ -20,9 +20,9 @@ func TestCamShmPaths(t *testing.T) { fn func(string) string want string }{ - {shmPath, "/tmp/minisimcam.ABC-123.frames"}, - {statusFilePath, "/tmp/minisimcam.ABC-123.status"}, - {pidFilePath, "/tmp/minisimcam.ABC-123.pid"}, + {shmPath, "/tmp/iris.ABC-123.frames"}, + {statusFilePath, "/tmp/iris.ABC-123.status"}, + {pidFilePath, "/tmp/iris.ABC-123.pid"}, } for _, tt := range tests { @@ -71,7 +71,7 @@ func TestCamStatusParsing(t *testing.T) { 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)) + path := filepath.Join(os.TempDir(), fmt.Sprintf("iris.%s.status", udid)) t.Cleanup(func() { _ = os.Remove(path) }) status := camFrameLoopStatus{FPS: 60} @@ -96,10 +96,10 @@ func TestStopFrameHostNoFile(t *testing.T) { } } -// 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)) +// TestIrisDirFallback verifies that getIrisDir() returns a path ending in "Iris". +func TestIrisDirFallback(t *testing.T) { + dir := getIrisDir() + if filepath.Base(dir) != "Iris" { + t.Errorf("expected base to be Iris, got %q", filepath.Base(dir)) } } diff --git a/cmd/constants.go b/cmd/constants.go index 9ee04c9..eeb1a06 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -28,7 +28,7 @@ const ( PrefixScreenshot = "screenshot" PrefixRecording = "recording" - // Camera injector (MiniSimCam) defaults. + // Camera injector (Iris) defaults. DefaultCamWidth = 1280 DefaultCamHeight = 720 DefaultCamFPS = 30 diff --git a/cmd/device.go b/cmd/device.go index 6290505..42755af 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -9,8 +9,6 @@ import ( "github.com/spf13/cobra" ) -// --- Cobra Commands --- - var startCmd = &cobra.Command{ Use: "start [device-name-or-udid|lts]", Aliases: []string{"s"}, @@ -229,8 +227,6 @@ var ltsCmd = &cobra.Command{ }, } -// --- Shared Start Logic --- - // startDevice handles the 'start' action for both startCmd and ltsCmd. // NoWait skips the Android boot-wait polling when true. func startDevice(deviceID string, noWait bool) error { @@ -256,10 +252,6 @@ func startDevice(deviceID string, noWait bool) error { return fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotFound) } -// --- Old functions removed --- - -// --- Device Lookup Helpers --- - // FindIOSSimulatorByID finds an iOS simulator by name (case-insensitive) or exact UDID. // Merges the former findIOSSimulator and findIOSSimulatorByID into a single efficient function. func FindIOSSimulatorByID(deviceID string) *Device { diff --git a/cmd/embed_cam.go b/cmd/embed_cam.go index 6cae0dc..a31d737 100644 --- a/cmd/embed_cam.go +++ b/cmd/embed_cam.go @@ -10,7 +10,7 @@ import ( "path/filepath" ) -//go:embed assets/FrameHost assets/MiniCamInject.dylib +//go:embed assets/FrameHost assets/IrisInject.dylib var embeddedAssets embed.FS // ensureExtractedAssets extracts embedded assets to ~/.sim-cli/bin/ if valid. @@ -32,7 +32,7 @@ func ensureExtractedAssets() (string, error) { mode os.FileMode }{ {"assets/FrameHost", "FrameHost", 0755}, - {"assets/MiniCamInject.dylib", "MiniCamInject.dylib", 0644}, + {"assets/IrisInject.dylib", "IrisInject.dylib", 0644}, } for _, f := range files { diff --git a/cmd/embed_stub.go b/cmd/embed_stub.go index 99fb470..8a96f34 100644 --- a/cmd/embed_stub.go +++ b/cmd/embed_stub.go @@ -3,7 +3,7 @@ package cmd // ensureExtractedAssets is a no-op when built without cam_embed tag. -// The CLI falls back to local MiniSimCam/.build/ paths. +// The CLI falls back to local Iris/.build/ paths. func ensureExtractedAssets() (string, error) { return "", nil } diff --git a/cmd/media.go b/cmd/media.go index dff6427..14345b5 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -23,8 +23,6 @@ type capturer interface { GetName() string } -// --- iOS Simulator --- - type iOSSimulator struct { udid string name string @@ -86,8 +84,6 @@ func (s *iOSSimulator) GetName() string { return s.name } -// --- Android Emulator --- - type androidEmulator struct { udid string name string @@ -180,8 +176,6 @@ func (e *androidEmulator) GetName() string { return e.name } -// --- Device Resolution --- - func getCapturer(deviceID string) (capturer, error) { if deviceID == "" { return getActiveDevice() @@ -300,8 +294,6 @@ func handleRecording(c capturer, outputFile string, duration, fps, scale int, co return nil } -// --- Cobra Commands --- - var screenshotCmd = &cobra.Command{ Use: "screenshot [device-name-or-udid] [output-file]", Aliases: []string{"ss", "shot"}, diff --git a/cmd/utils.go b/cmd/utils.go index 6df0a21..62f139a 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -11,8 +11,6 @@ import ( "github.com/atotto/clipboard" ) -// --- File and Path Helpers --- - // GenerateFilename creates a timestamped filename with the given prefix, device ID, and extension. func GenerateFilename(prefix, deviceID, extension string) string { timestamp := time.Now().Format("20060102_150405") @@ -27,8 +25,6 @@ func EnsureExtension(filename, ext string) string { return strings.TrimSuffix(filename, filepath.Ext(filename)) + ext } -// --- Clipboard Operations --- - func copyToClipboard(text string) error { return clipboard.WriteAll(text) } @@ -101,8 +97,6 @@ func copyFileToClipboardLinux(filePath, ext string) error { return nil } -// --- Command Execution --- - // CommandExists reports whether the named executable exists in the system PATH. func CommandExists(cmd string) bool { _, err := exec.LookPath(cmd) @@ -110,8 +104,6 @@ func CommandExists(cmd string) bool { return err == nil } -// --- Recording Duration Validation --- - // ValidateRecordingDuration returns an error if duration is negative. func ValidateRecordingDuration(duration int) error { if duration < 0 { @@ -121,8 +113,6 @@ func ValidateRecordingDuration(duration int) error { return nil } -// --- Video Conversion --- - func convertToGIF(inputFile, outputFile string, fps, scale int) error { if !CommandExists(CmdFFmpeg) { return ErrFFmpegNotInstalled diff --git a/docs/SIM_CAM_ARCHITECTURE.md b/docs/SIM_CAM_ARCHITECTURE.md index 6904b61..4100fb6 100644 --- a/docs/SIM_CAM_ARCHITECTURE.md +++ b/docs/SIM_CAM_ARCHITECTURE.md @@ -6,7 +6,7 @@ This document explains the technical architecture of the `sim cam` feature. It d 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. +`sim-cli` solves this using a distributed, two-process architecture communicating over a lock-free shared memory header and hardware-accelerated `IOSurface` graphics memory. ```mermaid flowchart TD @@ -19,14 +19,15 @@ flowchart TD SharedFrameWriter["SharedFrameWriter"] end - SHM[/"/tmp/minisimcam..frames (Shared Memory)"/] + SHM[/"/tmp/iris..frames (Shared Memory Header)"/] + GPU[/"GPU Memory (IOSurface)"/] end subgraph Sim["iOS Simulator"] subgraph App["Target iOS App"] AVFoundation["AVFoundation"] - subgraph Injector["MiniCamInject.dylib"] + subgraph Injector["IrisInject.dylib"] Swizzler["Swizzler"] SharedFrameReader["SharedFrameReader"] SampleBufferFactory["SampleBufferFactory"] @@ -38,9 +39,11 @@ flowchart TD CLI -. "Injects via launchctl" .-> App WebCam -- "Frames" --> CameraSource CameraSource -- "BGRA" --> SharedFrameWriter - SharedFrameWriter -- "Seq-locked write" --> SHM - SHM -- "Polling read" --> SharedFrameReader - SharedFrameReader -- "BGRA" --> SampleBufferFactory + SharedFrameWriter -- "Writes Frame" --> GPU + SharedFrameWriter -- "Seq-locked write ID" --> SHM + SHM -- "Polling read ID" --> SharedFrameReader + GPU -- "Zero-Copy Bind" --> SharedFrameReader + SharedFrameReader -- "IOSurface" --> SampleBufferFactory SampleBufferFactory -- "CMSampleBuffer" --> Swizzler Swizzler -- "Spoofs feed" --> AVFoundation ``` @@ -52,12 +55,12 @@ flowchart TD 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`)**. +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`)** combined with **IOSurface**. -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. +`IOSurface` is Apple’s native framework for sharing hardware-accelerated graphics memory across process boundaries. `FrameHost` writes frames to a global `IOSurface` pool, and only shares a 32-bit `ioSurfaceID` via a memory-mapped file. This enables **true zero-copy delivery** with virtually 0% CPU overhead for 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. +The shared memory file consists *only* of a 128-byte header (`IRISStreamHeader`). It does not contain the actual pixel data. ```c typedef struct { @@ -67,19 +70,19 @@ typedef struct { 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 + uint32_t _pad1; // Explicit padding + uint32_t _pad2; // Explicit padding uint64_t sequence; // ATOMIC: Sequence lock for writers - uint32_t publishedIndex; // ATOMIC: Current readable buffer index - uint32_t _pad0; // Alignment padding + uint32_t ioSurfaceID; // ATOMIC: ID of the active IOSurface + uint32_t _pad0; // Explicit 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 +} IRISStreamHeader; // 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). +Because the producer (`FrameHost`) and consumer (`IrisInject`) run in separate processes, they need a way to coordinate. If `FrameHost` updates the `ioSurfaceID` and timing metadata while the app is reading it, the video frame tears or presents with the wrong timestamp. **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. @@ -90,14 +93,14 @@ Instead, `sim-cli` uses a **Sequence Lock** (SeqLock) implementation with C++20 sequenceDiagram participant P as FrameHost
(Producer) participant SHM as Shared Memory - participant C as MiniCamInject
(Consumer) + participant C as IrisInject
(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: write new ioSurfaceID + P->>SHM: write presentationTimeNs P->>SHM: sequence = sequence + 1 (Even: Unlocked) %% Consumer Read @@ -105,10 +108,9 @@ sequenceDiagram loop until valid frame C->>SHM: seqA = load sequence alt seqA is Odd - C->>C: sched_yield() (retry) + C->>C: __builtin_arm_yield() (retry) else seqA is Even - C->>SHM: idx = load publishedIndex - C->>SHM: copy pixels from buffer[idx] + C->>SHM: read ioSurfaceID and PTS C->>SHM: seqB = load sequence alt seqA == seqB C->>C: Valid frame! Break loop. @@ -119,8 +121,8 @@ sequenceDiagram 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. +1. **Producer writes:** `FrameHost` writes a frame to a new `IOSurface` in its pool. It atomically increments the `sequence` integer to an **odd** number, signaling a write is in progress. It updates `ioSurfaceID` and timestamp metadata, and increments `sequence` to an **even** number. +2. **Consumer reads:** `IrisInject` polls the `sequence` integer. If it is even, it begins reading the IDs and metadata. After copying the 128-byte header, it checks `sequence` again. If `sequence` changed during the read, a torn read occurred. The consumer discards the read and retries instantly. It then invokes `IOSurfaceLookup` using the valid ID. --- @@ -138,44 +140,44 @@ sequenceDiagram participant LaunchCtl as launchctl participant Host as FrameHost participant App as iOS App - participant Inject as MiniCamInject + participant Inject as IrisInject participant AV as AVCaptureSession User->>CLI: sim cam start CLI->>Host: spawn(udid) activate Host - Host->>Host: allocate /tmp/minisimcam..frames + Host->>Host: allocate /tmp/iris..frames Host->>Host: start AVFoundation capture CLI->>LaunchCtl: setenv DYLD_INSERT_LIBRARIES - CLI->>LaunchCtl: setenv MINISIMCAM_PATH + CLI->>LaunchCtl: setenv IRIS_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) + Inject->>AV: method_exchangeImplementations(startRunning, iris_startRunning) deactivate Inject App->>AV: [session startRunning] - AV->>Inject: msc_startRunning() (intercepted) + AV->>Inject: iris_startRunning() (intercepted) activate Inject Inject->>Inject: setup GCD background queue - Inject->>Inject: mmap(/tmp/minisimcam..frames) + Inject->>Inject: mmap(/tmp/iris..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. +- `DYLD_INSERT_LIBRARIES=/path/to/IrisInject.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. +- `IRIS_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. +Inside `IrisInject.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. @@ -183,27 +185,27 @@ When the iOS app calls `[session startRunning]`, execution jumps to our code. Th ## 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. +When the iOS app's GCD queue successfully reads a valid `ioSurfaceID` from shared memory, it looks up the surface and packages it for `AVFoundation`. ```mermaid flowchart LR - SHM[("Shared Memory
(Raw BGRA Bytes)")] + SHM[("Shared Memory
(128B Header)")] 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 + SHM -- "Reads IOSurfaceID" --> Factory + Factory -- "IOSurfaceLookup" --> 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`. +1. `SampleBufferFactory` reads the `ioSurfaceID` and looks up the `IOSurfaceRef` via the kernel. +2. It wraps the `IOSurface` 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. @@ -260,40 +262,29 @@ When the active camera is changed: ## 5. Future Architectural Improvements -The current architecture is highly functional, but several advanced improvements could be implemented to elevate performance and provide a completely seamless "magic" experience. +The current architecture is highly functional, but advanced improvements could elevate performance and provide a completely seamless "magic" experience. ```mermaid flowchart TD subgraph Host["macOS Host"] subgraph FrameHost["FrameHost (Next-Gen)"] CameraSource["CameraSource"] - IOSurfaceWriter["IOSurface Writer"] ControlReader["Control Message Reader"] end SHM[/"Shared Memory (Header & Control)"/] - GPU[/"GPU Memory (IOSurface)"/] end subgraph Sim["iOS Simulator"] subgraph App["Target iOS App"] - subgraph Injector["MiniCamInject.dylib"] + subgraph Injector["IrisInject.dylib"] VNODE["VNODE File Monitor"] - IOSurfaceReader["IOSurface Lookup"] ControlWriter["Control Message Writer"] end AVF["AVFoundation"] end end - CameraSource -- "Frames" --> IOSurfaceWriter - IOSurfaceWriter -- "Hardware Write" --> GPU - IOSurfaceWriter -- "Passes IOSurfaceID" --> SHM - - SHM -- "Reads ID" --> IOSurfaceReader - GPU -- "Zero-Copy Read" --> IOSurfaceReader - IOSurfaceReader -- "CMSampleBuffer" --> AVF - AVF -- "UI Events (Focus, Flip)" --> ControlWriter ControlWriter -- "Control Messages" --> SHM SHM -- "Reads Messages" --> ControlReader @@ -302,11 +293,8 @@ flowchart TD VNODE -. "Listens for deletion" .-> SHM ``` -### 1. Zero-Copy `IOSurface` Delivery (Hardware Acceleration) -Instead of using a POSIX shared memory file for raw BGRA bytes (which requires `memcpy` operations on the CPU), the architecture could transition to `IOSurface`. `IOSurface` is Apple’s native framework for sharing hardware-accelerated graphics memory across process boundaries. `FrameHost` would write frames directly to the GPU, place the integer `IOSurfaceID` in the shared memory header, and the iOS app would use `IOSurfaceLookup()` to grab it. This provides **true zero-copy delivery** with virtually 0% CPU overhead. - -### 2. Seamless Hot-Swapping (The VNODE File Monitor) +### 1. Seamless Hot-Swapping (The VNODE File Monitor) To fix the limitation where resolution changes freeze the camera feed, the iOS app's `SharedFrameReader` could implement a Grand Central Dispatch (GCD) `DISPATCH_SOURCE_TYPE_VNODE` monitor. This allows the iOS app to listen for `NOTE_DELETE` or `NOTE_RENAME` events on the shared memory file. If `sim-cli` deletes the file to change resolutions, the app detects it instantly, closes the old memory map, and `mmap`s the new file on the fly—making camera swapping perfectly seamless. -### 3. Bidirectional Control Channel -Currently, data flows one way (macOS → iOS). By adding a lock-free **Control Ring-Buffer** in the shared memory header, the iOS app could send camera control events *back* to macOS. If the user taps the "Flip Camera" button or taps to focus inside the iOS Simulator, `MiniCamInject` could write those events to the control buffer. `FrameHost` would read them and automatically switch from the Mac's FaceTime camera to a plugged-in DSLR or adjust the hardware focus, making the injection truly interactive. +### 2. Bidirectional Control Channel +Currently, data flows one way (macOS → iOS). By adding a lock-free **Control Ring-Buffer** in the shared memory header, the iOS app could send camera control events *back* to macOS. If the user taps the "Flip Camera" button or taps to focus inside the iOS Simulator, `IrisInject` could write those events to the control buffer. `FrameHost` would read them and automatically switch from the Mac's FaceTime camera to a plugged-in DSLR or adjust the hardware focus, making the injection truly interactive.