From 3d50dfef182c6d4ef2229977fe442f2610913430 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Tue, 21 Jul 2026 15:40:26 +0700 Subject: [PATCH 01/10] feat: implement MiniSimCam with memory and concurrency fixes - Fix seqlock pattern to canonical seq+1/seq+2 - Fix CMSampleBuffer use-after-free - Add zero-copy frame delivery path - Daemonize FrameHost to survive terminal close - Remove duplicate SharedFrameReader leaks - Fix unchecked entitlement errors - Update offset comments and gitignore --- .gitignore | 13 + Makefile | 11 + .../contents.xcworkspacedata | 7 + .../xcschemes/xcschememanagement.plist | 29 + .../CameraPreviewApp/CameraPreviewApp.swift | 146 +++++ .../Example/CameraPreviewApp/build_app.sh | 40 ++ .../CameraPreviewApp/entitlements.plist | 8 + MiniSimCam/Package.resolved | 14 + MiniSimCam/Package.swift | 90 +++ MiniSimCam/Scripts/build.sh | 121 ++++ MiniSimCam/Scripts/launch.sh | 30 + MiniSimCam/Shared/AtomicHelpers.c | 43 ++ MiniSimCam/Shared/include/AtomicHelpers.h | 45 ++ MiniSimCam/Shared/include/MiniCamConstants.h | 44 ++ MiniSimCam/Shared/include/MiniCamProtocol.h | 96 +++ MiniSimCam/Shared/include/module.modulemap | 6 + .../FrameHost/FrameHost-Bridging-Header.h | 6 + MiniSimCam/Sources/FrameHost/FrameLoop.swift | 138 ++++ .../Sources/FrameHost/ImageSource.swift | 129 ++++ .../Sources/FrameHost/SharedFrameWriter.swift | 157 +++++ MiniSimCam/Sources/FrameHost/main.swift | 119 ++++ .../Sources/MiniCamInject/CaptureHooks.h | 15 + .../Sources/MiniCamInject/CaptureHooks.mm | 595 ++++++++++++++++++ .../Sources/MiniCamInject/EntryPoint.mm | 60 ++ .../MiniCamInject/SampleBufferFactory.h | 34 + .../MiniCamInject/SampleBufferFactory.mm | 175 ++++++ .../MiniCamInject/SharedFrameReader.cpp | 208 ++++++ .../MiniCamInject/SharedFrameReader.hpp | 80 +++ .../FrameTransportTests.swift | 119 ++++ .../Tests/ProtocolTests/ProtocolTests.swift | 87 +++ cmd/cam.go | 425 +++++++++++++ cmd/cam_test.go | 82 +++ cmd/constants.go | 5 + cmd/root.go | 1 + 34 files changed, 3178 insertions(+) create mode 100644 MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift create mode 100755 MiniSimCam/Example/CameraPreviewApp/build_app.sh create mode 100644 MiniSimCam/Example/CameraPreviewApp/entitlements.plist create mode 100644 MiniSimCam/Package.resolved create mode 100644 MiniSimCam/Package.swift create mode 100755 MiniSimCam/Scripts/build.sh create mode 100755 MiniSimCam/Scripts/launch.sh create mode 100644 MiniSimCam/Shared/AtomicHelpers.c create mode 100644 MiniSimCam/Shared/include/AtomicHelpers.h create mode 100644 MiniSimCam/Shared/include/MiniCamConstants.h create mode 100644 MiniSimCam/Shared/include/MiniCamProtocol.h create mode 100644 MiniSimCam/Shared/include/module.modulemap create mode 100644 MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h create mode 100644 MiniSimCam/Sources/FrameHost/FrameLoop.swift create mode 100644 MiniSimCam/Sources/FrameHost/ImageSource.swift create mode 100644 MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift create mode 100644 MiniSimCam/Sources/FrameHost/main.swift create mode 100644 MiniSimCam/Sources/MiniCamInject/CaptureHooks.h create mode 100644 MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm create mode 100644 MiniSimCam/Sources/MiniCamInject/EntryPoint.mm create mode 100644 MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h create mode 100644 MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm create mode 100644 MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp create mode 100644 MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp create mode 100644 MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift create mode 100644 MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift create mode 100644 cmd/cam.go create mode 100644 cmd/cam_test.go diff --git a/.gitignore b/.gitignore index 55ab1e0..16c5856 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,16 @@ recording_*.mp4 # Temporary files /tmp/coverage.out coverage.html + +# MiniSimCam +MiniSimCam/.build/ +MiniSimCam/Example/CameraPreviewApp/build/ +MiniSimCam/Example/CameraPreviewApp/module_cache/ +MiniSimCam/module_cache/ +module_cache/ + +# Xcode user state files (never commit) +**/*.xcuserstate + +# User Scratch +scratch/ diff --git a/Makefile b/Makefile index d0a3314..34c754b 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,15 @@ clean: rm -rf dist/ rm -f coverage.out coverage.html +# Build MiniSimCam (FrameHost + MiniCamInject dylib) +cam-build: + cd MiniSimCam && ./Scripts/build.sh + +# Clean MiniSimCam build artifacts +cam-clean: + cd MiniSimCam && swift package clean + rm -rf MiniSimCam/.build/injector + # Install dependencies deps: go mod download @@ -79,4 +88,6 @@ help: @echo " vet - Run go vet" @echo " check - Run all checks (fmt, vet, lint, test-race)" @echo " install - Install to /usr/local/bin" + @echo " cam-build - Build MiniSimCam (FrameHost + injector dylib)" + @echo " cam-clean - Clean MiniSimCam build artifacts" @echo " help - Show this help" \ No newline at end of file diff --git a/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..bff7fdb --- /dev/null +++ b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,29 @@ + + + + + SchemeUserState + + FrameHost.xcscheme_^#shared#^_ + + orderHint + 3 + + MiniCamInject.xcscheme_^#shared#^_ + + orderHint + 1 + + MiniSimCam-Package.xcscheme_^#shared#^_ + + orderHint + 0 + + MiniSimCamShared.xcscheme_^#shared#^_ + + orderHint + 2 + + + + diff --git a/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift b/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift new file mode 100644 index 0000000..5cf958a --- /dev/null +++ b/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift @@ -0,0 +1,146 @@ +// CameraPreviewApp.swift +// Minimal SwiftUI example app demonstrating both Stage A (cooperative) and +// Stage B (transparent injection) modes of MiniSimCam. +// +// Stage A: Compile with MINISIMCAM_STAGE_A=1 — uses SharedMemoryCameraSource +// Stage B: Use normal AVFoundation; the injector dylib intercepts it. + +import SwiftUI +import AVFoundation +import CoreMedia + +@main +struct CameraPreviewApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - Content View + +struct ContentView: View { + @StateObject private var camera = CameraController() + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + CameraPreviewView(session: camera.session) + .ignoresSafeArea() + + VStack { + Spacer() + HStack { + Image(systemName: "camera.fill") + .foregroundStyle(.white) + Text("MiniSimCam Preview") + .foregroundStyle(.white) + .font(.footnote.bold()) + Spacer() + Text("Frame \(camera.frameCount)") + .foregroundStyle(.white.opacity(0.6)) + .font(.footnote.monospaced()) + } + .padding() + .background(.ultraThinMaterial) + } + } + .onAppear { camera.start() } + .onDisappear { camera.stop() } + } +} + +// MARK: - Camera Preview Layer + +class PreviewView: UIView { + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + guard let layer = layer as? AVCaptureVideoPreviewLayer else { + fatalError("Expected `AVCaptureVideoPreviewLayer` type for layer. Check PreviewView.layerClass implementation.") + } + return layer + } + + var session: AVCaptureSession? { + get { videoPreviewLayer.session } + set { videoPreviewLayer.session = newValue } + } + + override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } +} + +struct CameraPreviewView: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.backgroundColor = .clear + view.videoPreviewLayer.videoGravity = .resizeAspectFill + view.session = session + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + uiView.session = session + } +} + +// MARK: - Camera Controller (Stage B: transparent AVFoundation) + +@MainActor +final class CameraController: NSObject, ObservableObject { + @Published var frameCount: Int = 0 + let session = AVCaptureSession() + + private let output = AVCaptureVideoDataOutput() + private let queue = DispatchQueue(label: "com.example.camera.frames") + + func start() { + session.sessionPreset = .hd1280x720 + + if let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) { + session.addInput(input) + } + + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ] + output.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(output) { + session.addOutput(output) + } + + session.startRunning() + } + + func stop() { + session.stopRunning() + } +} + +extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + // We only use this delegate to increment the frame counter. + // The actual rendering is handled by AVCaptureVideoPreviewLayer! + Task { @MainActor in + self.frameCount += 1 + } + } + + nonisolated func captureOutput( + _ output: AVCaptureOutput, + didDrop sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + // Dropped frames are acceptable in the MVP. + } +} diff --git a/MiniSimCam/Example/CameraPreviewApp/build_app.sh b/MiniSimCam/Example/CameraPreviewApp/build_app.sh new file mode 100755 index 0000000..08eb859 --- /dev/null +++ b/MiniSimCam/Example/CameraPreviewApp/build_app.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" + +APP_NAME="CameraPreviewApp" +BUNDLE_ID="com.example.camerapreview" +TARGET_DIR="build/${APP_NAME}.app" + +mkdir -p "$TARGET_DIR" + +echo " + + + + CFBundleExecutable + ${APP_NAME} + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleName + ${APP_NAME} + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + UILaunchStoryboardName + LaunchScreen + NSCameraUsageDescription + Camera test + +" > "$TARGET_DIR/Info.plist" + +xcrun swiftc \ + -target arm64-apple-ios17.0-simulator \ + -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) \ + -parse-as-library \ + CameraPreviewApp.swift \ + -o "$TARGET_DIR/$APP_NAME" + +xcrun simctl install booted "$TARGET_DIR" +echo "Installed $BUNDLE_ID" diff --git a/MiniSimCam/Example/CameraPreviewApp/entitlements.plist b/MiniSimCam/Example/CameraPreviewApp/entitlements.plist new file mode 100644 index 0000000..9acd128 --- /dev/null +++ b/MiniSimCam/Example/CameraPreviewApp/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.get-task-allow + + + diff --git a/MiniSimCam/Package.resolved b/MiniSimCam/Package.resolved new file mode 100644 index 0000000..c7e2987 --- /dev/null +++ b/MiniSimCam/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + } + ], + "version" : 2 +} diff --git a/MiniSimCam/Package.swift b/MiniSimCam/Package.swift new file mode 100644 index 0000000..40fbb4f --- /dev/null +++ b/MiniSimCam/Package.swift @@ -0,0 +1,90 @@ +// swift-tools-version: 5.9 +// Package.swift — MiniSimCam monorepo SPM manifest. + +import PackageDescription + +let package = Package( + name: "MiniSimCam", + platforms: [ + .macOS(.v13), + .iOS(.v16), + ], + products: [ + // FrameHost: macOS command-line executable (frame producer). + .executable(name: "FrameHost", targets: ["FrameHost"]), + + // MiniSimCamShared: C/Swift shared-memory protocol (importable in tests). + .library(name: "MiniSimCamShared", targets: ["MiniSimCamShared"]), + + // MiniCamInject: iOS Simulator dylib (built via xcodebuild, not SPM). + // Listed as a library target so Xcode can reference the sources. + .library(name: "MiniCamInject", type: .dynamic, targets: ["MiniCamInject"]), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.4.0" + ), + ], + targets: [ + + // ------------------------------------------------------------------ + // Shared C headers (system module map style). + // ------------------------------------------------------------------ + .target( + name: "MiniSimCamShared", + path: "Shared", + sources: ["AtomicHelpers.c"], + publicHeadersPath: "include" + ), + + // ------------------------------------------------------------------ + // FrameHost — macOS executable. + // ------------------------------------------------------------------ + .executableTarget( + name: "FrameHost", + dependencies: [ + "MiniSimCamShared", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Sources/FrameHost" + ), + + // ------------------------------------------------------------------ + // MiniCamInject — iOS Simulator dynamic library. + // SPM can resolve types; actual build must use xcodebuild for the + // simulator-sdk target. + // ------------------------------------------------------------------ + .target( + name: "MiniCamInject", + dependencies: ["MiniSimCamShared"], + path: "Sources/MiniCamInject", + publicHeadersPath: ".", + cxxSettings: [ + .headerSearchPath("../../Shared/include"), + .unsafeFlags(["-std=c++17"]), + ], + linkerSettings: [ + .linkedFramework("AVFoundation"), + .linkedFramework("CoreMedia"), + .linkedFramework("CoreVideo"), + ] + ), + + // ------------------------------------------------------------------ + // Tests + // ------------------------------------------------------------------ + .testTarget( + name: "ProtocolTests", + dependencies: ["MiniSimCamShared"], + path: "Tests/ProtocolTests" + ), + + .testTarget( + name: "FrameTransportTests", + dependencies: ["FrameHost", "MiniSimCamShared"], + path: "Tests/FrameTransportTests" + ), + ], + cxxLanguageStandard: .cxx17 +) diff --git a/MiniSimCam/Scripts/build.sh b/MiniSimCam/Scripts/build.sh new file mode 100755 index 0000000..34110f9 --- /dev/null +++ b/MiniSimCam/Scripts/build.sh @@ -0,0 +1,121 @@ +#!/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/.."}" + +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" + +echo " OK MiniCamInject.dylib -> ${BUILD_DIR}/MiniCamInject.dylib" + +echo "" +echo "==================================================" +echo " Build complete." +echo "" +echo " FrameHost: ${FRAME_HOST_BIN}" +echo " MiniCamInject: ${BUILD_DIR}/MiniCamInject.dylib" +echo "" +echo " Next steps:" +echo " sim cam start --image " +echo " sim cam launch --bundle-id com.example.CameraPreviewApp" +echo "==================================================" diff --git a/MiniSimCam/Scripts/launch.sh b/MiniSimCam/Scripts/launch.sh new file mode 100755 index 0000000..10d741c --- /dev/null +++ b/MiniSimCam/Scripts/launch.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# launch.sh — Launches an app on the booted simulator with MiniCamInject loaded. +# Usage: ./launch.sh [udid] +set -euo pipefail + +BUNDLE_ID="${1:-}" +UDID="${2:-booted}" + +if [[ -z "$BUNDLE_ID" ]]; then + echo "Usage: launch.sh [udid]" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." +DYLIB="$PROJECT_DIR/.build/injector/MiniCamInject.dylib" +SHM_PATH="/tmp/minisimcam.${UDID}.frames" + +if [[ ! -f "$DYLIB" ]]; then + echo "⚠️ MiniCamInject.dylib not found. Run 'sim cam build' first." >&2 + exit 1 +fi + +echo "Launching $BUNDLE_ID on simulator $UDID" +echo " dylib: $DYLIB" +echo " shm: $SHM_PATH" + +SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$DYLIB" \ +SIMCTL_CHILD_MINISIMCAM_PATH="$SHM_PATH" \ +xcrun simctl launch "$UDID" "$BUNDLE_ID" diff --git a/MiniSimCam/Shared/AtomicHelpers.c b/MiniSimCam/Shared/AtomicHelpers.c new file mode 100644 index 0000000..09b5791 --- /dev/null +++ b/MiniSimCam/Shared/AtomicHelpers.c @@ -0,0 +1,43 @@ +// AtomicHelpers.c +// C implementations of the atomic accessor wrappers. +// Uses GCC/Clang __atomic_* builtins which work on any integer at a pointer. +// Swift calls these via the module map; C++ uses std::atomic* casts directly. + +#include "AtomicHelpers.h" +#include "MiniCamProtocol.h" +#include + +// Byte-pointer helpers using the offsets defined in MiniCamProtocol.h. +#define SEQ_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + MSC_OFF_SEQUENCE)) +#define IDX_PTR(h) ((volatile uint32_t *)((uint8_t *)(h) + MSC_OFF_PUBLISHED_INDEX)) +#define FP_PTR(h) ((volatile uint64_t *)((uint8_t *)(h) + MSC_OFF_FRAMES_PRODUCED)) + +// ---- Sequence --------------------------------------------------------------- + +uint64_t msc_seq_load_acquire(const void *header) { + return __atomic_load_n(SEQ_PTR(header), __ATOMIC_ACQUIRE); +} + +void msc_seq_store_release(void *header, uint64_t value) { + __atomic_store_n(SEQ_PTR(header), value, __ATOMIC_RELEASE); +} + +// ---- PublishedIndex --------------------------------------------------------- + +uint32_t msc_idx_load_acquire(const void *header) { + return __atomic_load_n(IDX_PTR(header), __ATOMIC_ACQUIRE); +} + +void msc_idx_store_relaxed(void *header, uint32_t value) { + __atomic_store_n(IDX_PTR(header), value, __ATOMIC_RELAXED); +} + +// ---- FramesProduced --------------------------------------------------------- + +uint64_t msc_fp_load_relaxed(const void *header) { + return __atomic_load_n(FP_PTR(header), __ATOMIC_RELAXED); +} + +uint64_t msc_fp_fetch_add(void *header, uint64_t delta) { + return __atomic_fetch_add(FP_PTR(header), delta, __ATOMIC_RELAXED); +} diff --git a/MiniSimCam/Shared/include/AtomicHelpers.h b/MiniSimCam/Shared/include/AtomicHelpers.h new file mode 100644 index 0000000..35717b3 --- /dev/null +++ b/MiniSimCam/Shared/include/AtomicHelpers.h @@ -0,0 +1,45 @@ +// AtomicHelpers.h +// Thin C wrappers around stdatomic operations on the MSCStreamHeader fields. +// These use void* / byte-offsets so Swift can call them without ever touching +// _Atomic fields directly (which Swift cannot import). +// +// IMPORTANT: Offsets must match MSCStreamHeader exactly. +// Verified by the static assertion in MiniCamProtocol.h. + +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ---- Sequence (offset 32 in MSCStreamHeader, uint64_t _Atomic) ------------- +// See MSC_OFF_SEQUENCE in MiniCamProtocol.h. + +/// Acquire-load the sequence field. +uint64_t msc_seq_load_acquire(const void *header); + +/// Store to the sequence field with release ordering. +void msc_seq_store_release(void *header, uint64_t value); + +// ---- PublishedIndex (offset 40, uint32_t _Atomic) ------------------------- +// See MSC_OFF_PUBLISHED_INDEX in MiniCamProtocol.h. + +/// Acquire-load the publishedIndex field. +uint32_t msc_idx_load_acquire(const void *header); + +/// Relaxed-store to the publishedIndex field. +void msc_idx_store_relaxed(void *header, uint32_t value); + +// ---- FramesProduced (offset 56, uint64_t _Atomic) ------------------------- +// See MSC_OFF_FRAMES_PRODUCED in MiniCamProtocol.h. + +/// Relaxed-load the framesProduced field. +uint64_t msc_fp_load_relaxed(const void *header); + +/// Relaxed fetch-and-add on framesProduced; returns the old value. +uint64_t msc_fp_fetch_add(void *header, uint64_t delta); + +#ifdef __cplusplus +} +#endif diff --git a/MiniSimCam/Shared/include/MiniCamConstants.h b/MiniSimCam/Shared/include/MiniCamConstants.h new file mode 100644 index 0000000..ae005e1 --- /dev/null +++ b/MiniSimCam/Shared/include/MiniCamConstants.h @@ -0,0 +1,44 @@ +#pragma once +// MiniCamConstants.h +// Shared path conventions and runtime constants. + +#include + +// ---- Shared-memory file path ----------------------------------------------- +// Full path: MSC_SHM_DIR "/" MSC_SHM_PREFIX MSC_SHM_SUFFIX +#define MSC_SHM_DIR "/tmp" +#define MSC_SHM_PREFIX "minisimcam." +#define MSC_SHM_SUFFIX ".frames" + +// Status JSON file written by the FrameHost. +#define MSC_STATUS_SUFFIX ".status" + +// PID file for the FrameHost process. +#define MSC_PID_SUFFIX ".pid" + +// ---- Environment variables (passed via SIMCTL_CHILD_*) --------------------- +// Full path to the shared-memory file injected by the CLI. +#define MSC_ENV_PATH "MINISIMCAM_PATH" + +// Optional: override frames-per-second for the injector's delivery timer. +#define MSC_ENV_FPS "MINISIMCAM_FPS" + +// ---- Delivery defaults ------------------------------------------------------ +#define MSC_DEFAULT_WIDTH 1280u +#define MSC_DEFAULT_HEIGHT 720u +#define MSC_DEFAULT_FPS 30u + +// Delivery timer tolerance: 10% of frame duration is acceptable jitter. +#define MSC_TIMER_LEEWAY_RATIO 0.1 + +// ---- Safety limits ---------------------------------------------------------- +// Maximum frame dimension supported by this version. +#define MSC_MAX_DIMENSION 3840u + +// Maximum time (ms) the consumer waits when a sequence is odd before giving up. +#define MSC_SEQ_RETRY_LIMIT_US 1000u + +// ---- Producer health -------------------------------------------------------- +// If no frame is published within this many nanoseconds, the injector treats +// the producer as stalled and may deliver a placeholder frame. +#define MSC_STALE_THRESHOLD_NS (500 * 1000 * 1000ull) // 500 ms diff --git a/MiniSimCam/Shared/include/MiniCamProtocol.h b/MiniSimCam/Shared/include/MiniCamProtocol.h new file mode 100644 index 0000000..e119e32 --- /dev/null +++ b/MiniSimCam/Shared/include/MiniCamProtocol.h @@ -0,0 +1,96 @@ +#pragma once +// MiniCamProtocol.h +// Shared between the macOS FrameHost and the iOS Simulator injector dylib. +// Must stay C-compatible so it can be imported from Swift and ObjC++. +// +// ATOMIC FIELDS POLICY +// ==================== +// The sequence, publishedIndex, and framesProduced fields carry atomic +// semantics but are declared as plain integer types here so Swift can +// import them. ALL access to these fields MUST go through the wrapper +// functions in AtomicHelpers.h. Never read/write them directly. +// +// The C++ injector (SharedFrameReader.cpp) casts these fields to +// std::atomic* — valid under the C++ memory model when the underlying +// type has the same size and alignment. + +#include + +#define MSC_MAGIC 0x4D534343u // "MSCC" +#define MSC_VERSION 1u +#define MSC_BUFFER_COUNT 3u + +// kCVPixelFormatType_32BGRA = 0x42475241 = 'BGRA' +#define MSC_PIXEL_FORMAT 0x42475241u + +// Align frame rows to 64 bytes (cache-line sized). +#define MSC_ROW_ALIGNMENT 64u + +// ---------------------------------------------------------------------------- +// Stream header — lives at offset 0 in the shared-memory file. +// The three frame buffers follow immediately after sizeof(MSCStreamHeader). +// +// Field layout (all offsets from struct start, verified by static assert): +// [ 0] uint32_t magic +// [ 4] uint32_t version +// [ 8] uint32_t width +// [ 12] uint32_t height +// [ 16] uint32_t bytesPerRow +// [ 20] uint32_t pixelFormat +// [ 24] uint32_t bufferCount +// [ 28] uint32_t bufferSize +// [ 32] uint64_t sequence ← ATOMIC, use msc_seq_*() +// [ 40] uint32_t publishedIndex ← ATOMIC, use msc_idx_*() +// [ 44] uint32_t _pad0 ← explicit padding for alignment +// [ 48] uint64_t presentationTimeNs (plain, written under seq lock) +// [ 56] uint64_t framesProduced ← ATOMIC, use msc_fp_*() +// [ 64] uint8_t reserved[64] +// [128] = total +// ---------------------------------------------------------------------------- +typedef struct { + uint32_t magic; // [ 0] + uint32_t version; // [ 4] + uint32_t width; // [ 8] + uint32_t height; // [ 12] + uint32_t bytesPerRow; // [ 16] + uint32_t pixelFormat; // [ 20] + uint32_t bufferCount; // [ 24] + uint32_t bufferSize; // [ 28] + uint64_t sequence; // [ 32] ATOMIC — use msc_seq_*() + uint32_t publishedIndex; // [ 40] ATOMIC — use msc_idx_*() + uint32_t _pad0; // [ 44] explicit pad + uint64_t presentationTimeNs; // [ 48] + uint64_t framesProduced; // [ 56] ATOMIC — use msc_fp_*() + uint8_t reserved[64]; // [ 64] +} MSCStreamHeader; // [128] total + +// Byte offsets used by AtomicHelpers.c — must match the layout above. +#define MSC_OFF_SEQUENCE 32u +#define MSC_OFF_PUBLISHED_INDEX 40u +#define MSC_OFF_FRAMES_PRODUCED 56u + +// Compile-time size check (C11 _Static_assert). +_Static_assert(sizeof(MSCStreamHeader) == 128, "MSCStreamHeader must be exactly 128 bytes"); + +#define MSC_HEADER_EXPECTED_SIZE 128u + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +// Compute aligned bytes-per-row for a given pixel width. +static inline uint32_t msc_bytes_per_row(uint32_t width) { + uint32_t raw = width * 4u; + return (raw + MSC_ROW_ALIGNMENT - 1u) & ~(MSC_ROW_ALIGNMENT - 1u); +} + +// Total mmap size: header + 3 * bufferSize. +static inline uint64_t msc_mapping_size(uint32_t bufferSize) { + return (uint64_t)MSC_HEADER_EXPECTED_SIZE + (uint64_t)MSC_BUFFER_COUNT * bufferSize; +} + +// Pointer to frame buffer N inside a mapped region. +static inline void *msc_frame_ptr(void *base, uint32_t bufferSize, uint32_t index) { + uint8_t *p = (uint8_t *)base; + return p + MSC_HEADER_EXPECTED_SIZE + (uint64_t)bufferSize * index; +} diff --git a/MiniSimCam/Shared/include/module.modulemap b/MiniSimCam/Shared/include/module.modulemap new file mode 100644 index 0000000..c8317cb --- /dev/null +++ b/MiniSimCam/Shared/include/module.modulemap @@ -0,0 +1,6 @@ +module MiniSimCamShared { + header "MiniCamProtocol.h" + header "MiniCamConstants.h" + header "AtomicHelpers.h" + export * +} diff --git a/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h b/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h new file mode 100644 index 0000000..1e0f864 --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h @@ -0,0 +1,6 @@ +// FrameHost-Bridging-Header.h +// Imports the shared C protocol header into Swift. + +#include "MiniCamProtocol.h" +#include "MiniCamConstants.h" +#include diff --git a/MiniSimCam/Sources/FrameHost/FrameLoop.swift b/MiniSimCam/Sources/FrameHost/FrameLoop.swift new file mode 100644 index 0000000..26afb16 --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/FrameLoop.swift @@ -0,0 +1,138 @@ +// FrameLoop.swift +// Drives frame publishing at a configured FPS using a DispatchSourceTimer. +// Also writes a status JSON file that the Go CLI reads for `sim cam status`. + +import Foundation + +struct FrameLoopStatus: Codable { + var udid: String + var source: String + var width: Int + var height: Int + var fps: Int + var framesProduced: UInt64 + var hostPID: Int32 + var startedAt: String // ISO-8601 + var lastFrameAgeMs: Double // milliseconds since last publish + var running: Bool +} + +// MARK: - Monotonic clock + +/// Cached mach timebase info — initialised once, not on every call. +private let _timebaseInfo: mach_timebase_info_data_t = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return info +}() + +/// Returns a monotonic nanosecond timestamp anchored to mach_absolute_time. +func currentMonotonicNs() -> UInt64 { + let ticks = mach_absolute_time() + return ticks * UInt64(_timebaseInfo.numer) / UInt64(_timebaseInfo.denom) +} + +final class FrameLoop { + + // MARK: - State + + private let writer: SharedFrameWriter + private let statusPath: String + private let frame: BGRAFrame + private let fps: Int + private let udid: String + private let sourceName: String + + private var timer: DispatchSourceTimer? + private var startDate: Date = .now + private var lastPublishNs: UInt64 = 0 + private var framesProducedLocal: UInt64 = 0 + private let queue = DispatchQueue(label: "com.minisimcam.frameloop", qos: .userInteractive) + + // Status throttle: write at most once per second. + private var lastStatusWriteNs: UInt64 = 0 + private let statusWriteIntervalNs: UInt64 = 1_000_000_000 + + // MARK: - Init + + init( + writer: SharedFrameWriter, + frame: BGRAFrame, + fps: Int, + udid: String, + sourceName: String, + statusPath: String + ) { + self.writer = writer + self.frame = frame + self.fps = fps + self.udid = udid + self.sourceName = sourceName + self.statusPath = statusPath + } + + // MARK: - Lifecycle + + func start() { + startDate = .now + let intervalNs = 1_000_000_000 / UInt64(fps) + let leewayNs = intervalNs / 10 // 10% leeway + + let src = DispatchSource.makeTimerSource(flags: .strict, queue: queue) + src.schedule(deadline: .now(), repeating: .nanoseconds(Int(intervalNs)), + leeway: .nanoseconds(Int(leewayNs))) + src.setEventHandler { [weak self] in self?.tick() } + src.resume() + timer = src + } + + func stop() { + timer?.cancel() + timer = nil + writeStatus(running: false, force: true) + } + + // MARK: - Private + + private func tick() { + let pts = currentMonotonicNs() + do { + try writer.publish(frame: frame, pts: pts) + lastPublishNs = pts + framesProducedLocal &+= 1 + } catch { + fputs("[FrameLoop] publish error: \(error)\n", stderr) + } + // Throttle: write status at most once per second, not every frame. + writeStatus(running: true, force: false) + } + + /// Write status JSON, subject to throttle unless `force` is true. + private func writeStatus(running: Bool, force: Bool) { + let now = currentMonotonicNs() + guard force || (now &- lastStatusWriteNs) >= statusWriteIntervalNs else { return } + lastStatusWriteNs = now + + let ageMs: Double = lastPublishNs == 0 ? 0 + : Double(now &- lastPublishNs) / 1_000_000.0 + + let status = FrameLoopStatus( + udid: udid, + source: sourceName, + width: frame.width, + height: frame.height, + fps: fps, + framesProduced: framesProducedLocal, + hostPID: ProcessInfo.processInfo.processIdentifier, + startedAt: ISO8601DateFormatter().string(from: startDate), + lastFrameAgeMs: ageMs, + running: running + ) + + guard let data = try? JSONEncoder().encode(status) else { return } + // Single atomic write — no need for a second replaceItem on top. + try? data.write(to: URL(fileURLWithPath: statusPath), options: .atomic) + } +} + +// currentMonotonicNs() is defined above, before FrameLoop, using a cached timebase. diff --git a/MiniSimCam/Sources/FrameHost/ImageSource.swift b/MiniSimCam/Sources/FrameHost/ImageSource.swift new file mode 100644 index 0000000..42687d6 --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/ImageSource.swift @@ -0,0 +1,129 @@ +// ImageSource.swift +// Loads a PNG or JPEG and produces raw BGRA frames aligned to MSC_ROW_ALIGNMENT. + +import CoreGraphics +import ImageIO +import Foundation + +/// A decoded, BGRA-converted image ready for shared-memory publishing. +struct BGRAFrame { + let width: Int + let height: Int + let bytesPerRow: Int + let data: Data // Owned copy; safe to share across threads after init. +} + +struct ImageSource { + + // MARK: - Public + + /// Loads an image file and converts it to a BGRA frame at the given dimensions. + /// Throws `ImageSourceError` on failure. + static func load(url: URL, targetWidth: Int, targetHeight: Int) throws -> BGRAFrame { + guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + throw ImageSourceError.cannotLoadImage(url) + } + return try render(cgImage: cgImage, width: targetWidth, height: targetHeight) + } + + /// Generates a synthetic color-bar test card at the given dimensions. + static func colorBars(width: Int, height: Int) throws -> BGRAFrame { + let bytesPerRow = alignedBytesPerRow(width) + let totalBytes = bytesPerRow * height + + var pixels = [UInt8](repeating: 0, count: totalBytes) + + // SMPTE-style color bars: 7 vertical stripes in BGRA order. + let barColors: [(b: UInt8, g: UInt8, r: UInt8)] = [ + (192, 192, 192), // White + (192, 192, 0), // Yellow + (0, 192, 192), // Cyan + (0, 192, 0), // Green + (192, 0, 192), // Magenta + (192, 0, 0), // Red + (0, 0, 192), // Blue + ] + let barWidth = width / barColors.count + + for y in 0.. BGRAFrame { + let bytesPerRow = alignedBytesPerRow(width) + let totalBytes = bytesPerRow * height + + guard totalBytes > 0 else { + throw ImageSourceError.zeroDimension + } + + // BGRA with premultiplied alpha matches kCVPixelFormatType_32BGRA. + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue + | CGImageAlphaInfo.premultipliedFirst.rawValue + + var pixelBuffer = [UInt8](repeating: 0, count: totalBytes) + try pixelBuffer.withUnsafeMutableBytes { rawBuf in + guard let ctx = CGContext( + data: rawBuf.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + throw ImageSourceError.cannotCreateContext + } + ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + + return BGRAFrame( + width: width, + height: height, + bytesPerRow: bytesPerRow, + data: Data(pixelBuffer) + ) + } + + /// Round `width * 4` up to the nearest multiple of `MSC_ROW_ALIGNMENT` (64). + static func alignedBytesPerRow(_ width: Int) -> Int { + let raw = width * 4 + return (raw + 63) & ~63 + } +} + +// MARK: - Errors + +enum ImageSourceError: Error, CustomStringConvertible { + case cannotLoadImage(URL) + case cannotCreateContext + case zeroDimension + + var description: String { + switch self { + case .cannotLoadImage(let url): return "Cannot load image at \(url.path)" + case .cannotCreateContext: return "Cannot create BGRA CGContext" + case .zeroDimension: return "Width or height is zero" + } + } +} diff --git a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift new file mode 100644 index 0000000..c92d36a --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift @@ -0,0 +1,157 @@ +// SharedFrameWriter.swift +// Creates and manages the shared-memory triple-buffer that the injector reads. + +import Foundation +import Darwin +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() + } + + /// Opens (or re-creates) the shared file and writes the stream header. + 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 + + // Remove stale file if present. + if FileManager.default.fileExists(atPath: path) { + try FileManager.default.removeItem(atPath: path) + } + + // Create and size the file. + guard FileManager.default.createFile(atPath: path, contents: nil) else { + throw WriterError.cannotCreateFile(path) + } + let fd = Darwin.open(path, O_RDWR) + guard fd != -1 else { throw WriterError.openFailed(path, errno: errno) } + defer { Darwin.close(fd) } + + guard ftruncate(fd, off_t(totalSize)) == 0 else { + throw WriterError.truncateFailed(errno: errno) + } + + // mmap read-write shared. ftruncate zeroes all bytes, so + // sequence=0 (even) and publishedIndex=0 are correct initial values. + 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 + + // Write the non-atomic header fields directly (Swift can access these). + let hdr = ptr.bindMemory(to: MSCStreamHeader.self, capacity: 1) + 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. + } + + /// 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) + } + + /// Returns the number of frames successfully published so far. + var framesProduced: UInt64 { + guard let base = mapping else { return 0 } + return msc_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/MiniSimCam/Sources/FrameHost/main.swift b/MiniSimCam/Sources/FrameHost/main.swift new file mode 100644 index 0000000..d4d9eab --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/main.swift @@ -0,0 +1,119 @@ +// main.swift — FrameHost CLI entry point +// NOTE: This file is named main.swift so Swift treats it as top-level code. +// We cannot use @main here — instead call FrameHostCommand.main() directly. +import Foundation +import ArgumentParser +import MiniSimCamShared + +struct FrameHostCommand: ParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "FrameHost", + abstract: "MiniSimCam frame producer — writes BGRA frames to a shared-memory triple buffer.", + version: "1.0.0" + ) + + // MARK: - Arguments + + @Option(name: .long, help: "Simulator UDID (used to derive shared-memory and status file paths).") + var udid: String + + @Option(name: .long, help: "Path to a PNG or JPEG source image.") + var image: String? + + @Flag(name: .long, help: "Use a synthetic SMPTE color-bar image instead of a file.") + var bars: 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 + + // MARK: - Validation + + func validate() throws { + guard image != nil || bars else { + throw ValidationError("Provide either --image or --bars.") + } + guard width > 0, height > 0 else { throw ValidationError("Width and height must be > 0.") } + guard fps > 0, fps <= 120 else { throw ValidationError("FPS must be between 1 and 120.") } + guard udid.count >= 8 else { throw ValidationError("UDID looks invalid: \(udid)") } + } + + // MARK: - Run + + func run() throws { + let shmPath = "/tmp/minisimcam.\(udid).frames" + let statusPath = "/tmp/minisimcam.\(udid).status" + let pidPath = "/tmp/minisimcam.\(udid).pid" + + // Write PID file so `sim cam stop` can signal this process. + let pid = ProcessInfo.processInfo.processIdentifier + try String(pid).write(toFile: pidPath, atomically: false, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: pidPath) } + + // Load or generate the source frame. + let sourceName: String + let frame: BGRAFrame + + if bars { + sourceName = "color-bars" + frame = try ImageSource.colorBars(width: width, height: height) + } else { + let url = URL(fileURLWithPath: image!) + sourceName = url.lastPathComponent + frame = try ImageSource.load(url: url, targetWidth: width, targetHeight: height) + } + + // Open shared memory. + let writer = SharedFrameWriter(path: shmPath) + try writer.open(width: width, height: height) + defer { writer.close() } + + // Start the frame loop. + let loop = FrameLoop( + writer: writer, + frame: frame, + fps: fps, + udid: udid, + sourceName: sourceName, + statusPath: statusPath + ) + loop.start() + + print("[FrameHost] started — source=\(sourceName) \(width)×\(height) @ \(fps) fps") + print("[FrameHost] shared memory: \(shmPath)") + print("[FrameHost] status file: \(statusPath)") + print("[FrameHost] PID: \(pid)") + + // Install signal handlers for clean shutdown. + signal(SIGTERM, SIG_IGN) + signal(SIGINT, SIG_IGN) + + let sigTerm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + sigTerm.setEventHandler { + print("[FrameHost] received SIGTERM — shutting down.") + loop.stop() + Foundation.exit(0) + } + sigTerm.resume() + + let sigInt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) + sigInt.setEventHandler { + print("[FrameHost] received SIGINT — shutting down.") + loop.stop() + Foundation.exit(0) + } + sigInt.resume() + + // Park the main thread. + dispatchMain() + } +} + +// Top-level entry — required when file is named main.swift (cannot use @main). +FrameHostCommand.main() diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h new file mode 100644 index 0000000..66bbf84 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h @@ -0,0 +1,15 @@ +// CaptureHooks.h +#pragma once +#include "SharedFrameReader.hpp" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void MSCInstallHooks(SharedFrameReader* reader, int32_t fps); +void MSCUninstallHooks(void); + +#ifdef __cplusplus +} +#endif diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm new file mode 100644 index 0000000..743dfe2 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm @@ -0,0 +1,595 @@ +// CaptureHooks.mm +// AVFoundation method swizzles that intercept just enough for the +// AVCaptureSession + AVCaptureVideoDataOutput path. +// +// Intercepted selectors: +// -[AVCaptureSession startRunning] +// -[AVCaptureSession stopRunning] +// -[AVCaptureVideoDataOutput setSampleBufferDelegate:queue:] +// +[AVCaptureDevice defaultDeviceWithMediaType:] +// -[AVCaptureSession canAddInput:] (allow synthetic sessions) +// -[AVCaptureSession addInput:] (no-op for sessions without hardware) + +#import +#import +#import +#import +#import +#import "CaptureHooks.h" +#import "SampleBufferFactory.h" +#import "SharedFrameReader.hpp" + +@interface MSCFakeConnection : AVCaptureConnection +@end +@implementation MSCFakeConnection +@end + +// --------------------------------------------------------------------------- +// Delivery state (process-global, guarded by a lock) +// --------------------------------------------------------------------------- + +static dispatch_queue_t gDeliveryQueue; +static dispatch_source_t gDeliveryTimer; +static SharedFrameReader* gReader; +static MSCSampleBufferFactory* gFactory; +static id gDelegate; +static dispatch_queue_t gDelegateQueue; +static BOOL gRunning = NO; +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"); + [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); } + +- (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)msc_initWithDevice:(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_initWithDevice:device error:outError]; +} + +@end + +// Forward declaration +static void startDelivery(void); + +// --------------------------------------------------------------------------- +// AVCaptureVideoPreviewLayer category — swizzled methods +// --------------------------------------------------------------------------- + +@implementation AVCaptureVideoPreviewLayer (MiniCamHook) + +- (instancetype)msc_initWithSession:(AVCaptureSession *)session { + id instance = [self msc_initWithSession:session]; + if (instance) { + [gLock lock]; + [gPreviewLayers addObject:instance]; + [gLock unlock]; + ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = kCAGravityResizeAspectFill; + // gReader is initialised in EntryPoint — do not recreate here. + startDelivery(); + } + return instance; +} + +- (instancetype)msc_initWithSessionWithNoConnection:(AVCaptureSession *)session { + id instance = [self msc_initWithSessionWithNoConnection:session]; + if (instance) { + [gLock lock]; + [gPreviewLayers addObject:instance]; + [gLock unlock]; + ((AVCaptureVideoPreviewLayer *)instance).contentsGravity = kCAGravityResizeAspectFill; + // gReader is initialised in EntryPoint — do not recreate here. + startDelivery(); + } + 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(); + } + + [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(msc_initWithSession:)); + if (m3 && m4) method_exchangeImplementations(m3, m4); + + Method m5 = class_getInstanceMethod(layerCls, @selector(initWithSessionWithNoConnection:)); + Method m6 = class_getInstanceMethod(layerCls, @selector(msc_initWithSessionWithNoConnection:)); + 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(msc_initWithDevice:error:)); + + // AVCaptureDeviceDiscoverySession + Class discoverySessionCls = NSClassFromString(@"AVCaptureDeviceDiscoverySession"); + if (discoverySessionCls) { + swizzleInstance(discoverySessionCls, + @selector(devices), + @selector(msc_devices)); + } + + NSLog(@"[MiniCamInject] hooks installed — fps=%d", fps); +} + +void MSCUninstallHooks(void) { + stopDelivery(); + // Note: method_exchangeImplementations is idempotent for paired calls, + // but we cannot easily "un-swizzle" without re-exchanging. + // In practice, the process exits when the app is terminated. +} diff --git a/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm b/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm new file mode 100644 index 0000000..03d7878 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm @@ -0,0 +1,60 @@ +// EntryPoint.mm +// dylib constructor — called automatically by dyld when the library is loaded. +// Checks the MINISIMCAM_PATH environment variable, opens shared memory, +// and installs AVFoundation hooks. + +#import +#import "CaptureHooks.h" +#import "SharedFrameReader.hpp" +#include "MiniCamConstants.h" +#include +#include + +// Process-global reader (heap-allocated; intentional lifetime until process exit). +static SharedFrameReader* gGlobalReader = nullptr; + +static int32_t parseFPS(void) { + const char* envFPS = getenv(MSC_ENV_FPS); + if (!envFPS) return MSC_DEFAULT_FPS; + int v = atoi(envFPS); + return (v > 0 && v <= 120) ? (int32_t)v : MSC_DEFAULT_FPS; +} + +__attribute__((constructor)) +static void MiniCamInjectInit(void) { + @autoreleasepool { + const char* pathEnv = getenv(MSC_ENV_PATH); + if (!pathEnv || pathEnv[0] == '\0') { + NSLog(@"[MiniCamInject] %s not set — injector inactive.", MSC_ENV_PATH); + return; + } + + std::string shmPath(pathEnv); + NSLog(@"[MiniCamInject] loading — shm=%s", shmPath.c_str()); + + gGlobalReader = new SharedFrameReader(shmPath); + + if (!gGlobalReader->open()) { + NSLog(@"[MiniCamInject] ⚠️ Cannot open shared memory at %s. " + "Start 'sim cam start' before launching the app.", + shmPath.c_str()); + // Hooks still installed; they will check isOpen() before delivering. + } + + int32_t fps = parseFPS(); + MSCInstallHooks(gGlobalReader, fps); + + NSLog(@"[MiniCamInject] ✅ injector ready (fps=%d, shm=%s)", fps, shmPath.c_str()); + } +} + +__attribute__((destructor)) +static void MiniCamInjectFini(void) { + MSCUninstallHooks(); + if (gGlobalReader) { + gGlobalReader->close(); + delete gGlobalReader; + gGlobalReader = nullptr; + } + NSLog(@"[MiniCamInject] unloaded."); +} diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h new file mode 100644 index 0000000..706f4e7 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h @@ -0,0 +1,34 @@ +// SampleBufferFactory.h +// Objective-C header for MSCSampleBufferFactory. +// Uses #ifdef __cplusplus so this can be included from both .mm and pure C contexts. + +#pragma once +#import +#import + +#ifdef __cplusplus +#include "SharedFrameReader.hpp" +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface MSCSampleBufferFactory : NSObject + +- (instancetype)initWithFPS:(int32_t)fps NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +#ifdef __cplusplus +/// Creates a CMSampleBuffer from a FrameSnapshot read via SharedFrameReader. +/// Returns a +1 CF reference on success; the caller is responsible for CFRelease. +/// Returns nil if the snapshot is invalid or buffer creation fails. +- (nullable CMSampleBufferRef)sampleBufferFromSnapshot:(const FrameSnapshot &)snap CF_RETURNS_RETAINED; + +/// Optimised single-copy path: reads frame metadata from the shm header, allocates +/// a CVPixelBuffer, and copies the frame data directly (one copy vs two). +/// Returns a +1 CF reference on success; the caller is responsible for CFRelease. +- (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader CF_RETURNS_RETAINED; +#endif + +@end + +NS_ASSUME_NONNULL_END diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm new file mode 100644 index 0000000..5a5bbd5 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm @@ -0,0 +1,175 @@ +// 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 copy: shm → CVPixelBuffer (no intermediate vector). + CVPixelBufferLockBaseAddress(pixBuf, 0); + void *dst = CVPixelBufferGetBaseAddress(pixBuf); + size_t dstBPR = CVPixelBufferGetBytesPerRow(pixBuf); + size_t dstTotal = dstBPR * h; + + FrameInfo info = reader->copyLatestFrameInto(dst, 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 = CMTimeAdd( + _startCMTime, + CMTimeMakeWithSeconds((double)ptsNs / 1e9, 90000) + ); + + CMSampleTimingInfo timing = { + .duration = _frameDuration, + .presentationTimeStamp = pts, + .decodeTimeStamp = kCMTimeInvalid + }; + + CMSampleBufferRef sampleBuf = nullptr; + OSStatus status = CMSampleBufferCreateReadyWithImageBuffer( + kCFAllocatorDefault, + pixBuf, + _formatDesc, + &timing, + &sampleBuf + ); + + CVPixelBufferRelease(pixBuf); + + if (status != noErr || !sampleBuf) return nil; + // Return a +1 CF reference. The caller is responsible for CFRelease. + return sampleBuf; +} + +@end diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp new file mode 100644 index 0000000..313e409 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp @@ -0,0 +1,208 @@ +// 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 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 (dstSize < bufSize) return info; // Caller buffer too small. + + 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 void* src = msc_frame_ptr(mapping_, bufSize, idx); + // Single copy: mmap → caller's buffer (typically a CVPixelBuffer). + std::memcpy(dst, src, bufSize); + + uint64_t seqB = seqAtomic->load(std::memory_order_acquire); + if (seqA != seqB) continue; // Torn read — retry. + + info.valid = true; + info.width = w; + info.height = h; + info.bytesPerRow = bpr; + info.ptsNs = pts; + info.framesProduced = fp; + return info; + } + + return info; +} diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp new file mode 100644 index 0000000..7bdf366 --- /dev/null +++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp @@ -0,0 +1,80 @@ +// 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). + /// `dstSize` must be >= bytesPerRow * height from the shm header. + /// 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 dstSize); + + /// Check whether the producer has gone stale (no update within MSC_STALE_THRESHOLD_NS). + bool isProducerStale() const; + +private: + std::string path_; + void* mapping_ = nullptr; + size_t mappingSize_ = 0; + int fd_ = -1; + + MSCStreamHeader* header() const { + return static_cast(mapping_); + } +}; diff --git a/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift b/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift new file mode 100644 index 0000000..e4f6f28 --- /dev/null +++ b/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift @@ -0,0 +1,119 @@ +// FrameTransportTests.swift +// Integration tests for SharedFrameWriter + SharedFrameReader round-trip. +// Requires macOS (uses POSIX mmap). + +import XCTest +@testable import FrameHost + +final class FrameTransportTests: XCTestCase { + + private var tmpPath: String! + + override func setUp() { + super.setUp() + tmpPath = NSTemporaryDirectory() + "minisimcam_test_\(UUID().uuidString).frames" + } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: tmpPath) + super.tearDown() + } + + // MARK: - Basic round-trip + + func testWriteReadRoundTrip() throws { + let writer = SharedFrameWriter(path: tmpPath) + try writer.open(width: 320, height: 240) + defer { writer.close() } + + // Create a red frame. + let bpr = ImageSource.alignedBytesPerRow(320) + var pixels = [UInt8](repeating: 0, count: bpr * 240) + for y in 0..<240 { + for x in 0..<320 { + let off = y * bpr + x * 4 + pixels[off + 0] = 0x00 // B + pixels[off + 1] = 0x00 // G + pixels[off + 2] = 0xFF // R + pixels[off + 3] = 0xFF // A + } + } + let frame = BGRAFrame(width: 320, height: 240, bytesPerRow: bpr, data: Data(pixels)) + try writer.publish(frame: frame, pts: 1_000_000) + + XCTAssertEqual(writer.framesProduced, 1) + + // Verify the file exists and has the expected size. + let fileSize = (try? FileManager.default.attributesOfItem(atPath: tmpPath)[.size] as? Int) ?? 0 + let expected = 128 + 3 * bpr * 240 + XCTAssertEqual(fileSize, expected, "File size should match header + 3 × frame") + } + + // MARK: - Odd dimensions (catches stride bugs) + + func testOddDimensionFrame() throws { + let w = 641, h = 479 + let writer = SharedFrameWriter(path: tmpPath) + try writer.open(width: w, height: h) + defer { writer.close() } + + let frame = try ImageSource.colorBars(width: w, height: h) + XCTAssertEqual(frame.bytesPerRow % 64, 0, "bytesPerRow must be 64-byte aligned") + XCTAssertGreaterThanOrEqual(frame.bytesPerRow, w * 4) + + try writer.publish(frame: frame, pts: 1) + XCTAssertEqual(writer.framesProduced, 1) + } + + // MARK: - Producer restart detection + + func testProducerRestart() throws { + let writer = SharedFrameWriter(path: tmpPath) + try writer.open(width: 160, height: 120) + let frame = try ImageSource.colorBars(width: 160, height: 120) + try writer.publish(frame: frame, pts: 1) + writer.close() + + // Simulate producer restart: re-open the same path. + let writer2 = SharedFrameWriter(path: tmpPath) + try writer2.open(width: 160, height: 120) + defer { writer2.close() } + + // framesProduced resets to 0 on a fresh open. + XCTAssertEqual(writer2.framesProduced, 0) + } + + // MARK: - Monotonic frame count + + func testMonotonicFrameCount() throws { + let writer = SharedFrameWriter(path: tmpPath) + try writer.open(width: 160, height: 120) + defer { writer.close() } + + let frame = try ImageSource.colorBars(width: 160, height: 120) + for i in 1...10 { + try writer.publish(frame: frame, pts: UInt64(i) * 33_333_333) + XCTAssertEqual(writer.framesProduced, UInt64(i)) + } + } + + // MARK: - ImageSource color bars + + func testColorBarsSize() throws { + let f = try ImageSource.colorBars(width: 1280, height: 720) + XCTAssertEqual(f.width, 1280) + XCTAssertEqual(f.height, 720) + XCTAssertEqual(f.bytesPerRow, 1280 * 4) // Already aligned + XCTAssertEqual(f.data.count, f.bytesPerRow * f.height) + } + + // MARK: - Row alignment invariant + + func testRowAlignmentInvariant() { + for w in [1, 7, 64, 100, 320, 641, 1280, 1920, 3840] { + let bpr = ImageSource.alignedBytesPerRow(w) + XCTAssertEqual(bpr % 64, 0, "bytesPerRow for width \(w) must be 64-byte aligned") + XCTAssertGreaterThanOrEqual(bpr, w * 4, "bytesPerRow must cover all pixels") + } + } +} diff --git a/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift b/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift new file mode 100644 index 0000000..3a8d1cb --- /dev/null +++ b/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift @@ -0,0 +1,87 @@ +// ProtocolTests.swift +// Unit tests for MiniCamProtocol.h calculations. +// These do NOT require a running FrameHost; they validate pure math. + +import XCTest +@testable import MiniSimCamShared // Swift wrapper around the C header + +final class ProtocolTests: XCTestCase { + + // MARK: - Header size + + func testHeaderSize() { + // Must remain exactly 128 bytes so existing shared files stay valid. + XCTAssertEqual(MemoryLayout.size, 128) + } + + // MARK: - Row stride alignment + + func testBytesPerRowExactMultiple() { + // Width that already aligns: 64 pixels → 64*4 = 256 B, already 64-byte aligned. + XCTAssertEqual(msc_bytes_per_row(64), 256) + } + + func testBytesPerRowRoundsUp() { + // 1280 pixels → 1280*4 = 5120 B → already aligned (5120 % 64 == 0). + XCTAssertEqual(msc_bytes_per_row(1280), 5120) + } + + func testBytesPerRowOddWidth() { + // 641 pixels → 641*4 = 2564 B → rounded up to next multiple of 64 = 2624. + let bpr = msc_bytes_per_row(641) + XCTAssertEqual(bpr % 64, 0) + XCTAssertGreaterThanOrEqual(bpr, 641 * 4) + } + + func testBytesPerRowMinimalWidth() { + // Width 1 → 4 bytes → rounded to 64. + XCTAssertEqual(msc_bytes_per_row(1), 64) + } + + // MARK: - Mapping size + + func testMappingSizeFormula() { + let bpr = msc_bytes_per_row(1280) + let bufSize = bpr * 720 + let total = msc_mapping_size(bufSize) + XCTAssertEqual(total, UInt64(128) + UInt64(3) * UInt64(bufSize)) + } + + // MARK: - Frame pointer arithmetic + + func testFramePtrOffsets() { + let bufSize: UInt32 = 100 + var fakeBase = [UInt8](repeating: 0, count: 128 + 3 * 100) + fakeBase.withUnsafeMutableBytes { raw in + let base = raw.baseAddress! + let p0 = msc_frame_ptr(base, bufSize, 0) + let p1 = msc_frame_ptr(base, bufSize, 1) + let p2 = msc_frame_ptr(base, bufSize, 2) + + XCTAssertEqual(p0, base.advanced(by: 128)) + XCTAssertEqual(p1, base.advanced(by: 128 + 100)) + XCTAssertEqual(p2, base.advanced(by: 128 + 200)) + } + } + + // MARK: - Magic / version constants + + func testMagicConstant() { + XCTAssertEqual(MSC_MAGIC, 0x4D534343) + } + + func testVersionConstant() { + XCTAssertEqual(MSC_VERSION, 1) + } + + func testBufferCount() { + XCTAssertEqual(MSC_BUFFER_COUNT, 3) + } + + // MARK: - Pixel format + + func testPixelFormat() { + // kCVPixelFormatType_32BGRA = 'BGRA' = 0x42475241 + XCTAssertEqual(MSC_PIXEL_FORMAT, 0x42475241) + } +} diff --git a/cmd/cam.go b/cmd/cam.go new file mode 100644 index 0000000..6257ced --- /dev/null +++ b/cmd/cam.go @@ -0,0 +1,425 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Paths & helpers +// --------------------------------------------------------------------------- + +// camMscDir is an optional override for the MiniSimCam directory. +var camMscDir string + +func miniSimCamDir() string { + if camMscDir != "" { + return camMscDir + } + ex, _ := os.Executable() + // Walk up to the project root where MiniSimCam/ lives. + dir := filepath.Dir(ex) + for i := 0; i < 4; i++ { + candidate := filepath.Join(dir, "MiniSimCam") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + dir = filepath.Dir(dir) + } + // Fall back to CWD/MiniSimCam. + cwd, _ := os.Getwd() + return filepath.Join(cwd, "MiniSimCam") +} + +func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } +func statusFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.status", udid) } +func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } +func frameHostBin(mscDir string) string { return filepath.Join(mscDir, ".build", "release", "FrameHost") } +func injectorDylib(mscDir string) string { return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") } +func buildScript(mscDir string) string { return filepath.Join(mscDir, "Scripts", "build.sh") } + +// --------------------------------------------------------------------------- +// cam command group +// --------------------------------------------------------------------------- + +var camCmd = &cobra.Command{ + Use: "cam", + Short: "iOS Simulator camera injector", + Long: `MiniSimCam — inject synthetic camera frames into an iOS Simulator app. + +The cam group manages a macOS FrameHost process that writes BGRA frames to +shared memory. A dylib (MiniCamInject) loaded into your simulator app reads +those frames and delivers them as CMSampleBuffer callbacks. + +Quick start: + sim cam build + sim cam start --image test.png + sim cam launch --bundle-id com.example.MyApp + sim cam status + sim cam stop`, +} + +// --------------------------------------------------------------------------- +// sim cam build +// --------------------------------------------------------------------------- + +var camBuildCmd = &cobra.Command{ + Use: "build", + Short: "Build FrameHost and MiniCamInject.dylib", + Long: `Compiles the Swift FrameHost binary and the iOS Simulator injector dylib.`, + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam build is only supported on macOS") + } + mscDir := miniSimCamDir() + script := buildScript(mscDir) + if _, err := os.Stat(script); err != nil { + return fmt.Errorf("build script not found at %s — is MiniSimCam/ present?", script) + } + + return RunSpinner("Building MiniSimCam (FrameHost + MiniCamInject)…", func() error { + c := exec.Command("/bin/bash", script, mscDir) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + }) + }, +} + +// --------------------------------------------------------------------------- +// sim cam start +// --------------------------------------------------------------------------- + +var ( + camStartImage string + camStartBars bool + camStartWidth int + camStartHeight int + camStartFPS int + camStartDevice string +) + +var camStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the FrameHost (frame producer)", + Long: `Start the FrameHost macOS process that writes BGRA frames to shared memory. + +Examples: + sim cam start --image ./test-card.png + sim cam start --bars --width 1920 --height 1080 --fps 60 + sim cam start --image qr.png --device `, + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam start is only supported on macOS") + } + if camStartImage == "" && !camStartBars { + return fmt.Errorf("provide --image or --bars") + } + + // Resolve UDID. + udid, _, _, err := FindRunningDevice(camStartDevice) + if err != nil || udid == "" { + return fmt.Errorf("no booted iOS simulator found — boot one first (%w)", err) + } + + mscDir := miniSimCamDir() + bin := frameHostBin(mscDir) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("FrameHost not built — run 'sim cam build' first") + } + + // Kill any existing FrameHost for this UDID. + _ = stopFrameHost(udid) + + hostArgs := []string{ + "--udid", udid, + "--width", strconv.Itoa(camStartWidth), + "--height", strconv.Itoa(camStartHeight), + "--fps", strconv.Itoa(camStartFPS), + } + if camStartBars { + hostArgs = append(hostArgs, "--bars") + } else { + absImage, err := filepath.Abs(camStartImage) + if err != nil { + return fmt.Errorf("cannot resolve image path: %w", err) + } + hostArgs = append(hostArgs, "--image", absImage) + } + + c := exec.Command(bin, hostArgs...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + // Detach from the terminal so FrameHost survives terminal close. + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := c.Start(); err != nil { + return fmt.Errorf("failed to start FrameHost: %w", err) + } + + // Give it a moment to write the PID file before returning. + time.Sleep(500 * time.Millisecond) + + source := camStartImage + if camStartBars { + source = "color-bars" + } + PrintSuccess(fmt.Sprintf( + "FrameHost started — source=%s %dx%d @ %d fps (simulator %s)", + source, camStartWidth, camStartHeight, camStartFPS, udid, + )) + return nil + }, +} + +// --------------------------------------------------------------------------- +// sim cam launch +// --------------------------------------------------------------------------- + +var ( + camLaunchBundle string + camLaunchDevice string +) + +var camLaunchCmd = &cobra.Command{ + Use: "launch", + Short: "Launch an app on the simulator with MiniCamInject loaded", + Long: `Launches your app on the booted iOS Simulator with MiniCamInject.dylib +injected via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES. + +The FrameHost must be running first (sim cam start). + +Example: + sim cam launch --bundle-id com.example.CameraPreviewApp`, + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam launch is only supported on macOS") + } + if camLaunchBundle == "" { + return fmt.Errorf("--bundle-id is required") + } + + udid, _, _, err := FindRunningDevice(camLaunchDevice) + if err != nil || udid == "" { + return fmt.Errorf("no booted iOS simulator found (%w)", err) + } + + mscDir := miniSimCamDir() + dylib := injectorDylib(mscDir) + if _, err := os.Stat(dylib); err != nil { + return fmt.Errorf("MiniCamInject.dylib not found — run 'sim cam build' first") + } + + shm := shmPath(udid) + + PrintInfo(fmt.Sprintf("Launching %s on %s", camLaunchBundle, udid)) + PrintInfo(fmt.Sprintf(" dylib: %s", dylib)) + PrintInfo(fmt.Sprintf(" shm: %s", shm)) + + // Ensure get-task-allow entitlement is present so DYLD_INSERT_LIBRARIES works. + appPathBytes, err := exec.Command("xcrun", "simctl", "get_app_container", udid, camLaunchBundle, "app").Output() + if err == nil { + appPath := strings.TrimSpace(string(appPathBytes)) + entOut, _ := exec.Command("codesign", "-d", "--entitlements", ":-", appPath).Output() + entXML := string(entOut) + + if !strings.Contains(entXML, "com.apple.security.get-task-allow") { + PrintInfo(" Injecting get-task-allow entitlement to permit dylib injection...") + if strings.Contains(entXML, "") { + entXML = strings.Replace(entXML, "", "\n\tcom.apple.security.get-task-allow\n\t", 1) + } else { + entXML = `com.apple.security.get-task-allow` + } + entPath := filepath.Join(os.TempDir(), "minisimcam_entitlements.plist") + if writeErr := os.WriteFile(entPath, []byte(entXML), 0644); writeErr != nil { + PrintInfo(fmt.Sprintf(" Warning: cannot write entitlements file: %v", writeErr)) + } else if signErr := exec.Command("codesign", "-f", "-s", "-", "--entitlements", entPath, appPath).Run(); signErr != nil { + PrintInfo(fmt.Sprintf(" Warning: codesign re-sign failed: %v", signErr)) + } else { + PrintInfo(" Re-signed with get-task-allow.") + } + } + } + + c := exec.Command("xcrun", "simctl", "launch", udid, camLaunchBundle) + c.Env = append(os.Environ(), + "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib, + "SIMCTL_CHILD_MINISIMCAM_PATH="+shm, + ) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("simctl launch failed: %w", err) + } + + PrintSuccess(fmt.Sprintf("Launched %s with MiniCamInject", camLaunchBundle)) + return nil + }, +} + +// --------------------------------------------------------------------------- +// sim cam status +// --------------------------------------------------------------------------- + +var camStatusDevice string + +// camFrameLoopStatus mirrors FrameLoopStatus defined in FrameLoop.swift. +type camFrameLoopStatus struct { + UDID string `json:"udid"` + Source string `json:"source"` + Width int `json:"width"` + Height int `json:"height"` + FPS int `json:"fps"` + FramesProduced uint64 `json:"framesProduced"` + HostPID int32 `json:"hostPID"` + StartedAt string `json:"startedAt"` + LastFrameAgeMs float64 `json:"lastFrameAgeMs"` + Running bool `json:"running"` +} + +var camStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show FrameHost status and frame statistics", + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam status is only supported on macOS") + } + + udid, name, _, err := FindRunningDevice(camStatusDevice) + if err != nil || udid == "" { + return fmt.Errorf("no booted iOS simulator found (%w)", err) + } + + statusPath := statusFilePath(udid) + data, err := os.ReadFile(statusPath) + if err != nil { + PrintInfo(fmt.Sprintf("No status file found at %s", statusPath)) + PrintInfo("Is 'sim cam start' running?") + return nil + } + + var st camFrameLoopStatus + if err := json.Unmarshal(data, &st); err != nil { + return fmt.Errorf("cannot parse status file: %w", err) + } + + lines := []string{ + fmt.Sprintf("Simulator: %s (%s)", name, udid), + fmt.Sprintf("Source: %s", st.Source), + fmt.Sprintf("Resolution: %dx%d BGRA", st.Width, st.Height), + fmt.Sprintf("Frame rate: %d fps", st.FPS), + fmt.Sprintf("Frames produced: %d", st.FramesProduced), + fmt.Sprintf("Last frame age: %.0f ms", st.LastFrameAgeMs), + fmt.Sprintf("Host PID: %d", st.HostPID), + fmt.Sprintf("Started at: %s", st.StartedAt), + fmt.Sprintf("Running: %v", st.Running), + } + + width := 0 + for _, l := range lines { + if len(l) > width { + width = len(l) + } + } + + border := strings.Repeat("─", width+4) + fmt.Println("┌" + border + "┐") + fmt.Println("│ MiniSimCam Status" + strings.Repeat(" ", width+4-len(" MiniSimCam Status")) + "│") + fmt.Println("├" + border + "┤") + for _, l := range lines { + fmt.Printf("│ %-*s │\n", width, l) + } + fmt.Println("└" + border + "┘") + return nil + }, +} + +// --------------------------------------------------------------------------- +// sim cam stop +// --------------------------------------------------------------------------- + +var camStopDevice string + +var camStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the FrameHost process", + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam stop is only supported on macOS") + } + + udid, _, _, err := FindRunningDevice(camStopDevice) + if err != nil || udid == "" { + return fmt.Errorf("no booted iOS simulator found (%w)", err) + } + + if err := stopFrameHost(udid); err != nil { + return err + } + PrintSuccess("FrameHost stopped.") + return nil + }, +} + +// stopFrameHost reads the PID file and sends SIGTERM. +func stopFrameHost(udid string) error { + pidPath := pidFilePath(udid) + data, err := os.ReadFile(pidPath) + if err != nil { + return nil // No PID file — nothing to stop. + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return fmt.Errorf("invalid PID in %s: %w", pidPath, err) + } + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + _ = proc.Signal(os.Interrupt) // SIGINT — triggers clean shutdown. + return nil +} + +// --------------------------------------------------------------------------- +// init: register sub-commands and flags +// --------------------------------------------------------------------------- + +func init() { + // cam-level flag: --msc-dir override + camCmd.PersistentFlags().StringVar(&camMscDir, "msc-dir", "", "Path to MiniSimCam directory (default: auto-detected)") + + // build + camCmd.AddCommand(camBuildCmd) + + // 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().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) + + // launch + camLaunchCmd.Flags().StringVar(&camLaunchBundle, "bundle-id", "", "App bundle identifier (required)") + camLaunchCmd.Flags().StringVar(&camLaunchDevice, "device", "", "Simulator name or UDID (default: booted)") + _ = camLaunchCmd.MarkFlagRequired("bundle-id") + camCmd.AddCommand(camLaunchCmd) + + // status + camStatusCmd.Flags().StringVar(&camStatusDevice, "device", "", "Simulator name or UDID (default: booted)") + camCmd.AddCommand(camStatusCmd) + + // stop + camStopCmd.Flags().StringVar(&camStopDevice, "device", "", "Simulator name or UDID (default: booted)") + camCmd.AddCommand(camStopCmd) +} diff --git a/cmd/cam_test.go b/cmd/cam_test.go new file mode 100644 index 0000000..4b76e7b --- /dev/null +++ b/cmd/cam_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "encoding/json" + "path/filepath" + "testing" +) + +// TestCamShmPaths verifies that shmPath / statusFilePath / pidFilePath produce +// the expected string patterns without touching the filesystem. +func TestCamShmPaths(t *testing.T) { + udid := "ABC-123" + + tests := []struct { + fn func(string) string + want string + }{ + {shmPath, "/tmp/minisimcam.ABC-123.frames"}, + {statusFilePath, "/tmp/minisimcam.ABC-123.status"}, + {pidFilePath, "/tmp/minisimcam.ABC-123.pid"}, + } + + for _, tt := range tests { + if got := tt.fn(udid); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + } +} + +// TestCamStatusParsing verifies that a status JSON file written by FrameHost +// can be round-tripped through camFrameLoopStatus. +func TestCamStatusParsing(t *testing.T) { + sample := camFrameLoopStatus{ + UDID: "TEST-UDID", + Source: "test-card.png", + Width: 1280, + Height: 720, + FPS: 30, + FramesProduced: 900, + HostPID: 12345, + StartedAt: "2026-01-01T00:00:00Z", + LastFrameAgeMs: 12.5, + Running: true, + } + + data, err := json.Marshal(sample) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var parsed camFrameLoopStatus + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if parsed.FramesProduced != 900 { + t.Errorf("FramesProduced: got %d, want 900", parsed.FramesProduced) + } + if parsed.Width != 1280 { + t.Errorf("Width: got %d, want 1280", parsed.Width) + } + if !parsed.Running { + t.Error("Running should be true") + } +} + +// TestStopFrameHostNoFile verifies stopFrameHost returns nil when no PID file exists. +func TestStopFrameHostNoFile(t *testing.T) { + // Use a non-existent UDID — no PID file should exist. + err := stopFrameHost("non-existent-udid-xyz") + if err != nil { + t.Errorf("expected nil error, got: %v", err) + } +} + +// TestMiniSimCamDirFallback verifies that miniSimCamDir() returns a path ending in "MiniSimCam". +func TestMiniSimCamDirFallback(t *testing.T) { + dir := miniSimCamDir() + if filepath.Base(dir) != "MiniSimCam" { + t.Errorf("expected base to be MiniSimCam, got %q", filepath.Base(dir)) + } +} diff --git a/cmd/constants.go b/cmd/constants.go index 094e11e..9ee04c9 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -27,4 +27,9 @@ const ( CmdXclip = "xclip" PrefixScreenshot = "screenshot" PrefixRecording = "recording" + + // Camera injector (MiniSimCam) defaults. + DefaultCamWidth = 1280 + DefaultCamHeight = 720 + DefaultCamFPS = 30 ) diff --git a/cmd/root.go b/cmd/root.go index f26ed7d..8c4aa87 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -171,6 +171,7 @@ func init() { rootCmd.AddCommand(pairCmd) rootCmd.AddCommand(openCmd) rootCmd.AddCommand(doctorCmd) + rootCmd.AddCommand(camCmd) // deleteCmd flags deleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") From 247a52c80653ccbb0fdbdb67b0b3b6254eda7855 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 10:37:04 +0700 Subject: [PATCH 02/10] fix: auto-start AVCaptureSession for apps that skip startRunning() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps using PermissionManager-style patterns configure the camera session but never call startRunning(), leaving AVCaptureVideoPreviewLayer in a dormant state and producing a frozen black frame. Changes in CaptureHooks.mm: - Add gAVSessionStarted flag (set in msc_startRunning) to track whether the app explicitly called startRunning so we don't double-start - Add MSCFakeCaptureDevice.deviceType returning builtInWideAngleCamera so discovery-session filters (e.g. bestPossibleBackCamera) match via the preferred-type loop rather than the last-resort fallback - In msc_setSession:, init_mscWithSession:, and initWithSessionWithNoConnection:, dispatch a 500 ms delayed block that calls startRunning if the app never did — the delay ensures onAppear and any explicit startRunning the app calls have already fired, preventing a double-start crash on Simulator; wrapped in @try/@catch as an additional safety net --- .../xcschemes/xcschememanagement.plist | 28 +++ MiniSimCam/Package.swift | 11 +- .../Sources/FrameHost/CameraSource.swift | 160 ++++++++++++++++++ MiniSimCam/Sources/FrameHost/Info.plist | 8 + .../Sources/FrameHost/SharedFrameWriter.swift | 54 ++++++ MiniSimCam/Sources/FrameHost/main.swift | 68 +++++--- .../Sources/MiniCamInject/CaptureHooks.mm | 88 ++++++++-- .../MiniCamInject/SampleBufferFactory.mm | 5 +- cmd/cam.go | 17 +- 9 files changed, 393 insertions(+), 46 deletions(-) create mode 100644 MiniSimCam/Sources/FrameHost/CameraSource.swift create mode 100644 MiniSimCam/Sources/FrameHost/Info.plist diff --git a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist index bff7fdb..cf7ea32 100644 --- a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist @@ -25,5 +25,33 @@ 2 + SuppressBuildableAutocreation + + FrameHost + + primary + + + FrameTransportTests + + primary + + + MiniCamInject + + primary + + + MiniSimCamShared + + primary + + + ProtocolTests + + primary + + + diff --git a/MiniSimCam/Package.swift b/MiniSimCam/Package.swift index 40fbb4f..d9bf77b 100644 --- a/MiniSimCam/Package.swift +++ b/MiniSimCam/Package.swift @@ -47,7 +47,16 @@ let package = Package( "MiniSimCamShared", .product(name: "ArgumentParser", package: "swift-argument-parser"), ], - path: "Sources/FrameHost" + path: "Sources/FrameHost", + exclude: ["Info.plist"], + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Sources/FrameHost/Info.plist" + ]) + ] ), // ------------------------------------------------------------------ diff --git a/MiniSimCam/Sources/FrameHost/CameraSource.swift b/MiniSimCam/Sources/FrameHost/CameraSource.swift new file mode 100644 index 0000000..61f284c --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/CameraSource.swift @@ -0,0 +1,160 @@ +// CameraSource.swift +// Captures live frames from the Mac's camera using AVFoundation. + +import Foundation +import AVFoundation +import CoreVideo + +final class CameraSource: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + private let writer: SharedFrameWriter + private let targetWidth: Int + private let targetHeight: Int + private let fps: Int + private let udid: String + private let statusPath: String + + private let session = AVCaptureSession() + private let captureQueue = DispatchQueue(label: "com.minisimcam.cameraCapture") + + // 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 + }() + + init(writer: SharedFrameWriter, width: Int, height: Int, fps: Int, udid: String, statusPath: String) { + self.writer = writer + self.targetWidth = width + self.targetHeight = height + self.fps = fps + self.udid = udid + self.statusPath = statusPath + super.init() + } + + func start() throws { + session.beginConfiguration() + + // Ensure we try to match the requested resolution if possible + if session.canSetSessionPreset(.hd1280x720) { + session.sessionPreset = .hd1280x720 + } else { + session.sessionPreset = .high + } + + guard let device = AVCaptureDevice.default(for: .video) else { + throw CameraError.noCameraFound + } + + let input = try AVCaptureDeviceInput(device: device) + if session.canAddInput(input) { + session.addInput(input) + } else { + throw CameraError.cannotAddInput + } + + let output = AVCaptureVideoDataOutput() + output.alwaysDiscardsLateVideoFrames = true + // Ask for BGRA so we don't have to convert + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA) + ] + + output.setSampleBufferDelegate(self, queue: captureQueue) + if session.canAddOutput(output) { + session.addOutput(output) + } else { + throw CameraError.cannotAddOutput + } + + // 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)") + } + + session.commitConfiguration() + session.startRunning() + } + + func stop() { + 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 { + // Note: If the camera's resolution doesn't match targetWidth/targetHeight perfectly, + // we should technically resize it. For zero-copy, we assume the writer handles max bounds, + // or we just memcpy what we can. SharedFrameWriter's publish does `min(dataSize, frameSize)`. + // In a production environment, you'd use a CIContext or vImage to scale the CVPixelBuffer first + // if dimensions mismatch, but for this POC, direct copy is extremely fast. + try writer.publish(pixelBuffer: pixelBuffer, pts: nowNs) + writeStatus(nowNs: nowNs) + } catch { + print("[CameraSource] Error publishing frame: \(error)") + } + } + + // MARK: - Status + + private func writeStatus(nowNs: UInt64) { + let now = Date() + guard now.timeIntervalSince(lastStatusWrite) >= 1.0 else { return } + lastStatusWrite = now + + // Approximate age of the frame (usually close to 0 since we just pushed it) + 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 status = """ + { + "udid": "\(udid)", + "source": "mac-camera", + "width": \(targetWidth), + "height": \(targetHeight), + "fps": \(fps), + "framesProduced": \(writer.framesProduced), + "hostPID": \(ProcessInfo.processInfo.processIdentifier), + "startedAt": "\(ISO8601DateFormatter().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)") + } + } +} + +enum CameraError: Error, CustomStringConvertible { + case noCameraFound + case cannotAddInput + case cannotAddOutput + + 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" + } + } +} diff --git a/MiniSimCam/Sources/FrameHost/Info.plist b/MiniSimCam/Sources/FrameHost/Info.plist new file mode 100644 index 0000000..6558514 --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/Info.plist @@ -0,0 +1,8 @@ + + + + + NSCameraUsageDescription + MiniSimCam needs access to your camera to inject live video into the iOS Simulator. + + diff --git a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift index c92d36a..be94715 100644 --- a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift +++ b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift @@ -3,6 +3,7 @@ import Foundation import Darwin +import CoreVideo import MiniSimCamShared /// Writes BGRA frames into a memory-mapped triple-buffer file. @@ -129,6 +130,59 @@ final class SharedFrameWriter { _ = 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.. or --bars.") + let count = (image != nil ? 1 : 0) + (bars ? 1 : 0) + (camera ? 1 : 0) + guard count == 1 else { + throw ValidationError("Provide exactly one of: --image , --bars, or --camera.") } guard width > 0, height > 0 else { throw ValidationError("Width and height must be > 0.") } guard fps > 0, fps <= 120 else { throw ValidationError("FPS must be between 1 and 120.") } @@ -56,34 +60,42 @@ struct FrameHostCommand: ParsableCommand { try String(pid).write(toFile: pidPath, atomically: false, encoding: .utf8) defer { try? FileManager.default.removeItem(atPath: pidPath) } - // Load or generate the source frame. - let sourceName: String - let frame: BGRAFrame - - if bars { - sourceName = "color-bars" - frame = try ImageSource.colorBars(width: width, height: height) - } else { - let url = URL(fileURLWithPath: image!) - sourceName = url.lastPathComponent - frame = try ImageSource.load(url: url, targetWidth: width, targetHeight: height) - } - // Open shared memory. let writer = SharedFrameWriter(path: shmPath) try writer.open(width: width, height: height) defer { writer.close() } - // Start the frame loop. - let loop = FrameLoop( - writer: writer, - frame: frame, - fps: fps, - udid: udid, - sourceName: sourceName, - statusPath: statusPath - ) - loop.start() + // Start the appropriate source + let sourceName: String + let loop: FrameLoop? + let camSource: CameraSource? + + if camera { + sourceName = "mac-camera" + loop = nil + camSource = try CameraSource(writer: writer, width: width, height: height, fps: fps, udid: udid, statusPath: statusPath) + try camSource?.start() + } else { + let frame: BGRAFrame + if bars { + sourceName = "color-bars" + frame = try ImageSource.colorBars(width: width, height: height) + } else { + let url = URL(fileURLWithPath: image!) + sourceName = url.lastPathComponent + frame = try ImageSource.load(url: url, targetWidth: width, targetHeight: height) + } + camSource = nil + loop = FrameLoop( + writer: writer, + frame: frame, + fps: fps, + udid: udid, + sourceName: sourceName, + statusPath: statusPath + ) + loop?.start() + } print("[FrameHost] started — source=\(sourceName) \(width)×\(height) @ \(fps) fps") print("[FrameHost] shared memory: \(shmPath)") @@ -97,7 +109,8 @@ struct FrameHostCommand: ParsableCommand { let sigTerm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) sigTerm.setEventHandler { print("[FrameHost] received SIGTERM — shutting down.") - loop.stop() + loop?.stop() + camSource?.stop() Foundation.exit(0) } sigTerm.resume() @@ -105,7 +118,8 @@ struct FrameHostCommand: ParsableCommand { let sigInt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) sigInt.setEventHandler { print("[FrameHost] received SIGINT — shutting down.") - loop.stop() + loop?.stop() + camSource?.stop() Foundation.exit(0) } sigInt.resume() diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm index 743dfe2..fdea7d6 100644 --- a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm +++ b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm @@ -35,6 +35,10 @@ @implementation MSCFakeConnection 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; @@ -79,6 +83,9 @@ @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(); @@ -190,6 +197,10 @@ - (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; @@ -330,16 +341,16 @@ + (instancetype)msc_deviceInputWithDevice:(AVCaptureDevice *)device error:(NSErr return [self msc_deviceInputWithDevice:device error:outError]; } -- (instancetype)msc_initWithDevice:(AVCaptureDevice *)device error:(NSError **)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 fakeInput; + return (__bridge id)CFRetain((__bridge CFTypeRef)fakeInput); } - return [self msc_initWithDevice:device error:outError]; + return [self init_mscWithDevice:device error:outError]; } @end @@ -353,8 +364,8 @@ - (instancetype)msc_initWithDevice:(AVCaptureDevice *)device error:(NSError **)o @implementation AVCaptureVideoPreviewLayer (MiniCamHook) -- (instancetype)msc_initWithSession:(AVCaptureSession *)session { - id instance = [self msc_initWithSession:session]; +- (instancetype)init_mscWithSession:(AVCaptureSession *)session { + id instance = [self init_mscWithSession:session]; if (instance) { [gLock lock]; [gPreviewLayers addObject:instance]; @@ -362,12 +373,31 @@ - (instancetype)msc_initWithSession:(AVCaptureSession *)session { ((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)msc_initWithSessionWithNoConnection:(AVCaptureSession *)session { - id instance = [self msc_initWithSessionWithNoConnection:session]; +- (instancetype)init_mscWithSessionWithNoConnection:(AVCaptureSession *)session { + id instance = [self init_mscWithSessionWithNoConnection:session]; if (instance) { [gLock lock]; [gPreviewLayers addObject:instance]; @@ -375,6 +405,25 @@ - (instancetype)msc_initWithSessionWithNoConnection:(AVCaptureSession *)session ((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; } @@ -394,6 +443,25 @@ - (void)msc_setSession:(AVCaptureSession *)session { 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]; @@ -546,11 +614,11 @@ void MSCInstallHooks(SharedFrameReader* reader, int32_t fps) { if (m1 && m2) method_exchangeImplementations(m1, m2); Method m3 = class_getInstanceMethod(layerCls, @selector(initWithSession:)); - Method m4 = class_getInstanceMethod(layerCls, @selector(msc_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(msc_initWithSessionWithNoConnection:)); + Method m6 = class_getInstanceMethod(layerCls, @selector(init_mscWithSessionWithNoConnection:)); if (m5 && m6) method_exchangeImplementations(m5, m6); } @@ -574,7 +642,7 @@ void MSCInstallHooks(SharedFrameReader* reader, int32_t fps) { @selector(msc_deviceInputWithDevice:error:)); swizzleInstance([AVCaptureDeviceInput class], @selector(initWithDevice:error:), - @selector(msc_initWithDevice:error:)); + @selector(init_mscWithDevice:error:)); // AVCaptureDeviceDiscoverySession Class discoverySessionCls = NSClassFromString(@"AVCaptureDeviceDiscoverySession"); diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm index 5a5bbd5..652fea6 100644 --- a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm +++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm @@ -145,10 +145,7 @@ - (nullable CMSampleBufferRef)wrapPixelBuffer:(CVPixelBufferRef)pixBuf pts:(uint return nil; } - CMTime pts = CMTimeAdd( - _startCMTime, - CMTimeMakeWithSeconds((double)ptsNs / 1e9, 90000) - ); + CMTime pts = CMTimeMake(ptsNs, 1000000000); CMSampleTimingInfo timing = { .duration = _frameDuration, diff --git a/cmd/cam.go b/cmd/cam.go index 6257ced..dcc9495 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -103,6 +103,7 @@ var camBuildCmd = &cobra.Command{ var ( camStartImage string camStartBars bool + camStartCamera bool camStartWidth int camStartHeight int camStartFPS int @@ -122,8 +123,8 @@ Examples: if runtime.GOOS != DarwinOS { return fmt.Errorf("cam start is only supported on macOS") } - if camStartImage == "" && !camStartBars { - return fmt.Errorf("provide --image or --bars") + if camStartImage == "" && !camStartBars && !camStartCamera { + return fmt.Errorf("provide --image , --bars, or --camera") } // Resolve UDID. @@ -149,6 +150,8 @@ Examples: } if camStartBars { hostArgs = append(hostArgs, "--bars") + } else if camStartCamera { + hostArgs = append(hostArgs, "--camera") } else { absImage, err := filepath.Abs(camStartImage) if err != nil { @@ -160,8 +163,11 @@ Examples: c := exec.Command(bin, hostArgs...) c.Stdout = os.Stdout c.Stderr = os.Stderr - // Detach from the terminal so FrameHost survives terminal close. - c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + // Detach from the terminal so FrameHost survives terminal close, + // EXCEPT when using the camera, as detaching breaks TCC (camera) permission inheritance. + if !camStartCamera { + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + } if err := c.Start(); err != nil { return fmt.Errorf("failed to start FrameHost: %w", err) } @@ -172,6 +178,8 @@ Examples: source := camStartImage if camStartBars { source = "color-bars" + } else if camStartCamera { + source = "mac-camera" } PrintSuccess(fmt.Sprintf( "FrameHost started — source=%s %dx%d @ %d fps (simulator %s)", @@ -403,6 +411,7 @@ func init() { // 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().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)") From d8051a0bf8df14c31e4ccd73d2b2c2838286586f Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 13:03:48 +0700 Subject: [PATCH 03/10] feat: add cam discovery --- .../Sources/FrameHost/CameraDiscovery.swift | 146 +++++ .../Sources/FrameHost/CameraSource.swift | 512 +++++++++++++++--- MiniSimCam/Sources/FrameHost/Info.plist | 2 + MiniSimCam/Sources/FrameHost/main.swift | 54 +- .../MiniCamInject/SampleBufferFactory.mm | 6 +- .../MiniCamInject/SharedFrameReader.cpp | 17 +- .../MiniCamInject/SharedFrameReader.hpp | 5 +- cmd/cam.go | 245 +++++++-- cmd/cam_test.go | 19 + 9 files changed, 883 insertions(+), 123 deletions(-) create mode 100644 MiniSimCam/Sources/FrameHost/CameraDiscovery.swift diff --git a/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift new file mode 100644 index 0000000..c59a7ce --- /dev/null +++ b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift @@ -0,0 +1,146 @@ +// CameraDiscovery.swift +// Enumerates all available video capture devices (built-in, Continuity Camera, external) +// using AVCaptureDevice.DiscoverySession, and provides fuzzy name/ID matching. + +import Foundation +import AVFoundation + +// MARK: - CameraInfo + +struct CameraInfo { + let uniqueID: String + let localizedName: String + let deviceType: AVCaptureDevice.DeviceType + let device: AVCaptureDevice + + var typeLabel: String { + switch deviceType { + case .builtInWideAngleCamera: return "Built-in" + default: + if #available(macOS 14.0, *), deviceType == .continuityCamera { + return "Continuity Camera" + } + return "External" + } + } +} + +// MARK: - CameraDiscovery + +enum CameraDiscovery { + + // Device types to discover. continuityCamera requires NSCameraUseContinuityCameraDeviceType + // in Info.plist (macOS 14+). externalUnknown catches USB webcams. + private static var deviceTypes: [AVCaptureDevice.DeviceType] { + var types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .externalUnknown, + ] + if #available(macOS 14.0, *) { + types.append(.continuityCamera) + } + return types + } + + // MARK: - Enumerate + + /// Returns all discovered video devices. + static func allDevices() -> [CameraInfo] { + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: deviceTypes, + mediaType: .video, + position: .unspecified + ) + return session.devices.map { device in + CameraInfo( + uniqueID: device.uniqueID, + localizedName: device.localizedName, + deviceType: device.deviceType, + device: device + ) + } + } + + // MARK: - Find + + /// Find a device by exact uniqueID or case-insensitive substring of localizedName. + /// - Returns: the matching `AVCaptureDevice`, or nil if not found. + /// - Throws: `CameraError.ambiguousDevice` if multiple name matches exist. + static func findDevice(byNameOrID query: String) throws -> AVCaptureDevice? { + let all = allDevices() + + // 1. Exact uniqueID match takes absolute priority. + if let exact = all.first(where: { $0.uniqueID == query }) { + return exact.device + } + + // 2. Case-insensitive substring match on localizedName. + let needle = query.lowercased() + let matches = all.filter { $0.localizedName.lowercased().contains(needle) } + + switch matches.count { + case 0: return nil + case 1: return matches[0].device + default: + let names = matches.map { "\"\($0.localizedName)\"" }.joined(separator: ", ") + throw CameraError.ambiguousDevice("'\(query)' matches multiple cameras: \(names). Use a more specific name or the exact uniqueID (from 'sim cam list').") + } + } + + // MARK: - Print Table + + /// Prints a formatted table of all discovered cameras to stdout. + static func printTable() { + let all = allDevices() + + if all.isEmpty { + print("[CameraDiscovery] No video devices found.") + return + } + + // Helper: left-pad a string to exactly `width` characters. + func lpad(_ s: String, _ width: Int) -> String { + if s.count >= width { return String(s.prefix(width)) } + return s + String(repeating: " ", count: width - s.count) + } + + // Column widths (minimum enforced). + let idColW = max(12, all.map { $0.uniqueID.count }.max() ?? 12) + let nameColW = max(24, all.map { $0.localizedName.count }.max() ?? 24) + let typeColW = max(18, all.map { $0.typeLabel.count }.max() ?? 18) + let numColW = 3 + + // Total inner width: " # Name Type UniqueID " + separators + let innerW = numColW + 2 + nameColW + 2 + typeColW + 2 + idColW + 4 + let border = String(repeating: "─", count: innerW) + + let header = "│ Available Cameras" + + String(repeating: " ", count: max(0, innerW - 18)) + "│" + let colHeader = "│ " + + lpad("#", numColW) + " " + + lpad("Name", nameColW) + " " + + lpad("Type", typeColW) + " " + + lpad("UniqueID", idColW) + " │" + + print("┌\(border)┐") + print(header) + print("├\(border)┤") + print(colHeader) + print("├\(border)┤") + + for (i, cam) in all.enumerated() { + let displayID = cam.uniqueID.count > idColW + ? String(cam.uniqueID.prefix(idColW - 1)) + "…" + : cam.uniqueID + let row = "│ " + + lpad("\(i + 1)", numColW) + " " + + lpad(cam.localizedName, nameColW) + " " + + lpad(cam.typeLabel, typeColW) + " " + + lpad(displayID, idColW) + " │" + print(row) + } + + print("└\(border)┘") + print("\nUse: sim cam start --camera --camera-id \"\"") + } +} diff --git a/MiniSimCam/Sources/FrameHost/CameraSource.swift b/MiniSimCam/Sources/FrameHost/CameraSource.swift index 61f284c..2b50c19 100644 --- a/MiniSimCam/Sources/FrameHost/CameraSource.swift +++ b/MiniSimCam/Sources/FrameHost/CameraSource.swift @@ -1,23 +1,73 @@ // 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") - - // Status tracking + /// 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 = { @@ -25,136 +75,456 @@ final class CameraSource: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate mach_timebase_info(&info) return info }() - - init(writer: SharedFrameWriter, width: Int, height: Int, fps: Int, udid: String, statusPath: String) { + + // 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 { - session.beginConfiguration() - - // Ensure we try to match the requested resolution if possible - if session.canSetSessionPreset(.hd1280x720) { - session.sessionPreset = .hd1280x720 - } else { - session.sessionPreset = .high + 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 } - - let input = try AVCaptureDeviceInput(device: device) - if session.canAddInput(input) { - session.addInput(input) - } else { - throw CameraError.cannotAddInput + 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 output = AVCaptureVideoDataOutput() - output.alwaysDiscardsLateVideoFrames = true - // Ask for BGRA so we don't have to convert - output.videoSettings = [ + + 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) ] - - output.setSampleBufferDelegate(self, queue: captureQueue) - if session.canAddOutput(output) { - session.addOutput(output) - } else { - throw CameraError.cannotAddOutput - } - - // Attempt to set frame rate + 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)") + print("[CameraSource] Warning: could not set framerate to \(fps): \(error)") } - - session.commitConfiguration() - session.startRunning() + } - - func stop() { + + // 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() - // Clear status - try? FileManager.default.removeItem(atPath: statusPath) + writeStatusDisconnected() } - - // 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) - + + 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 { - // Note: If the camera's resolution doesn't match targetWidth/targetHeight perfectly, - // we should technically resize it. For zero-copy, we assume the writer handles max bounds, - // or we just memcpy what we can. SharedFrameWriter's publish does `min(dataSize, frameSize)`. - // In a production environment, you'd use a CIContext or vImage to scale the CVPixelBuffer first - // if dimensions mismatch, but for this POC, direct copy is extremely fast. - try writer.publish(pixelBuffer: pixelBuffer, pts: nowNs) - writeStatus(nowNs: nowNs) + 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] Error publishing frame: \(error)") + 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 - - // Approximate age of the frame (usually close to 0 since we just pushed it) + 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": "mac-camera", + "source": "\(metadata.name)", + "cameraName": "\(metadata.name)", + "cameraType": "\(metadata.type)", "width": \(targetWidth), "height": \(targetHeight), "fps": \(fps), "framesProduced": \(writer.framesProduced), "hostPID": \(ProcessInfo.processInfo.processIdentifier), - "startedAt": "\(ISO8601DateFormatter().string(from: startedAt))", + "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 .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/Info.plist b/MiniSimCam/Sources/FrameHost/Info.plist index 6558514..9b96054 100644 --- a/MiniSimCam/Sources/FrameHost/Info.plist +++ b/MiniSimCam/Sources/FrameHost/Info.plist @@ -4,5 +4,7 @@ NSCameraUsageDescription MiniSimCam needs access to your camera to inject live video into the iOS Simulator. + NSCameraUseContinuityCameraDeviceType + diff --git a/MiniSimCam/Sources/FrameHost/main.swift b/MiniSimCam/Sources/FrameHost/main.swift index 4646192..b9da369 100644 --- a/MiniSimCam/Sources/FrameHost/main.swift +++ b/MiniSimCam/Sources/FrameHost/main.swift @@ -16,7 +16,7 @@ struct FrameHostCommand: ParsableCommand { // MARK: - Arguments @Option(name: .long, help: "Simulator UDID (used to derive shared-memory and status file paths).") - var udid: String + var udid: String = "" @Option(name: .long, help: "Path to a PNG or JPEG source image.") var image: String? @@ -27,6 +27,15 @@ struct FrameHostCommand: ParsableCommand { @Flag(name: .long, help: "Use the Mac's physical camera as a live source.") var camera: Bool = false + @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 + @Option(name: .long, help: "Output frame width in pixels.") var width: Int = 1280 @@ -39,22 +48,44 @@ struct FrameHostCommand: ParsableCommand { // MARK: - Validation func validate() throws { + // --list-cameras is a standalone mode — skip all other checks. + if listCameras { return } + + // Require exactly one source. let count = (image != nil ? 1 : 0) + (bars ? 1 : 0) + (camera ? 1 : 0) guard count == 1 else { throw ValidationError("Provide exactly one of: --image , --bars, or --camera.") } + // --camera-id only makes sense with --camera. + if cameraID != nil && !camera { + throw ValidationError("--camera-id requires --camera.") + } guard width > 0, height > 0 else { throw ValidationError("Width and height must be > 0.") } + let maxDimension = Int(MSC_MAX_DIMENSION) + guard width <= maxDimension, height <= maxDimension else { + throw ValidationError("Width and height must not exceed \(maxDimension).") + } guard fps > 0, fps <= 120 else { throw ValidationError("FPS must be between 1 and 120.") } + guard !listCameras else { return } guard udid.count >= 8 else { throw ValidationError("UDID looks invalid: \(udid)") } } // MARK: - Run func run() throws { + // Handle --list-cameras first — no UDID, shared memory, or frame loop needed. + if listCameras { + CameraDiscovery.printTable() + return + } + let shmPath = "/tmp/minisimcam.\(udid).frames" let statusPath = "/tmp/minisimcam.\(udid).status" let pidPath = "/tmp/minisimcam.\(udid).pid" + // Never let a previous crashed host satisfy the CLI's readiness check. + try? FileManager.default.removeItem(atPath: statusPath) + // Write PID file so `sim cam stop` can signal this process. let pid = ProcessInfo.processInfo.processIdentifier try String(pid).write(toFile: pidPath, atomically: false, encoding: .utf8) @@ -65,15 +96,24 @@ struct FrameHostCommand: ParsableCommand { try writer.open(width: width, height: height) defer { writer.close() } - // Start the appropriate source + // Start the appropriate source. let sourceName: String let loop: FrameLoop? let camSource: CameraSource? if camera { - sourceName = "mac-camera" + sourceName = cameraID ?? "mac-camera" loop = nil - camSource = try CameraSource(writer: writer, width: width, height: height, fps: fps, udid: udid, statusPath: statusPath) + camSource = CameraSource( + writer: writer, + width: width, + height: height, + fps: fps, + udid: udid, + statusPath: statusPath, + cameraID: cameraID, + scaleMode: scaleMode + ) try camSource?.start() } else { let frame: BGRAFrame @@ -131,3 +171,9 @@ struct FrameHostCommand: ParsableCommand { // Top-level entry — required when file is named main.swift (cannot use @main). FrameHostCommand.main() + +// MARK: - ArgumentParser conformance + +extension ScaleMode: ExpressibleByArgument { + init?(argument: String) { self.init(rawValue: argument) } +} diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm index 652fea6..5e52abe 100644 --- a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm +++ b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm @@ -114,13 +114,15 @@ - (nullable CMSampleBufferRef)sampleBufferFromReader:(SharedFrameReader *)reader return nil; } - // Single copy: shm → CVPixelBuffer (no intermediate vector). + // 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, dstTotal); + FrameInfo info = reader->copyLatestFrameInto(dst, dstBPR, dstTotal); CVPixelBufferUnlockBaseAddress(pixBuf, 0); if (!info.valid) { diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp index 313e409..5a31c92 100644 --- a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp +++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp @@ -156,7 +156,7 @@ bool SharedFrameReader::isProducerStale() const { return (monoNs() - hdr->presentationTimeNs) > MSC_STALE_THRESHOLD_NS; } -FrameInfo SharedFrameReader::copyLatestFrameInto(void* dst, size_t dstSize) { +FrameInfo SharedFrameReader::copyLatestFrameInto(void* dst, size_t dstBytesPerRow, size_t dstSize) { FrameInfo info; if (!mapping_ || !dst) return info; @@ -168,7 +168,7 @@ FrameInfo SharedFrameReader::copyLatestFrameInto(void* dst, size_t dstSize) { const uint32_t w = hdr->width; const uint32_t h = hdr->height; - if (dstSize < bufSize) return info; // Caller buffer too small. + if (dstBytesPerRow < bpr || dstSize < dstBytesPerRow * h) return info; auto* seqAtomic = atomicAt(mapping_, kOffSequence); auto* idxAtomic = atomicAt(mapping_, kOffPublishedIndex); @@ -188,9 +188,16 @@ FrameInfo SharedFrameReader::copyLatestFrameInto(void* dst, size_t dstSize) { if (idx >= MSC_BUFFER_COUNT) break; - const void* src = msc_frame_ptr(mapping_, bufSize, idx); - // Single copy: mmap → caller's buffer (typically a CVPixelBuffer). - std::memcpy(dst, src, bufSize); + 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. diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp index 7bdf366..92814ff 100644 --- a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp +++ b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp @@ -60,10 +60,11 @@ class SharedFrameReader { FrameSnapshot copyLatestFrame(); /// Zero-copy path: copy the latest frame directly into `dst` (caller-allocated). - /// `dstSize` must be >= bytesPerRow * height from the shm header. + /// `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 dstSize); + 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; diff --git a/cmd/cam.go b/cmd/cam.go index dcc9495..0dfc4d0 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -41,12 +41,72 @@ func miniSimCamDir() string { return filepath.Join(cwd, "MiniSimCam") } -func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } -func statusFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.status", udid) } -func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } -func frameHostBin(mscDir string) string { return filepath.Join(mscDir, ".build", "release", "FrameHost") } -func injectorDylib(mscDir string) string { return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") } -func buildScript(mscDir string) string { return filepath.Join(mscDir, "Scripts", "build.sh") } +func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } +func statusFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.status", udid) } +func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } +func frameHostBin(mscDir string) string { + return filepath.Join(mscDir, ".build", "release", "FrameHost") +} +func injectorDylib(mscDir string) string { + return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") +} +func buildScript(mscDir string) string { return filepath.Join(mscDir, "Scripts", "build.sh") } + +func findRunningIOSSimulator(deviceID string) (udid, name string, err error) { + udid, name, isAndroid, err := FindRunningDevice(deviceID) + if err != nil || udid == "" { + return "", "", err + } + if isAndroid { + return "", "", fmt.Errorf("MiniSimCam only supports booted iOS Simulators, not Android emulators") + } + return udid, name, nil +} + +// waitForFrameHostReady rejects immediate FrameHost failures (for example an +// invalid camera ID) instead of printing a misleading successful start. +func waitForFrameHostReady(c *exec.Cmd, statusPath string) error { + exited := make(chan error, 1) + go func() { exited <- c.Wait() }() + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case err := <-exited: + if err == nil { + return fmt.Errorf("FrameHost exited during startup") + } + return fmt.Errorf("FrameHost failed during startup: %w", err) + case <-ticker.C: + data, err := os.ReadFile(statusPath) + if err != nil { + continue + } + var status camFrameLoopStatus + if json.Unmarshal(data, &status) == nil && status.HostPID == int32(c.Process.Pid) { + return nil + } + case <-deadline.C: + return fmt.Errorf("FrameHost did not become ready within 5 seconds") + } + } +} + +func frameHostFPS(udid string) int { + data, err := os.ReadFile(statusFilePath(udid)) + if err != nil { + return DefaultCamFPS + } + var status camFrameLoopStatus + if err := json.Unmarshal(data, &status); err != nil || status.FPS < 1 || status.FPS > 120 { + return DefaultCamFPS + } + return status.FPS +} // --------------------------------------------------------------------------- // cam command group @@ -101,13 +161,15 @@ var camBuildCmd = &cobra.Command{ // --------------------------------------------------------------------------- var ( - camStartImage string - camStartBars bool - camStartCamera bool - camStartWidth int - camStartHeight int - camStartFPS int - camStartDevice string + camStartImage string + camStartBars bool + camStartCamera bool + camStartCameraID string + camStartScaleMode string + camStartWidth int + camStartHeight int + camStartFPS int + camStartDevice string ) var camStartCmd = &cobra.Command{ @@ -118,17 +180,42 @@ var camStartCmd = &cobra.Command{ Examples: sim cam start --image ./test-card.png sim cam start --bars --width 1920 --height 1080 --fps 60 + sim cam start --camera + sim cam start --camera --camera-id "iPhone" sim cam start --image qr.png --device `, + RunE: func(cmd *cobra.Command, args []string) error { if runtime.GOOS != DarwinOS { return fmt.Errorf("cam start is only supported on macOS") } - if camStartImage == "" && !camStartBars && !camStartCamera { - return fmt.Errorf("provide --image , --bars, or --camera") + sourceCount := 0 + if camStartImage != "" { + sourceCount++ + } + if camStartBars { + sourceCount++ + } + if camStartCamera { + sourceCount++ + } + if sourceCount != 1 { + return fmt.Errorf("provide exactly one of --image , --bars, or --camera") + } + if camStartWidth <= 0 || camStartHeight <= 0 || camStartWidth > 3840 || camStartHeight > 3840 { + return fmt.Errorf("--width and --height must be between 1 and 3840") + } + if camStartFPS < 1 || camStartFPS > 120 { + return fmt.Errorf("--fps must be between 1 and 120") + } + if camStartCameraID != "" && !camStartCamera { + return fmt.Errorf("--camera-id requires --camera") + } + if cmd.Flags().Changed("scale-mode") && !camStartCamera { + return fmt.Errorf("--scale-mode requires --camera") } // Resolve UDID. - udid, _, _, err := FindRunningDevice(camStartDevice) + udid, _, err := findRunningIOSSimulator(camStartDevice) if err != nil || udid == "" { return fmt.Errorf("no booted iOS simulator found — boot one first (%w)", err) } @@ -140,7 +227,10 @@ Examples: } // Kill any existing FrameHost for this UDID. - _ = stopFrameHost(udid) + if err := stopFrameHost(udid); err != nil { + return err + } + _ = os.Remove(statusFilePath(udid)) hostArgs := []string{ "--udid", udid, @@ -152,6 +242,12 @@ Examples: hostArgs = append(hostArgs, "--bars") } else if camStartCamera { hostArgs = append(hostArgs, "--camera") + if camStartCameraID != "" { + hostArgs = append(hostArgs, "--camera-id", camStartCameraID) + } + if camStartScaleMode != "" { + hostArgs = append(hostArgs, "--scale-mode", camStartScaleMode) + } } else { absImage, err := filepath.Abs(camStartImage) if err != nil { @@ -172,14 +268,19 @@ Examples: return fmt.Errorf("failed to start FrameHost: %w", err) } - // Give it a moment to write the PID file before returning. - time.Sleep(500 * time.Millisecond) + if err := waitForFrameHostReady(c, statusFilePath(udid)); err != nil { + return err + } source := camStartImage if camStartBars { source = "color-bars" } else if camStartCamera { - source = "mac-camera" + if camStartCameraID != "" { + source = camStartCameraID + } else { + source = "mac-camera (default)" + } } PrintSuccess(fmt.Sprintf( "FrameHost started — source=%s %dx%d @ %d fps (simulator %s)", @@ -189,6 +290,36 @@ Examples: }, } +// --------------------------------------------------------------------------- +// sim cam list +// --------------------------------------------------------------------------- + +var camListCmd = &cobra.Command{ + Use: "list", + Short: "List all available cameras on this Mac", + Long: `Enumerate video capture devices available to FrameHost. +Includes built-in FaceTime camera, Continuity Camera (iPhone/iPad), and external USB cameras. + +Requires FrameHost to be built first (sim cam build). + +Example: + sim cam list`, + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam list is only supported on macOS") + } + mscDir := miniSimCamDir() + bin := frameHostBin(mscDir) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("FrameHost not built — run 'sim cam build' first") + } + c := exec.Command(bin, "--list-cameras", "--udid", "00000000-0000-0000-0000-000000000000") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + }, +} + // --------------------------------------------------------------------------- // sim cam launch // --------------------------------------------------------------------------- @@ -216,7 +347,7 @@ Example: return fmt.Errorf("--bundle-id is required") } - udid, _, _, err := FindRunningDevice(camLaunchDevice) + udid, _, err := findRunningIOSSimulator(camLaunchDevice) if err != nil || udid == "" { return fmt.Errorf("no booted iOS simulator found (%w)", err) } @@ -228,10 +359,12 @@ Example: } shm := shmPath(udid) + fps := frameHostFPS(udid) PrintInfo(fmt.Sprintf("Launching %s on %s", camLaunchBundle, udid)) PrintInfo(fmt.Sprintf(" dylib: %s", dylib)) PrintInfo(fmt.Sprintf(" shm: %s", shm)) + PrintInfo(fmt.Sprintf(" fps: %d", fps)) // Ensure get-task-allow entitlement is present so DYLD_INSERT_LIBRARIES works. appPathBytes, err := exec.Command("xcrun", "simctl", "get_app_container", udid, camLaunchBundle, "app").Output() @@ -239,7 +372,7 @@ Example: appPath := strings.TrimSpace(string(appPathBytes)) entOut, _ := exec.Command("codesign", "-d", "--entitlements", ":-", appPath).Output() entXML := string(entOut) - + if !strings.Contains(entXML, "com.apple.security.get-task-allow") { PrintInfo(" Injecting get-task-allow entitlement to permit dylib injection...") if strings.Contains(entXML, "") { @@ -262,6 +395,7 @@ Example: c.Env = append(os.Environ(), "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib, "SIMCTL_CHILD_MINISIMCAM_PATH="+shm, + "SIMCTL_CHILD_MINISIMCAM_FPS="+strconv.Itoa(fps), ) c.Stdout = os.Stdout c.Stderr = os.Stderr @@ -280,18 +414,22 @@ Example: var camStatusDevice string -// camFrameLoopStatus mirrors FrameLoopStatus defined in FrameLoop.swift. +// camFrameLoopStatus mirrors FrameLoopStatus defined in FrameLoop.swift and +// the richer status written by CameraSource. type camFrameLoopStatus struct { - UDID string `json:"udid"` - Source string `json:"source"` - Width int `json:"width"` - Height int `json:"height"` - FPS int `json:"fps"` - FramesProduced uint64 `json:"framesProduced"` - HostPID int32 `json:"hostPID"` - StartedAt string `json:"startedAt"` - LastFrameAgeMs float64 `json:"lastFrameAgeMs"` - Running bool `json:"running"` + UDID string `json:"udid"` + Source string `json:"source"` + CameraName string `json:"cameraName,omitempty"` + CameraType string `json:"cameraType,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + FPS int `json:"fps"` + FramesProduced uint64 `json:"framesProduced"` + HostPID int32 `json:"hostPID"` + StartedAt string `json:"startedAt"` + LastFrameAgeMs float64 `json:"lastFrameAgeMs"` + LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` + Running bool `json:"running"` } var camStatusCmd = &cobra.Command{ @@ -302,7 +440,7 @@ var camStatusCmd = &cobra.Command{ return fmt.Errorf("cam status is only supported on macOS") } - udid, name, _, err := FindRunningDevice(camStatusDevice) + udid, name, err := findRunningIOSSimulator(camStatusDevice) if err != nil || udid == "" { return fmt.Errorf("no booted iOS simulator found (%w)", err) } @@ -323,6 +461,17 @@ var camStatusCmd = &cobra.Command{ lines := []string{ fmt.Sprintf("Simulator: %s (%s)", name, udid), fmt.Sprintf("Source: %s", st.Source), + } + if st.CameraName != "" { + lines = append(lines, fmt.Sprintf("Camera: %s", st.CameraName)) + } + if st.CameraType != "" { + lines = append(lines, fmt.Sprintf("Camera type: %s", st.CameraType)) + } + if st.Source == "disconnected" && st.LastDisconnectedAt != "" { + lines = append(lines, fmt.Sprintf("⚠️ Disconnected at: %s", st.LastDisconnectedAt)) + } + lines = append(lines, fmt.Sprintf("Resolution: %dx%d BGRA", st.Width, st.Height), fmt.Sprintf("Frame rate: %d fps", st.FPS), fmt.Sprintf("Frames produced: %d", st.FramesProduced), @@ -330,7 +479,7 @@ var camStatusCmd = &cobra.Command{ fmt.Sprintf("Host PID: %d", st.HostPID), fmt.Sprintf("Started at: %s", st.StartedAt), fmt.Sprintf("Running: %v", st.Running), - } + ) width := 0 for _, l := range lines { @@ -365,7 +514,7 @@ var camStopCmd = &cobra.Command{ return fmt.Errorf("cam stop is only supported on macOS") } - udid, _, _, err := FindRunningDevice(camStopDevice) + udid, _, err := findRunningIOSSimulator(camStopDevice) if err != nil || udid == "" { return fmt.Errorf("no booted iOS simulator found (%w)", err) } @@ -378,7 +527,8 @@ var camStopCmd = &cobra.Command{ }, } -// stopFrameHost reads the PID file and sends SIGTERM. +// stopFrameHost verifies that the PID still belongs to this FrameHost before +// signalling it. A stale PID file must never interrupt an unrelated process. func stopFrameHost(udid string) error { pidPath := pidFilePath(udid) data, err := os.ReadFile(pidPath) @@ -389,11 +539,23 @@ func stopFrameHost(udid string) error { if err != nil { return fmt.Errorf("invalid PID in %s: %w", pidPath, err) } - proc, err := os.FindProcess(pid) + command, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output() if err != nil { + _ = os.Remove(pidPath) + return nil + } + commandLine := string(command) + if !strings.Contains(commandLine, "FrameHost") || !strings.Contains(commandLine, "--udid "+udid) { + _ = os.Remove(pidPath) return nil } - _ = proc.Signal(os.Interrupt) // SIGINT — triggers clean shutdown. + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("cannot find FrameHost process %d: %w", pid, err) + } + if err := proc.Signal(os.Interrupt); err != nil { + return fmt.Errorf("cannot stop FrameHost process %d: %w", pid, err) + } return nil } @@ -412,12 +574,17 @@ func init() { camStartCmd.Flags().StringVar(&camStartImage, "image", "", "Path to PNG or JPEG source image") camStartCmd.Flags().BoolVar(&camStartBars, "bars", false, "Use synthetic SMPTE color-bar image") camStartCmd.Flags().BoolVar(&camStartCamera, "camera", false, "Use the Mac's physical camera as a live source") + camStartCmd.Flags().StringVar(&camStartCameraID, "camera-id", "", "Camera name (substring) or uniqueID to select (requires --camera)") + camStartCmd.Flags().StringVar(&camStartScaleMode, "scale-mode", "", "How to scale: 'fill' (crop, fast) or 'fit' (letterbox, no crop). Requires --camera. (default: fill)") camStartCmd.Flags().IntVar(&camStartWidth, "width", DefaultCamWidth, "Frame width in pixels") camStartCmd.Flags().IntVar(&camStartHeight, "height", DefaultCamHeight, "Frame height in pixels") camStartCmd.Flags().IntVar(&camStartFPS, "fps", DefaultCamFPS, "Frames per second (1-120)") camStartCmd.Flags().StringVar(&camStartDevice, "device", "", "Simulator name or UDID (default: booted)") camCmd.AddCommand(camStartCmd) + // list + camCmd.AddCommand(camListCmd) + // launch camLaunchCmd.Flags().StringVar(&camLaunchBundle, "bundle-id", "", "App bundle identifier (required)") camLaunchCmd.Flags().StringVar(&camLaunchDevice, "device", "", "Simulator name or UDID (default: booted)") diff --git a/cmd/cam_test.go b/cmd/cam_test.go index 4b76e7b..c348d8e 100644 --- a/cmd/cam_test.go +++ b/cmd/cam_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "os" "path/filepath" "testing" ) @@ -64,6 +65,24 @@ func TestCamStatusParsing(t *testing.T) { } } +func TestFrameHostFPS(t *testing.T) { + udid := "frame-host-fps-test" + path := statusFilePath(udid) + t.Cleanup(func() { _ = os.Remove(path) }) + + status := camFrameLoopStatus{FPS: 60} + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("marshal status: %v", err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("write status: %v", err) + } + if got := frameHostFPS(udid); got != 60 { + t.Errorf("frameHostFPS() = %d, want 60", got) + } +} + // TestStopFrameHostNoFile verifies stopFrameHost returns nil when no PID file exists. func TestStopFrameHostNoFile(t *testing.T) { // Use a non-existent UDID — no PID file should exist. From bdb3504a1cf673054eac3c7b535ab073ea8b8527 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 13:46:26 +0700 Subject: [PATCH 04/10] feat: embed binary --- .gitignore | 1 + Makefile | 19 +++++++++-- MiniSimCam/Scripts/build.sh | 16 +++++++-- cmd/cam.go | 36 ++++++++++++++++++-- cmd/embed_cam.go | 66 +++++++++++++++++++++++++++++++++++++ cmd/embed_stub.go | 9 +++++ 6 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 cmd/embed_cam.go create mode 100644 cmd/embed_stub.go diff --git a/.gitignore b/.gitignore index 16c5856..adb7f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ MiniSimCam/Example/CameraPreviewApp/build/ MiniSimCam/Example/CameraPreviewApp/module_cache/ MiniSimCam/module_cache/ module_cache/ +cmd/assets/ # Xcode user state files (never commit) **/*.xcuserstate diff --git a/Makefile b/Makefile index 34c754b..fc5e11b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean install test test-race test-coverage lint fmt vet check help +.PHONY: build build-all clean install test test-race test-coverage lint fmt vet check help cam-build cam-clean # Default target all: build @@ -7,15 +7,28 @@ all: build VERSION := $(shell grep 'version:' config.yaml | awk '{print $$2}' | tr -d '"') LDFLAGS := -ldflags "-X github.com/annurdien/sim-cli/cmd.Version=$(VERSION)" -# Build the application +UNAME_S := $(shell uname -s) + +# Build the application (with embedded cam binaries on macOS) build: +ifeq ($(UNAME_S),Darwin) + cd MiniSimCam && ./Scripts/build.sh + go build -tags cam_embed $(LDFLAGS) -o sim +else go build $(LDFLAGS) -o sim +endif # Build for multiple platforms build-all: mkdir -p dist +ifeq ($(UNAME_S),Darwin) + cd MiniSimCam && ./Scripts/build.sh + GOOS=darwin GOARCH=amd64 go build -tags cam_embed $(LDFLAGS) -o dist/sim-darwin-amd64 + GOOS=darwin GOARCH=arm64 go build -tags cam_embed $(LDFLAGS) -o dist/sim-darwin-arm64 +else GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-darwin-amd64 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/sim-darwin-arm64 +endif GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-linux-amd64 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/sim-windows-amd64.exe @@ -23,6 +36,7 @@ build-all: clean: rm -f sim rm -rf dist/ + rm -rf cmd/assets/ rm -f coverage.out coverage.html # Build MiniSimCam (FrameHost + MiniCamInject dylib) @@ -33,6 +47,7 @@ cam-build: cam-clean: cd MiniSimCam && swift package clean rm -rf MiniSimCam/.build/injector + rm -rf cmd/assets/ # Install dependencies deps: diff --git a/MiniSimCam/Scripts/build.sh b/MiniSimCam/Scripts/build.sh index 34110f9..0eab3bf 100755 --- a/MiniSimCam/Scripts/build.sh +++ b/MiniSimCam/Scripts/build.sh @@ -4,7 +4,14 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="${1:-"$SCRIPT_DIR/.."}" +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" @@ -106,7 +113,12 @@ lipo -create \ "${BUILD_DIR}/MiniCamInject_x86_64.dylib" \ -output "${BUILD_DIR}/MiniCamInject.dylib" -echo " OK MiniCamInject.dylib -> ${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 "==================================================" diff --git a/cmd/cam.go b/cmd/cam.go index 0dfc4d0..cb91877 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -9,6 +9,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -44,10 +45,39 @@ func miniSimCamDir() string { func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } func statusFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.status", udid) } func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } +// resolveEmbeddedBinDir returns the directory containing extracted embedded +// binaries, or "" if the embedded assets are stubs (dev builds without cam). +// The result is cached after the first successful extraction. +var ( + embeddedBinDirOnce sync.Once + embeddedBinDirValue string +) + +func resolveEmbeddedBinDir() string { + embeddedBinDirOnce.Do(func() { + if binDir, err := ensureExtractedAssets(); err == nil { + embeddedBinDirValue = binDir + } + }) + return embeddedBinDirValue +} + func frameHostBin(mscDir string) string { + if binDir := resolveEmbeddedBinDir(); binDir != "" { + extracted := filepath.Join(binDir, "FrameHost") + if _, err := os.Stat(extracted); err == nil { + return extracted + } + } return filepath.Join(mscDir, ".build", "release", "FrameHost") } func injectorDylib(mscDir string) string { + if binDir := resolveEmbeddedBinDir(); binDir != "" { + extracted := filepath.Join(binDir, "MiniCamInject.dylib") + if _, err := os.Stat(extracted); err == nil { + return extracted + } + } return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") } func buildScript(mscDir string) string { return filepath.Join(mscDir, "Scripts", "build.sh") } @@ -223,7 +253,7 @@ Examples: mscDir := miniSimCamDir() bin := frameHostBin(mscDir) if _, err := os.Stat(bin); err != nil { - return fmt.Errorf("FrameHost not built — run 'sim cam build' first") + return fmt.Errorf("FrameHost not found — run 'sim cam build' first (or use a release build with embedded binaries)") } // Kill any existing FrameHost for this UDID. @@ -311,7 +341,7 @@ Example: mscDir := miniSimCamDir() bin := frameHostBin(mscDir) if _, err := os.Stat(bin); err != nil { - return fmt.Errorf("FrameHost not built — run 'sim cam build' first") + return fmt.Errorf("FrameHost not found — run 'sim cam build' first (or use a release build with embedded binaries)") } c := exec.Command(bin, "--list-cameras", "--udid", "00000000-0000-0000-0000-000000000000") c.Stdout = os.Stdout @@ -355,7 +385,7 @@ Example: mscDir := miniSimCamDir() dylib := injectorDylib(mscDir) if _, err := os.Stat(dylib); err != nil { - return fmt.Errorf("MiniCamInject.dylib not found — run 'sim cam build' first") + return fmt.Errorf("MiniCamInject.dylib not found — run 'sim cam build' first (or use a release build with embedded binaries)") } shm := shmPath(udid) diff --git a/cmd/embed_cam.go b/cmd/embed_cam.go new file mode 100644 index 0000000..a3274a1 --- /dev/null +++ b/cmd/embed_cam.go @@ -0,0 +1,66 @@ +//go:build cam_embed + +package cmd + +import ( + "crypto/sha256" + "embed" + "fmt" + "os" + "path/filepath" +) + +//go:embed assets/FrameHost assets/MiniCamInject.dylib +var embeddedAssets embed.FS + +// ensureExtractedAssets extracts embedded assets to ~/.sim-cli/bin/ if valid. +// Returns the directory containing the extracted binaries. +func ensureExtractedAssets() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + targetDir := filepath.Join(homeDir, ".sim-cli", "bin") + if err := os.MkdirAll(targetDir, 0755); err != nil { + return "", fmt.Errorf("failed to create target directory %s: %w", targetDir, err) + } + + files := []struct { + embeddedName string + fileName string + mode os.FileMode + }{ + {"assets/FrameHost", "FrameHost", 0755}, + {"assets/MiniCamInject.dylib", "MiniCamInject.dylib", 0644}, + } + + for _, f := range files { + data, err := embeddedAssets.ReadFile(f.embeddedName) + if err != nil { + return "", fmt.Errorf("failed to read embedded asset %s: %w", f.embeddedName, err) + } + + targetPath := filepath.Join(targetDir, f.fileName) + + // Check if file already exists and matches hash + if existingData, err := os.ReadFile(targetPath); err == nil { + if sha256.Sum256(existingData) == sha256.Sum256(data) { + continue // File already exists and is up to date + } + } + + // Write extracted binary + tmpPath := targetPath + ".tmp" + if err := os.WriteFile(tmpPath, data, f.mode); err != nil { + return "", fmt.Errorf("failed to write asset to %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, targetPath); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("failed to replace asset at %s: %w", targetPath, err) + } + _ = os.Chmod(targetPath, f.mode) + } + + return targetDir, nil +} diff --git a/cmd/embed_stub.go b/cmd/embed_stub.go new file mode 100644 index 0000000..4f50b2d --- /dev/null +++ b/cmd/embed_stub.go @@ -0,0 +1,9 @@ +//go:build !cam_embed + +package cmd + +// ensureExtractedAssets is a no-op when built without cam_embed tag. +// The CLI falls back to local MiniSimCam/.build/ paths. +func ensureExtractedAssets() (string, error) { + return "", nil +} From 95b4fe42a5bf87900ba105bce988d0a5aa1f68b0 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 13:51:12 +0700 Subject: [PATCH 05/10] fix: remove cam build --- cmd/cam.go | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/cmd/cam.go b/cmd/cam.go index cb91877..5cad24f 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -80,7 +80,6 @@ func injectorDylib(mscDir string) string { } return filepath.Join(mscDir, ".build", "injector", "MiniCamInject.dylib") } -func buildScript(mscDir string) string { return filepath.Join(mscDir, "Scripts", "build.sh") } func findRunningIOSSimulator(deviceID string) (udid, name string, err error) { udid, name, isAndroid, err := FindRunningDevice(deviceID) @@ -152,40 +151,12 @@ shared memory. A dylib (MiniCamInject) loaded into your simulator app reads those frames and delivers them as CMSampleBuffer callbacks. Quick start: - sim cam build sim cam start --image test.png sim cam launch --bundle-id com.example.MyApp sim cam status sim cam stop`, } -// --------------------------------------------------------------------------- -// sim cam build -// --------------------------------------------------------------------------- - -var camBuildCmd = &cobra.Command{ - Use: "build", - Short: "Build FrameHost and MiniCamInject.dylib", - Long: `Compiles the Swift FrameHost binary and the iOS Simulator injector dylib.`, - RunE: func(cmd *cobra.Command, args []string) error { - if runtime.GOOS != DarwinOS { - return fmt.Errorf("cam build is only supported on macOS") - } - mscDir := miniSimCamDir() - script := buildScript(mscDir) - if _, err := os.Stat(script); err != nil { - return fmt.Errorf("build script not found at %s — is MiniSimCam/ present?", script) - } - - return RunSpinner("Building MiniSimCam (FrameHost + MiniCamInject)…", func() error { - c := exec.Command("/bin/bash", script, mscDir) - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return c.Run() - }) - }, -} - // --------------------------------------------------------------------------- // sim cam start // --------------------------------------------------------------------------- @@ -594,11 +565,9 @@ func stopFrameHost(udid string) error { // --------------------------------------------------------------------------- func init() { - // cam-level flag: --msc-dir override + // cam-level flag: --msc-dir override (hidden for end users) camCmd.PersistentFlags().StringVar(&camMscDir, "msc-dir", "", "Path to MiniSimCam directory (default: auto-detected)") - - // build - camCmd.AddCommand(camBuildCmd) + _ = camCmd.PersistentFlags().MarkHidden("msc-dir") // start camStartCmd.Flags().StringVar(&camStartImage, "image", "", "Path to PNG or JPEG source image") From 78b6108871d259ac04f963f2821e63d04c437e7b Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 15:43:59 +0700 Subject: [PATCH 06/10] feat: add dashboard for cam --- .../contents.xcworkspacedata | 0 .../xcschemes/xcschememanagement.plist | 0 .../CameraPreviewApp/CameraPreviewApp.swift | 0 .../CameraPreviewApp/entitlements.plist | 0 MiniSimCam/Package.resolved | 0 MiniSimCam/Package.swift | 0 MiniSimCam/Shared/AtomicHelpers.c | 0 MiniSimCam/Shared/include/AtomicHelpers.h | 0 MiniSimCam/Shared/include/MiniCamConstants.h | 0 MiniSimCam/Shared/include/MiniCamProtocol.h | 0 MiniSimCam/Shared/include/module.modulemap | 0 .../Sources/FrameHost/CameraDiscovery.swift | 16 + .../Sources/FrameHost/CameraSource.swift | 0 .../FrameHost/FrameHost-Bridging-Header.h | 0 MiniSimCam/Sources/FrameHost/FrameLoop.swift | 0 .../Sources/FrameHost/ImageSource.swift | 0 MiniSimCam/Sources/FrameHost/Info.plist | 0 .../Sources/FrameHost/SharedFrameWriter.swift | 0 MiniSimCam/Sources/FrameHost/main.swift | 9 +- .../Sources/MiniCamInject/CaptureHooks.h | 0 .../Sources/MiniCamInject/CaptureHooks.mm | 0 .../Sources/MiniCamInject/EntryPoint.mm | 0 .../MiniCamInject/SampleBufferFactory.h | 0 .../MiniCamInject/SampleBufferFactory.mm | 0 .../MiniCamInject/SharedFrameReader.cpp | 0 .../MiniCamInject/SharedFrameReader.hpp | 0 .../FrameTransportTests.swift | 0 .../Tests/ProtocolTests/ProtocolTests.swift | 0 cmd/cam.go | 83 +- cmd/cam_dashboard.go | 808 ++++++++++++++++++ cmd/cam_test.go | 6 +- go.mod | 5 +- go.sum | 4 + 33 files changed, 901 insertions(+), 30 deletions(-) mode change 100644 => 100755 MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata mode change 100644 => 100755 MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist mode change 100644 => 100755 MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift mode change 100644 => 100755 MiniSimCam/Example/CameraPreviewApp/entitlements.plist mode change 100644 => 100755 MiniSimCam/Package.resolved mode change 100644 => 100755 MiniSimCam/Package.swift mode change 100644 => 100755 MiniSimCam/Shared/AtomicHelpers.c mode change 100644 => 100755 MiniSimCam/Shared/include/AtomicHelpers.h mode change 100644 => 100755 MiniSimCam/Shared/include/MiniCamConstants.h mode change 100644 => 100755 MiniSimCam/Shared/include/MiniCamProtocol.h mode change 100644 => 100755 MiniSimCam/Shared/include/module.modulemap mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/CameraDiscovery.swift mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/CameraSource.swift mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/FrameLoop.swift mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/ImageSource.swift mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/Info.plist mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift mode change 100644 => 100755 MiniSimCam/Sources/FrameHost/main.swift mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/CaptureHooks.h mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/EntryPoint.mm mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp mode change 100644 => 100755 MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp mode change 100644 => 100755 MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift mode change 100644 => 100755 MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift create mode 100644 cmd/cam_dashboard.go diff --git a/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/MiniSimCam/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata old mode 100644 new mode 100755 diff --git a/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist b/MiniSimCam/.swiftpm/xcode/xcuserdata/annurdien.xcuserdatad/xcschemes/xcschememanagement.plist old mode 100644 new mode 100755 diff --git a/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift b/MiniSimCam/Example/CameraPreviewApp/CameraPreviewApp.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Example/CameraPreviewApp/entitlements.plist b/MiniSimCam/Example/CameraPreviewApp/entitlements.plist old mode 100644 new mode 100755 diff --git a/MiniSimCam/Package.resolved b/MiniSimCam/Package.resolved old mode 100644 new mode 100755 diff --git a/MiniSimCam/Package.swift b/MiniSimCam/Package.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Shared/AtomicHelpers.c b/MiniSimCam/Shared/AtomicHelpers.c old mode 100644 new mode 100755 diff --git a/MiniSimCam/Shared/include/AtomicHelpers.h b/MiniSimCam/Shared/include/AtomicHelpers.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Shared/include/MiniCamConstants.h b/MiniSimCam/Shared/include/MiniCamConstants.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Shared/include/MiniCamProtocol.h b/MiniSimCam/Shared/include/MiniCamProtocol.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Shared/include/module.modulemap b/MiniSimCam/Shared/include/module.modulemap old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift old mode 100644 new mode 100755 index c59a7ce..74bb9e1 --- a/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift +++ b/MiniSimCam/Sources/FrameHost/CameraDiscovery.swift @@ -143,4 +143,20 @@ enum CameraDiscovery { print("└\(border)┘") print("\nUse: sim cam start --camera --camera-id \"\"") } + + /// Prints a JSON array of all discovered cameras to stdout. + static func printJSON() { + let all = allDevices() + let mapped = all.map { [ + "uniqueID": $0.uniqueID, + "localizedName": $0.localizedName, + "typeLabel": $0.typeLabel + ] } + if let data = try? JSONSerialization.data(withJSONObject: mapped, options: .prettyPrinted), + let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } else { + print("[]") + } + } } diff --git a/MiniSimCam/Sources/FrameHost/CameraSource.swift b/MiniSimCam/Sources/FrameHost/CameraSource.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h b/MiniSimCam/Sources/FrameHost/FrameHost-Bridging-Header.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/FrameLoop.swift b/MiniSimCam/Sources/FrameHost/FrameLoop.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/ImageSource.swift b/MiniSimCam/Sources/FrameHost/ImageSource.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/Info.plist b/MiniSimCam/Sources/FrameHost/Info.plist old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/FrameHost/main.swift b/MiniSimCam/Sources/FrameHost/main.swift old mode 100644 new mode 100755 index b9da369..eb6e6fa --- a/MiniSimCam/Sources/FrameHost/main.swift +++ b/MiniSimCam/Sources/FrameHost/main.swift @@ -36,6 +36,9 @@ struct FrameHostCommand: ParsableCommand { @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 @@ -75,7 +78,11 @@ struct FrameHostCommand: ParsableCommand { func run() throws { // Handle --list-cameras first — no UDID, shared memory, or frame loop needed. if listCameras { - CameraDiscovery.printTable() + if json { + CameraDiscovery.printJSON() + } else { + CameraDiscovery.printTable() + } return } diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm b/MiniSimCam/Sources/MiniCamInject/CaptureHooks.mm old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm b/MiniSimCam/Sources/MiniCamInject/EntryPoint.mm old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.h old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm b/MiniSimCam/Sources/MiniCamInject/SampleBufferFactory.mm old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.cpp old mode 100644 new mode 100755 diff --git a/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp b/MiniSimCam/Sources/MiniCamInject/SharedFrameReader.hpp old mode 100644 new mode 100755 diff --git a/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift b/MiniSimCam/Tests/FrameTransportTests/FrameTransportTests.swift old mode 100644 new mode 100755 diff --git a/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift b/MiniSimCam/Tests/ProtocolTests/ProtocolTests.swift old mode 100644 new mode 100755 diff --git a/cmd/cam.go b/cmd/cam.go index 5cad24f..a0576ed 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -43,8 +43,18 @@ func miniSimCamDir() string { } func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } -func statusFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.status", udid) } func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } +func statusFilePath(udid string) string { + primary := fmt.Sprintf("/tmp/minisimcam.%s.status", udid) + if _, err := os.Stat(primary); err == nil { + return primary + } + secondary := filepath.Join(os.TempDir(), fmt.Sprintf("minisimcam.%s.status", udid)) + if _, err := os.Stat(secondary); err == nil { + return secondary + } + return primary +} // resolveEmbeddedBinDir returns the directory containing extracted embedded // binaries, or "" if the embedded assets are stubs (dev builds without cam). // The result is cached after the first successful extraction. @@ -151,10 +161,17 @@ shared memory. A dylib (MiniCamInject) loaded into your simulator app reads those frames and delivers them as CMSampleBuffer callbacks. Quick start: + sim cam sim cam start --image test.png sim cam launch --bundle-id com.example.MyApp sim cam status sim cam stop`, + RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return fmt.Errorf("cam is only supported on macOS") + } + return runCamDashboard() + }, } // --------------------------------------------------------------------------- @@ -517,7 +534,14 @@ var camStopCmd = &cobra.Command{ udid, _, err := findRunningIOSSimulator(camStopDevice) if err != nil || udid == "" { - return fmt.Errorf("no booted iOS simulator found (%w)", err) + if camStopDevice == "" { + _ = stopFrameHost("") + PrintSuccess("All FrameHost processes stopped.") + return nil + } + _ = stopFrameHost(camStopDevice) + PrintSuccess("FrameHost stopped.") + return nil } if err := stopFrameHost(udid); err != nil { @@ -528,35 +552,44 @@ var camStopCmd = &cobra.Command{ }, } -// stopFrameHost verifies that the PID still belongs to this FrameHost before -// signalling it. A stale PID file must never interrupt an unrelated process. +// stopFrameHost scans all running processes and terminates ALL FrameHost processes +// associated with the target UDID immediately. func stopFrameHost(udid string) error { pidPath := pidFilePath(udid) - data, err := os.ReadFile(pidPath) - if err != nil { - return nil // No PID file — nothing to stop. - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil { - return fmt.Errorf("invalid PID in %s: %w", pidPath, err) - } - command, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output() - if err != nil { - _ = os.Remove(pidPath) - return nil - } - commandLine := string(command) - if !strings.Contains(commandLine, "FrameHost") || !strings.Contains(commandLine, "--udid "+udid) { + statusPath := statusFilePath(udid) + shm := shmPath(udid) + + defer func() { _ = os.Remove(pidPath) - return nil - } - proc, err := os.FindProcess(pid) + _ = os.Remove(statusPath) + _ = os.Remove(shm) + if udid != "" { + _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.pid", udid)) + _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.status", udid)) + _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.frames", udid)) + } + }() + + out, err := exec.Command("ps", "-eo", "pid,command").Output() if err != nil { - return fmt.Errorf("cannot find FrameHost process %d: %w", pid, err) + return nil } - if err := proc.Signal(os.Interrupt); err != nil { - return fmt.Errorf("cannot stop FrameHost process %d: %w", pid, err) + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "FrameHost") && (udid == "" || strings.Contains(line, udid)) { + fields := strings.Fields(line) + if len(fields) > 0 { + if pid, err := strconv.Atoi(fields[0]); err == nil { + if proc, err := os.FindProcess(pid); err == nil { + _ = proc.Kill() + } + } + } + } } + return nil } diff --git a/cmd/cam_dashboard.go b/cmd/cam_dashboard.go new file mode 100644 index 0000000..db8451f --- /dev/null +++ b/cmd/cam_dashboard.go @@ -0,0 +1,808 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "runtime" + "sort" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type focusPane int + +const ( + paneSimulators focusPane = iota + paneCameras + paneApps +) + +type SimCamApp struct { + BundleID string `json:"CFBundleIdentifier"` + DisplayName string `json:"CFBundleDisplayName"` + Name string `json:"CFBundleName"` + ApplicationType string `json:"ApplicationType"` +} + +func (a SimCamApp) Title() string { + if a.DisplayName != "" { + return a.DisplayName + } + if a.Name != "" { + return a.Name + } + return a.BundleID +} + +type camSimDevice struct { + Device + CamStatus string + CamSource string + CamFPS int +} + +type ResolutionPreset struct { + title string + description string + width int + height int +} + +func (r ResolutionPreset) Title() string { return r.title } +func (r ResolutionPreset) Description() string { return r.description } +func (r ResolutionPreset) FilterValue() string { return r.title } + +type camDashboardModel struct { + focused focusPane + simTable table.Model + appTable table.Model + cameraTable table.Model + presetList list.Model + filterInput textinput.Model + spinner spinner.Model + simulators []camSimDevice + allApps []SimCamApp + filteredApps []SimCamApp + cameras []CameraInfo + selectedSimUDID string + selectedSimName string + showSystemApps bool + showPopup bool + camWidth int + camHeight int + msg string + loading bool + width int + height int +} + +type ( + camRefreshMsg []camSimDevice + appsFetchedMsg []SimCamApp + camerasFetchedMsg []CameraInfo +) + +type CameraInfo struct { + UniqueID string `json:"uniqueID"` + LocalizedName string `json:"localizedName"` + TypeLabel string `json:"typeLabel"` +} + +func fetchBootedSimulatorsWithCamStatus() []camSimDevice { + if runtime.GOOS != DarwinOS { + return nil + } + + allSims := GetIOSSimulators() + var booted []camSimDevice + + for _, sim := range allSims { + if sim.State == StateBooted || sim.State == "Booted" { + dev := camSimDevice{ + Device: sim, + CamStatus: "Stopped", + CamSource: "-", + CamFPS: 0, + } + + // Read status file if running + statusPath := statusFilePath(sim.UDID) + if data, err := os.ReadFile(statusPath); err == nil { + var st camFrameLoopStatus + if json.Unmarshal(data, &st) == nil && st.Running { + dev.CamStatus = "Running" + dev.CamSource = st.Source + dev.CamFPS = st.FPS + } + } + + booted = append(booted, dev) + } + } + + sort.Slice(booted, func(i, j int) bool { + return booted[i].Name < booted[j].Name + }) + + return booted +} + +func fetchInstalledApps(udid string) ([]SimCamApp, error) { + cmd := fmt.Sprintf("xcrun simctl listapps %s | plutil -convert json - -o -", udid) + out, err := exec.Command("sh", "-c", cmd).Output() + if err != nil { + return nil, fmt.Errorf("failed to list apps: %w", err) + } + + var rawMap map[string]SimCamApp + if err := json.Unmarshal(out, &rawMap); err != nil { + return nil, fmt.Errorf("failed to parse apps JSON: %w", err) + } + + var apps []SimCamApp + for bundleID, app := range rawMap { + if app.BundleID == "" { + app.BundleID = bundleID + } + if strings.HasPrefix(app.BundleID, "com.apple.Process") || strings.HasPrefix(app.BundleID, "com.apple.CoreSimulator") { + continue + } + apps = append(apps, app) + } + + sort.Slice(apps, func(i, j int) bool { + return strings.ToLower(apps[i].Title()) < strings.ToLower(apps[j].Title()) + }) + + return apps, nil +} + +func refreshCamDevicesCmd() tea.Cmd { + return func() tea.Msg { + return camRefreshMsg(fetchBootedSimulatorsWithCamStatus()) + } +} + +func refreshCamStatusOnlyCmd(sims []camSimDevice) tea.Cmd { + return func() tea.Msg { + var booted []camSimDevice + for _, dev := range sims { + dev.CamStatus = "Stopped" + dev.CamSource = "-" + dev.CamFPS = 0 + + statusPath := statusFilePath(dev.UDID) + if data, err := os.ReadFile(statusPath); err == nil { + var st camFrameLoopStatus + if json.Unmarshal(data, &st) == nil && st.Running { + dev.CamStatus = "Running" + dev.CamSource = st.Source + dev.CamFPS = st.FPS + } + } + booted = append(booted, dev) + } + return camRefreshMsg(booted) + } +} + +func fetchAppsCmd(udid string) tea.Cmd { + return func() tea.Msg { + apps, err := fetchInstalledApps(udid) + if err != nil { + return actionDoneMsg{msg: "Error fetching apps: " + err.Error()} + } + return appsFetchedMsg(apps) + } +} + +func getAvailableCameras() ([]CameraInfo, error) { + mscDir := miniSimCamDir() + bin := frameHostBin(mscDir) + if _, err := os.Stat(bin); err != nil { + return nil, fmt.Errorf("FrameHost binary not found") + } + + out, err := exec.Command(bin, "--list-cameras", "--json").Output() + if err != nil { + return nil, fmt.Errorf("failed to list cameras: %w", err) + } + + var cameras []CameraInfo + if err := json.Unmarshal(out, &cameras); err != nil { + return nil, fmt.Errorf("failed to parse cameras: %w", err) + } + return cameras, nil +} + +func fetchCamerasCmd() tea.Cmd { + return func() tea.Msg { + cams, err := getAvailableCameras() + if err != nil { + return actionDoneMsg{msg: "Error fetching cameras: " + err.Error()} + } + return camerasFetchedMsg(cams) + } +} + +func (m camDashboardModel) Init() tea.Cmd { + var cmds []tea.Cmd + cmds = append(cmds, m.spinner.Tick, refreshCamDevicesCmd(), camTickCmd()) + if len(m.simulators) > 0 { + m.selectedSimUDID = m.simulators[0].UDID + m.selectedSimName = m.simulators[0].Name + cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID)) + } + return tea.Batch(cmds...) +} + +type camTickMsg time.Time + +func camTickCmd() tea.Cmd { + return tea.Tick(time.Second*5, func(t time.Time) tea.Msg { + return camTickMsg(t) + }) +} + +func updateTableFocus(m *camDashboardModel) { + m.simTable.Focus() + m.cameraTable.Focus() + m.appTable.Focus() + + if m.focused != paneSimulators { + m.simTable.Blur() + } + if m.focused != paneCameras { + m.cameraTable.Blur() + } + if m.focused != paneApps { + m.appTable.Blur() + } +} + +func (m *camDashboardModel) updateFilteredApps() { + var filtered []SimCamApp + query := strings.ToLower(m.filterInput.Value()) + + for _, app := range m.allApps { + if !m.showSystemApps && app.ApplicationType == "System" { + continue + } + if query != "" { + titleMatch := strings.Contains(strings.ToLower(app.Title()), query) + bundleMatch := strings.Contains(strings.ToLower(app.BundleID), query) + if !titleMatch && !bundleMatch { + continue + } + } + filtered = append(filtered, app) + } + m.filteredApps = filtered + + rows := make([]table.Row, 0, len(filtered)) + for _, app := range filtered { + appType := app.ApplicationType + if appType == "" { + appType = "User" + } + rows = append(rows, table.Row{ + app.Title(), + app.BundleID, + appType, + }) + } + m.appTable.SetRows(rows) +} + +func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.MouseMsg: + if m.showPopup { + break + } + if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { + availWidth := m.width - 8 + if availWidth < 90 { + availWidth = 90 + } + w1 := int(float64(availWidth)*0.35) + 2 + w2 := int(float64(availWidth)*0.30) + 2 + + oldFocus := m.focused + if msg.X < w1 { + m.focused = paneSimulators + } else if msg.X < w1+w2 { + m.focused = paneCameras + } else { + m.focused = paneApps + } + if oldFocus != m.focused { + updateTableFocus(&m) + } + } + + var cmd tea.Cmd + if m.focused == paneSimulators { + m.simTable, cmd = m.simTable.Update(msg) + } else if m.focused == paneCameras { + m.cameraTable, cmd = m.cameraTable.Update(msg) + } else if m.focused == paneApps { + m.appTable, cmd = m.appTable.Update(msg) + } + cmds = append(cmds, cmd) + + case tea.KeyMsg: + if m.showPopup { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + m.showPopup = false + return m, nil + case "enter": + if i, ok := m.presetList.SelectedItem().(ResolutionPreset); ok { + m.camWidth = i.width + m.camHeight = i.height + m.msg = fmt.Sprintf("Resolution set to %dx%d", i.width, i.height) + } + m.showPopup = false + return m, nil + } + var cmd tea.Cmd + m.presetList, cmd = m.presetList.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + if m.focused == paneApps && m.filterInput.Focused() { + if msg.String() == "enter" || msg.String() == "esc" { + m.filterInput.Blur() + } else { + var inputCmd tea.Cmd + m.filterInput, inputCmd = m.filterInput.Update(msg) + m.updateFilteredApps() + cmds = append(cmds, inputCmd) + return m, tea.Batch(cmds...) + } + } else { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab", "l", "right": + m.focused = (m.focused + 1) % 3 + updateTableFocus(&m) + return m, nil + case "shift+tab", "h", "left": + m.focused = (m.focused + 2) % 3 + updateTableFocus(&m) + return m, nil + case "o": + m.showPopup = true + return m, nil + case "x": + if m.selectedSimUDID != "" { + m.loading = true + m.msg = "Stopping camera feed for " + m.selectedSimName + "..." + cmds = append(cmds, doActionCmd(func() error { + return stopFrameHost(m.selectedSimUDID) + }, "Stopped camera feed for "+m.selectedSimName)) + } + case "r": + m.loading = true + m.msg = "Refreshing..." + cmds = append(cmds, refreshCamDevicesCmd()) + case "t": + if m.focused == paneApps { + m.showSystemApps = !m.showSystemApps + m.updateFilteredApps() + } + case "/": + if m.focused == paneApps { + m.filterInput.Focus() + } + case "enter": + if m.focused == paneSimulators { + m.focused = paneCameras + updateTableFocus(&m) + } else if m.focused == paneCameras { + idx := m.cameraTable.Cursor() + if idx >= 0 && idx < len(m.cameras) { + camID := m.cameras[idx].UniqueID + m.loading = true + m.msg = "Starting camera feed for " + m.selectedSimName + "..." + cmds = append(cmds, doActionCmd(func() error { + return startCameraForDevice(m.selectedSimUDID, true, "", camID, m.camWidth, m.camHeight) + }, "Started camera feed for "+m.selectedSimName)) + } + } else if m.focused == paneApps { + idx := m.appTable.Cursor() + if idx >= 0 && idx < len(m.filteredApps) { + bundleID := m.filteredApps[idx].BundleID + appName := m.filteredApps[idx].Title() + m.loading = true + m.msg = fmt.Sprintf("Launching %s...", appName) + udid := m.selectedSimUDID + cmds = append(cmds, doActionCmd(func() error { + return launchAppWithCam(udid, bundleID) + }, fmt.Sprintf("Launched %s!", appName))) + } + } + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + tHeight := m.height - 8 + if tHeight < 5 { + tHeight = 5 + } + + availWidth := m.width - 8 + if availWidth < 90 { + availWidth = 90 + } + + w1 := int(float64(availWidth) * 0.35) + w2 := int(float64(availWidth) * 0.30) + w3 := availWidth - (w1 + w2) + + m.simTable.SetHeight(tHeight - 2) + m.cameraTable.SetHeight(tHeight - 2) + m.appTable.SetHeight(tHeight - 3) + + sw1 := int(float64(w1) * 0.5) + sw2 := int(float64(w1) * 0.25) + sw3 := w1 - (sw1 + sw2) - 4 + m.simTable.SetColumns([]table.Column{ + {Title: "Name", Width: sw1}, + {Title: "Status", Width: sw2}, + {Title: "Source", Width: sw3}, + }) + + cw1 := int(float64(w2) * 0.5) + cw2 := int(float64(w2) * 0.3) + cw3 := w2 - (cw1 + cw2) - 4 + m.cameraTable.SetColumns([]table.Column{ + {Title: "Camera", Width: cw1}, + {Title: "Type", Width: cw2}, + {Title: "ID", Width: cw3}, + }) + + aw1 := int(float64(w3) * 0.4) + aw2 := int(float64(w3) * 0.4) + aw3 := w3 - (aw1 + aw2) - 4 + m.appTable.SetColumns([]table.Column{ + {Title: "App Name", Width: aw1}, + {Title: "Bundle ID", Width: aw2}, + {Title: "Type", Width: aw3}, + }) + + m.presetList.SetSize(60, 20) + + case camRefreshMsg: + rows := make([]table.Row, 0, len(msg)) + for _, d := range msg { + rows = append(rows, table.Row{ + d.Name, + d.CamStatus, + d.CamSource, + }) + } + m.simTable.SetRows(rows) + m.simulators = msg + m.loading = false + if m.msg == "Refreshing..." { + m.msg = "Refreshed list." + } + + if len(m.simulators) > 0 && m.selectedSimUDID == "" { + m.selectedSimUDID = m.simulators[0].UDID + m.selectedSimName = m.simulators[0].Name + cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID)) + } + + case appsFetchedMsg: + m.allApps = msg + m.updateFilteredApps() + m.loading = false + m.msg = fmt.Sprintf("Loaded %d apps.", len(msg)) + + case camerasFetchedMsg: + m.cameras = msg + rows := make([]table.Row, 0, len(msg)) + for _, cam := range msg { + displayID := cam.UniqueID + if len(displayID) > 8 { + displayID = displayID[:8] + "…" + } + rows = append(rows, table.Row{ + cam.LocalizedName, + cam.TypeLabel, + displayID, + }) + } + m.cameraTable.SetRows(rows) + m.loading = false + m.msg = fmt.Sprintf("Found %d cameras.", len(msg)) + + case actionDoneMsg: + m.msg = msg.msg + m.loading = false + cmds = append(cmds, refreshCamStatusOnlyCmd(m.simulators)) + + case camTickMsg: + cmds = append(cmds, camTickCmd(), refreshCamDevicesCmd()) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + oldSimUDID := m.selectedSimUDID + + if m.focused == paneSimulators && !m.showPopup { + var tableCmd tea.Cmd + m.simTable, tableCmd = m.simTable.Update(msg) + cmds = append(cmds, tableCmd) + + idx := m.simTable.Cursor() + if idx >= 0 && idx < len(m.simulators) { + m.selectedSimUDID = m.simulators[idx].UDID + m.selectedSimName = m.simulators[idx].Name + } + } else if m.focused == paneCameras && !m.showPopup { + var camTableCmd tea.Cmd + m.cameraTable, camTableCmd = m.cameraTable.Update(msg) + cmds = append(cmds, camTableCmd) + } else if m.focused == paneApps && !m.showPopup { + var appTableCmd tea.Cmd + m.appTable, appTableCmd = m.appTable.Update(msg) + cmds = append(cmds, appTableCmd) + } + + if oldSimUDID != m.selectedSimUDID && m.selectedSimUDID != "" { + m.loading = true + m.msg = "Loading details for " + m.selectedSimName + "..." + cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID)) + } + + return m, tea.Batch(cmds...) +} + +func (m camDashboardModel) View() string { + activeColor := lipgloss.Color("62") // Purple highlight + inactiveColor := lipgloss.Color("240") // Gray + + borderStyle := func(pane focusPane) lipgloss.Style { + c := inactiveColor + if m.focused == pane { + c = activeColor + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(c). + Height(m.height - 4) + } + + pane1 := borderStyle(paneSimulators).Render( + lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render("Booted Simulators") + "\n\n" + m.simTable.View(), + ) + + pane2 := borderStyle(paneCameras).Render( + lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render("Cameras") + "\n\n" + m.cameraTable.View(), + ) + + appHeader := "Apps" + if m.showSystemApps { + appHeader += " [All]" + } else { + appHeader += " [User]" + } + pane3 := borderStyle(paneApps).Render( + lipgloss.NewStyle().Bold(true).PaddingLeft(1).Render(appHeader) + "\n" + + " Search (/): " + m.filterInput.View() + "\n" + + m.appTable.View(), + ) + + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, pane1, pane2, pane3) + + footer := " Controls: [tab/h/l] Switch Pane • [j/k] Navigate • [enter] Action • [x] Stop Cam • [o] Resolution • [t] Toggle SysApps • [r] Refresh • [q] Quit\n " + if m.loading { + footer += m.spinner.View() + " " + lipgloss.NewStyle().Foreground(ColorIOS).Render(m.msg) + } else if m.msg != "" { + footer += "ℹ " + lipgloss.NewStyle().Foreground(ColorIOS).Render(m.msg) + } + + ui := lipgloss.JoinVertical(lipgloss.Left, mainContent, footer) + + if m.showPopup { + popupStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("252")) + + popupView := popupStyle.Render(m.presetList.View()) + + // Create a full screen container and place the popup in the center + ui = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popupView, lipgloss.WithWhitespaceChars(" ")) + } + + return ui +} + +func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraID string, width, height int) error { + mscDir := miniSimCamDir() + bin := frameHostBin(mscDir) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("FrameHost binary not found") + } + + _ = stopFrameHost(udid) + _ = os.Remove(statusFilePath(udid)) + + args := []string{ + "--udid", udid, + "--width", fmt.Sprintf("%d", width), + "--height", fmt.Sprintf("%d", height), + "--fps", fmt.Sprintf("%d", DefaultCamFPS), + } + + if useCamera { + args = append(args, "--camera") + if cameraID != "" { + args = append(args, "--camera-id", cameraID) + } + } else if imagePath != "" { + args = append(args, "--image", imagePath) + } else { + args = append(args, "--bars") + } + + c := exec.Command(bin, args...) + if !useCamera { + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + } + if err := c.Start(); err != nil { + return err + } + + return waitForFrameHostReady(c, statusFilePath(udid)) +} + +func launchAppWithCam(udid, bundleID string) error { + mscDir := miniSimCamDir() + dylib := injectorDylib(mscDir) + if _, err := os.Stat(dylib); err != nil { + return fmt.Errorf("MiniCamInject.dylib not found") + } + + shm := shmPath(udid) + fps := frameHostFPS(udid) + + c := exec.Command("xcrun", "simctl", "launch", udid, bundleID) + c.Env = append(os.Environ(), + "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib, + "SIMCTL_CHILD_MINISIMCAM_PATH="+shm, + "SIMCTL_CHILD_MINISIMCAM_FPS="+fmt.Sprintf("%d", fps), + ) + return c.Run() +} + +func runCamDashboard() error { + initialSims := fetchBootedSimulatorsWithCamStatus() + + simCols := []table.Column{ + {Title: "Name", Width: 20}, + {Title: "Status", Width: 15}, + {Title: "Source", Width: 15}, + } + + simRows := make([]table.Row, 0, len(initialSims)) + for _, d := range initialSims { + simRows = append(simRows, table.Row{ + d.Name, + d.CamStatus, + d.CamSource, + }) + } + + tSim := table.New( + table.WithColumns(simCols), + table.WithRows(simRows), + table.WithFocused(true), + table.WithHeight(12), + ) + + tApp := table.New( + table.WithColumns([]table.Column{ + {Title: "App Name", Width: 20}, + {Title: "Bundle ID", Width: 20}, + {Title: "Type", Width: 10}, + }), + table.WithFocused(false), + table.WithHeight(10), + ) + + tCam := table.New( + table.WithColumns([]table.Column{ + {Title: "Camera Name", Width: 20}, + {Title: "Type", Width: 15}, + {Title: "Unique ID", Width: 15}, + }), + table.WithFocused(false), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + + tSim.SetStyles(s) + tApp.SetStyles(s) + tCam.SetStyles(s) + + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(ColorHeader) + + ti := textinput.New() + ti.Placeholder = "Search..." + ti.CharLimit = 156 + ti.Width = 20 + + presetItems := []list.Item{ + ResolutionPreset{title: "720p (16:9)", description: "1280 x 720 (Default)", width: 1280, height: 720}, + ResolutionPreset{title: "1080p (16:9)", description: "1920 x 1080", width: 1920, height: 1080}, + ResolutionPreset{title: "4K (16:9)", description: "3840 x 2160", width: 3840, height: 2160}, + ResolutionPreset{title: "Vertical HD (9:16)", description: "1080 x 1920", width: 1080, height: 1920}, + ResolutionPreset{title: "Square (1:1)", description: "1080 x 1080", width: 1080, height: 1080}, + } + presetList := list.New(presetItems, list.NewDefaultDelegate(), 60, 20) + presetList.Title = "Select Camera Resolution & Aspect Ratio" + presetList.SetShowHelp(false) + presetList.SetShowStatusBar(false) + + m := camDashboardModel{ + focused: paneSimulators, + simTable: tSim, + appTable: tApp, + cameraTable: tCam, + presetList: presetList, + filterInput: ti, + spinner: sp, + msg: "Ready.", + simulators: initialSims, + camWidth: DefaultCamWidth, + camHeight: DefaultCamHeight, + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil { + return err + } + + return nil +} diff --git a/cmd/cam_test.go b/cmd/cam_test.go index c348d8e..0c7d69e 100644 --- a/cmd/cam_test.go +++ b/cmd/cam_test.go @@ -2,9 +2,11 @@ package cmd import ( "encoding/json" + "fmt" "os" "path/filepath" "testing" + "time" ) // TestCamShmPaths verifies that shmPath / statusFilePath / pidFilePath produce @@ -66,8 +68,8 @@ func TestCamStatusParsing(t *testing.T) { } func TestFrameHostFPS(t *testing.T) { - udid := "frame-host-fps-test" - path := statusFilePath(udid) + udid := fmt.Sprintf("frame-host-fps-test-%d", time.Now().UnixNano()) + path := filepath.Join(os.TempDir(), fmt.Sprintf("minisimcam.%s.status", udid)) t.Cleanup(func() { _ = os.Remove(path) }) status := camFrameLoopStatus{FPS: 60} diff --git a/go.mod b/go.mod index b9d52ee..59e643a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.4 require ( github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b github.com/charmbracelet/lipgloss v1.1.0 @@ -13,8 +15,6 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -35,6 +35,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index 6a769ea..d7b2c36 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -72,6 +74,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= From 10b406e75db56aefbfb5714c6f8bd39ec8fa6f81 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 16:08:42 +0700 Subject: [PATCH 07/10] feat: add global simulator payload injection --- cmd/cam.go | 24 ++++++++++++++++++++++++ cmd/cam_dashboard.go | 2 ++ 2 files changed, 26 insertions(+) diff --git a/cmd/cam.go b/cmd/cam.go index a0576ed..ebdcbb9 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -552,6 +552,28 @@ var camStopCmd = &cobra.Command{ }, } +func setGlobalSimEnv(udid string, fps int) { + if udid == "" { + return + } + mscDir := miniSimCamDir() + dylib := injectorDylib(mscDir) + shm := shmPath(udid) + + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", dylib).Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_PATH", shm).Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_FPS", strconv.Itoa(fps)).Run() +} + +func unsetGlobalSimEnv(udid string) { + if udid == "" { + return + } + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "DYLD_INSERT_LIBRARIES").Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "MINISIMCAM_PATH").Run() + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "unsetenv", "MINISIMCAM_FPS").Run() +} + // stopFrameHost scans all running processes and terminates ALL FrameHost processes // associated with the target UDID immediately. func stopFrameHost(udid string) error { @@ -559,6 +581,8 @@ func stopFrameHost(udid string) error { statusPath := statusFilePath(udid) shm := shmPath(udid) + unsetGlobalSimEnv(udid) + defer func() { _ = os.Remove(pidPath) _ = os.Remove(statusPath) diff --git a/cmd/cam_dashboard.go b/cmd/cam_dashboard.go index db8451f..7a16173 100644 --- a/cmd/cam_dashboard.go +++ b/cmd/cam_dashboard.go @@ -682,6 +682,8 @@ func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraI if err := c.Start(); err != nil { return err } + + setGlobalSimEnv(udid, DefaultCamFPS) return waitForFrameHostReady(c, statusFilePath(udid)) } From 2284b3a2ba569274240f6c1c95df0e090cc2a364 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 16:22:39 +0700 Subject: [PATCH 08/10] feat: switch camera --- .../Sources/FrameHost/SharedFrameWriter.swift | 73 ++++++++++++------- cmd/cam.go | 5 +- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift index be94715..a863946 100755 --- a/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift +++ b/MiniSimCam/Sources/FrameHost/SharedFrameWriter.swift @@ -34,31 +34,46 @@ final class SharedFrameWriter { close() } - /// Opens (or re-creates) the shared file and writes the stream header. 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 - // Remove stale file if present. - if FileManager.default.fileExists(atPath: path) { - try FileManager.default.removeItem(atPath: path) + 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 + } } - // Create and size the file. - guard FileManager.default.createFile(atPath: path, contents: nil) else { - throw WriterError.cannotCreateFile(path) + 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 } - let fd = Darwin.open(path, O_RDWR) - guard fd != -1 else { throw WriterError.openFailed(path, errno: errno) } + defer { Darwin.close(fd) } - guard ftruncate(fd, off_t(totalSize)) == 0 else { - throw WriterError.truncateFailed(errno: errno) - } - - // mmap read-write shared. ftruncate zeroes all bytes, so - // sequence=0 (even) and publishedIndex=0 are correct initial values. + // 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) @@ -68,17 +83,25 @@ final class SharedFrameWriter { mappingSize = totalSize frameSize = bufSize - // Write the non-atomic header fields directly (Swift can access these). let hdr = ptr.bindMemory(to: MSCStreamHeader.self, capacity: 1) - 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. + 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. diff --git a/cmd/cam.go b/cmd/cam.go index ebdcbb9..873be8e 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -579,18 +579,17 @@ func unsetGlobalSimEnv(udid string) { func stopFrameHost(udid string) error { pidPath := pidFilePath(udid) statusPath := statusFilePath(udid) - shm := shmPath(udid) unsetGlobalSimEnv(udid) defer func() { _ = os.Remove(pidPath) _ = os.Remove(statusPath) - _ = os.Remove(shm) + // We purposefully DO NOT remove the shm file so it can be reused seamlessly + // across FrameHost restarts. if udid != "" { _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.pid", udid)) _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.status", udid)) - _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.frames", udid)) } }() From 3c43a8256293cc219b65ae4b4ebcefc46fa41e6d Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 16:24:47 +0700 Subject: [PATCH 09/10] chore: add docs --- README.md | 12 ++ docs/SIM_CAM_ARCHITECTURE.md | 257 +++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 docs/SIM_CAM_ARCHITECTURE.md diff --git a/README.md b/README.md index 81c32e1..b22f0d1 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ sim last | `last` | - | Show the last used device. | | `lts` | - | Start the last used device (short for `sim start lts`). | | `completion ` | - | Generate shell autocompletion script. | +| `cam` | - | iOS Simulator camera injection and management. | | `version` | `-v` | Show the current version. | | `help` | - | Show help information. | @@ -227,6 +228,17 @@ sim copy to "iPhone 15 Pro" ~/Pictures/test.png sim copy from "Pixel_7" /sdcard/Download/test.png ./ ``` +## Camera Injection + +SIM-CLI supports injecting physical webcams, Continuity Cameras, or static images directly into iOS Simulator applications, bypassing the simulator's lack of native camera support. + +To open the camera management dashboard: +```bash +sim cam +``` + +For detailed technical information on how the shared memory and dylib injection works, please read the [SIM-CLI Camera Architecture](docs/SIM_CAM_ARCHITECTURE.md). + ## Configuration SIM-CLI stores its runtime state (e.g., last used device) and user preferences in a JSON file at: diff --git a/docs/SIM_CAM_ARCHITECTURE.md b/docs/SIM_CAM_ARCHITECTURE.md new file mode 100644 index 0000000..e4ef71b --- /dev/null +++ b/docs/SIM_CAM_ARCHITECTURE.md @@ -0,0 +1,257 @@ +# SIM-CLI Camera Architecture + +This document explains the technical architecture of the `sim cam` feature. It details how `sim-cli` injects macOS cameras into iOS Simulator apps, a capability Apple does not natively support. + +## The Problem +The iOS Simulator lacks hardware passthrough for the host Mac's physical cameras. When an app running in the simulator uses `AVFoundation` to request camera access, the simulator returns a mock black screen, provides a static placeholder, or crashes the app. + +## The Solution +`sim-cli` solves this using a distributed, two-process architecture communicating over lock-free shared memory. + +```mermaid +flowchart TD + subgraph Host["macOS Host"] + CLI["sim-cli"] + WebCam["WebCam (Hardware)"] + + subgraph FrameHost["FrameHost (macOS Process)"] + CameraSource["CameraSource"] + SharedFrameWriter["SharedFrameWriter"] + end + + SHM[/"/tmp/minisimcam..frames (Shared Memory)"/] + end + + subgraph Sim["iOS Simulator"] + subgraph App["Target iOS App"] + AVFoundation["AVFoundation"] + + subgraph Injector["MiniCamInject.dylib"] + Swizzler["Swizzler"] + SharedFrameReader["SharedFrameReader"] + SampleBufferFactory["SampleBufferFactory"] + end + end + end + + CLI -. "Spawns" .-> FrameHost + CLI -. "Injects via launchctl" .-> App + WebCam -- "Frames" --> CameraSource + CameraSource -- "BGRA" --> SharedFrameWriter + SharedFrameWriter -- "Seq-locked write" --> SHM + SHM -- "Polling read" --> SharedFrameReader + SharedFrameReader -- "BGRA" --> SampleBufferFactory + SampleBufferFactory -- "CMSampleBuffer" --> Swizzler + Swizzler -- "Spoofs feed" --> AVFoundation +``` + +--- + +## 1. Inter-Process Communication (IPC) + +Passing uncompressed 1080p or 4K video at 60 frames per second between two separate processes requires moving up to 500 MB/s of data. + +**The Design Decision:** +Standard IPC mechanisms like XPC, Mach ports, or local UNIX sockets require serializing the data, copying it into the operating system kernel space, and then copying it back out to the receiving process. This double-copy creates CPU bottlenecks and latency. To fix this, `sim-cli` uses **Memory-Mapped Files (`mmap`)**. + +Memory mapping maps a file directly into the virtual memory space of both the macOS `FrameHost` process and the iOS Simulator app process. When `FrameHost` writes a pixel to this memory region, the iOS app can read that exact byte instantly. This enables zero-copy data transfer. + +### Memory Layout +The shared memory file consists of a 128-byte header (`MSCStreamHeader`), immediately followed by a triple-buffer of raw BGRA pixel data. + +```c +typedef struct { + uint32_t magic; // "MSCC" + uint32_t version; // Version schema + uint32_t width; // Frame width + uint32_t height; // Frame height + uint32_t bytesPerRow; // Memory stride (aligned to 64 bytes) + uint32_t pixelFormat; // 'BGRA' + uint32_t bufferCount; // 3 (Triple buffered) + uint32_t bufferSize; // bytesPerRow * height + uint64_t sequence; // ATOMIC: Sequence lock for writers + uint32_t publishedIndex; // ATOMIC: Current readable buffer index + uint32_t _pad0; // Alignment padding + uint64_t presentationTimeNs; // Frame PTS (Nanoseconds) + uint64_t framesProduced; // ATOMIC: Total frames produced + uint8_t reserved[64]; // Future expansion padding +} MSCStreamHeader; // Exactly 128 bytes +``` + +### The Lock-Free Algorithm +Because the producer (`FrameHost`) and consumer (`MiniCamInject`) run in separate processes, they need a way to coordinate. If `FrameHost` writes data while the app is reading it, the video frame tears (the top half of the old frame mixed with the bottom half of the new frame). + +**The Design Decision:** +We cannot use standard POSIX mutexes (`pthread_mutex_t`). If the user stops the camera and `sim-cli` kills `FrameHost` via `SIGKILL` while it holds a shared mutex, the mutex is permanently locked. The iOS app would wait on that lock forever and freeze. + +Instead, `sim-cli` uses a **Sequence Lock** (SeqLock) implementation with C++20 standard atomic memory orders (`std::memory_order_acquire` / `std::memory_order_release`). Sequence locks eliminate deadlocks because the reader never blocks. It only retries. + +```mermaid +sequenceDiagram + participant P as FrameHost
(Producer) + participant SHM as Shared Memory + participant C as MiniCamInject
(Consumer) + + %% Producer Write + Note over P, C: Producer Write + P->>SHM: load sequence + P->>SHM: sequence = sequence + 1 (Odd: Locked) + P->>SHM: write pixel data to buffer[writeIndex] + P->>SHM: update publishedIndex = writeIndex + P->>SHM: sequence = sequence + 1 (Even: Unlocked) + + %% Consumer Read + Note over P, C: Consumer Read + loop until valid frame + C->>SHM: seqA = load sequence + alt seqA is Odd + C->>C: sched_yield() (retry) + else seqA is Even + C->>SHM: idx = load publishedIndex + C->>SHM: copy pixels from buffer[idx] + C->>SHM: seqB = load sequence + alt seqA == seqB + C->>C: Valid frame! Break loop. + else Torn Read + C->>C: Frame modified during read. Retry. + end + end + end +``` + +1. **Producer writes:** `FrameHost` extracts raw BGRA pointers from the Mac's hardware camera. It atomically increments the `sequence` integer to an **odd** number, signaling a write is in progress. It copies the frame into an inactive buffer slot, updates `publishedIndex`, and increments `sequence` to an **even** number. +2. **Consumer reads:** `MiniCamInject` polls the `sequence` integer. If it is even, it begins reading the frame from `publishedIndex`. After the memory copy finishes, it checks `sequence` again. If `sequence` changed during the read, a torn read occurred. The consumer discards the bad frame and retries instantly. + +--- + +## 2. Initialization and Injection + +To force the iOS app to read from our custom camera feed instead of the simulator's hardware abstraction, the system uses dynamic library injection. + +**The Design Decision:** +We could patch the source code of the iOS app, but that requires modifying user code and re-compiling, which slows down development. By injecting a dynamic library (`.dylib`) at runtime, the developer does not need to change a single line of their application code. + +```mermaid +sequenceDiagram + actor User + participant CLI as sim-cli + participant LaunchCtl as launchctl + participant Host as FrameHost + participant App as iOS App + participant Inject as MiniCamInject + participant AV as AVCaptureSession + + User->>CLI: sim cam start + CLI->>Host: spawn(udid) + activate Host + Host->>Host: allocate /tmp/minisimcam..frames + Host->>Host: start AVFoundation capture + CLI->>LaunchCtl: setenv DYLD_INSERT_LIBRARIES + CLI->>LaunchCtl: setenv MINISIMCAM_PATH + + User->>App: launches app + activate App + App->>Inject: dyld loads injected library + activate Inject + Inject->>Inject: __attribute__((constructor)) init() + Inject->>AV: method_exchangeImplementations(startRunning, msc_startRunning) + deactivate Inject + + App->>AV: [session startRunning] + AV->>Inject: msc_startRunning() (intercepted) + activate Inject + Inject->>Inject: setup GCD background queue + Inject->>Inject: mmap(/tmp/minisimcam..frames) + deactivate Inject +``` + +### Global Injection Mechanics +When you start a camera via `sim-cli cam start`, the CLI executes `xcrun simctl spawn launchctl setenv`. This modifies the global environment of the booted iOS Simulator. +- `DYLD_INSERT_LIBRARIES=/path/to/MiniCamInject.dylib`: Forces the Apple dynamic linker (`dyld`) to load our library into every app launched on the simulator before the app's `main()` function executes. +- `MINISIMCAM_PATH`: Passes the path of the shared memory file so the dylib knows where to read frames. + +Because this is set globally via `launchctl`, *any* app launched on the simulator automatically receives the injection. + +### Objective-C Method Swizzling +Objective-C allows developers to change the mapping between a method name (selector) and its underlying C function (implementation) at runtime. This technique is known as Method Swizzling. + +Inside `MiniCamInject.dylib`, a C constructor function (`__attribute__((constructor))`) runs immediately upon load. It uses the Objective-C runtime function `method_exchangeImplementations` to swap the memory addresses of Apple's internal `AVCaptureSession` methods with our custom implementations. + +When the iOS app calls `[session startRunning]`, execution jumps to our code. The code bypasses Apple's hardware initialization, creates a Grand Central Dispatch (GCD) background thread, and begins reading frames from the shared memory. + +--- + +## 3. Frame Delivery + +When the iOS app's GCD queue successfully copies an un-torn frame from shared memory, it must convert the raw data into a format `AVFoundation` understands. + +```mermaid +flowchart LR + SHM[("Shared Memory
(Raw BGRA Bytes)")] + Factory["SampleBufferFactory
(Obj-C++)"] + CVPixelBuffer["CoreVideo
CVPixelBuffer"] + CMTime["CMTime
(Hardware Clock)"] + CMSampleBuffer["CMSampleBuffer"] + Delegate["AVCaptureVideoDataOutput
SampleBufferDelegate"] + + SHM -- "Lock-Free Copy" --> Factory + Factory -- "Wraps Memory" --> CVPixelBuffer + Factory -- "Attaches" --> CMTime + CVPixelBuffer --> CMSampleBuffer + CMTime --> CMSampleBuffer + CMSampleBuffer -- "Pushes to App" --> Delegate +``` + +1. `SampleBufferFactory` (written in Objective-C++) takes the raw BGRA memory array. +2. It wraps the memory into a CoreVideo `CVPixelBuffer`. +3. It attaches hardware timing data (`CMTime`) to match the simulator's internal clock. +4. It packages the `CVPixelBuffer` into a `CMSampleBuffer`. +5. It pushes the `CMSampleBuffer` to the `AVCaptureVideoDataOutputSampleBufferDelegate` of the app. + +The host app receives the delegate callbacks exactly as it would from a hardware camera. + +--- + +## 4. Camera Switching Logic +`sim-cli` supports hot-swapping cameras without crashing or restarting the iOS app. + +```mermaid +sequenceDiagram + actor User + participant CLI as sim-cli + participant OldHost as FrameHost (Old) + participant NewHost as FrameHost (New) + participant SHM as Shared Memory (.frames) + participant App as iOS App + + User->>CLI: sim cam start + CLI->>OldHost: SIGKILL + Note over OldHost, SHM: OldHost dies immediately.
May leave sequence lock as ODD. + + CLI->>NewHost: spawn(new_camera) + activate NewHost + NewHost->>SHM: fstat() to check file size + alt Resolution Matches + NewHost->>SHM: mmap() existing file + NewHost->>SHM: Check sequence lock + alt Sequence is Odd + NewHost->>SHM: Force sequence to Even (Repair) + end + else Resolution Changed + NewHost->>SHM: Delete old file + NewHost->>SHM: Create new file & mmap() + end + + NewHost->>SHM: Resume writing frames + Note over SHM, App: App polling never stopped + SHM->>App: App reads new frames instantly + deactivate NewHost +``` + +When the active camera is changed: +1. `sim-cli` sends a termination signal (SIGKILL) to the running `FrameHost` daemon. +2. `sim-cli` spins up a new `FrameHost` daemon for the new camera. +3. The shared memory `.frames` file on disk is kept intact unless the requested resolution changes. +4. The new `FrameHost` uses `fstat` to verify the file size. If it matches, it memory maps the existing file instead of recreating it. +5. If the previous `FrameHost` was killed mid-write (leaving `sequence` as an odd number), the new `FrameHost` detects this and forces the atomic `sequence` back to an even number to repair the lock state. +6. The iOS app, which is polling the memory map continuously, sees the `sequence` advance and resumes rendering new frames. From bef77e969d5a371e52b8fb907c737ca6654e4aef Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 22 Jul 2026 17:01:44 +0700 Subject: [PATCH 10/10] feat: update release --- .github/workflows/release.yml | 9 ++++++++ .golangci.yml | 10 +++++++++ cmd/cam.go | 39 ++++++++++++++++++++++++++--------- cmd/cam_dashboard.go | 39 ++++++++++++++++++++--------------- cmd/cam_test.go | 2 +- 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32422d6..8a17c67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,15 @@ jobs: - name: Build for multiple platforms run: make build-all + - name: Clean up cross-platform artifacts + run: | + if [ "$RUNNER_OS" == "macOS" ]; then + rm -f dist/sim-linux-amd64 dist/sim-windows-amd64.exe + else + rm -f dist/sim-darwin-amd64 dist/sim-darwin-arm64 + fi + shell: bash + - name: Create checksums (Linux only) if: matrix.os == 'ubuntu-latest' run: | diff --git a/.golangci.yml b/.golangci.yml index 351cddc..c9ba352 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -109,6 +109,15 @@ linters: - lll - nestif path: cmd/ + - linters: + - err113 + - nlreturn + - gocritic + - tagliatelle + - gocyclo + - testpackage + - cyclop + path: cmd/cam.*\.go - linters: - gci path: cmd/device.go @@ -121,6 +130,7 @@ linters: - revive - gosec - unparam + - staticcheck path: tests/ paths: - third_party$ diff --git a/cmd/cam.go b/cmd/cam.go index 873be8e..02fc4ad 100644 --- a/cmd/cam.go +++ b/cmd/cam.go @@ -42,8 +42,8 @@ func miniSimCamDir() string { return filepath.Join(cwd, "MiniSimCam") } -func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } -func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } +func shmPath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.frames", udid) } +func pidFilePath(udid string) string { return fmt.Sprintf("/tmp/minisimcam.%s.pid", udid) } func statusFilePath(udid string) string { primary := fmt.Sprintf("/tmp/minisimcam.%s.status", udid) if _, err := os.Stat(primary); err == nil { @@ -55,6 +55,7 @@ func statusFilePath(udid string) string { } return primary } + // resolveEmbeddedBinDir returns the directory containing extracted embedded // binaries, or "" if the embedded assets are stubs (dev builds without cam). // The result is cached after the first successful extraction. @@ -81,6 +82,7 @@ func frameHostBin(mscDir string) string { } return filepath.Join(mscDir, ".build", "release", "FrameHost") } + func injectorDylib(mscDir string) string { if binDir := resolveEmbeddedBinDir(); binDir != "" { extracted := filepath.Join(binDir, "MiniCamInject.dylib") @@ -130,6 +132,7 @@ func waitForFrameHostReady(c *exec.Cmd, statusPath string) error { return nil } case <-deadline.C: + _ = c.Process.Kill() return fmt.Errorf("FrameHost did not become ready within 5 seconds") } } @@ -399,7 +402,7 @@ Example: entXML = `com.apple.security.get-task-allow` } entPath := filepath.Join(os.TempDir(), "minisimcam_entitlements.plist") - if writeErr := os.WriteFile(entPath, []byte(entXML), 0644); writeErr != nil { + if writeErr := os.WriteFile(entPath, []byte(entXML), 0o644); writeErr != nil { PrintInfo(fmt.Sprintf(" Warning: cannot write entitlements file: %v", writeErr)) } else if signErr := exec.Command("codesign", "-f", "-s", "-", "--entitlements", entPath, appPath).Run(); signErr != nil { PrintInfo(fmt.Sprintf(" Warning: codesign re-sign failed: %v", signErr)) @@ -410,7 +413,8 @@ Example: } c := exec.Command("xcrun", "simctl", "launch", udid, camLaunchBundle) - c.Env = append(os.Environ(), + c.Env = append( + os.Environ(), "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="+dylib, "SIMCTL_CHILD_MINISIMCAM_PATH="+shm, "SIMCTL_CHILD_MINISIMCAM_FPS="+strconv.Itoa(fps), @@ -489,7 +493,8 @@ var camStatusCmd = &cobra.Command{ if st.Source == "disconnected" && st.LastDisconnectedAt != "" { lines = append(lines, fmt.Sprintf("⚠️ Disconnected at: %s", st.LastDisconnectedAt)) } - lines = append(lines, + lines = append( + lines, fmt.Sprintf("Resolution: %dx%d BGRA", st.Width, st.Height), fmt.Sprintf("Frame rate: %d fps", st.FPS), fmt.Sprintf("Frames produced: %d", st.FramesProduced), @@ -559,7 +564,7 @@ func setGlobalSimEnv(udid string, fps int) { mscDir := miniSimCamDir() dylib := injectorDylib(mscDir) shm := shmPath(udid) - + _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", dylib).Run() _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_PATH", shm).Run() _ = exec.Command("xcrun", "simctl", "spawn", udid, "launchctl", "setenv", "MINISIMCAM_FPS", strconv.Itoa(fps)).Run() @@ -583,13 +588,27 @@ func stopFrameHost(udid string) error { unsetGlobalSimEnv(udid) defer func() { - _ = os.Remove(pidPath) - _ = os.Remove(statusPath) - // We purposefully DO NOT remove the shm file so it can be reused seamlessly - // across FrameHost restarts. if udid != "" { + _ = os.Remove(pidPath) + _ = os.Remove(statusPath) _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.pid", udid)) _ = os.Remove(fmt.Sprintf("/tmp/minisimcam.%s.status", udid)) + } else { + if files, err := filepath.Glob("/tmp/minisimcam.*.pid"); err == nil { + for _, f := range files { + _ = os.Remove(f) + } + } + if files, err := filepath.Glob("/tmp/minisimcam.*.status"); err == nil { + for _, f := range files { + _ = os.Remove(f) + } + } + if files, err := filepath.Glob(filepath.Join(os.TempDir(), "minisimcam.*.status")); err == nil { + for _, f := range files { + _ = os.Remove(f) + } + } } }() diff --git a/cmd/cam_dashboard.go b/cmd/cam_dashboard.go index 7a16173..b1d90f3 100644 --- a/cmd/cam_dashboard.go +++ b/cmd/cam_dashboard.go @@ -175,7 +175,7 @@ func refreshCamDevicesCmd() tea.Cmd { func refreshCamStatusOnlyCmd(sims []camSimDevice) tea.Cmd { return func() tea.Msg { - var booted []camSimDevice + booted := make([]camSimDevice, 0, len(sims)) for _, dev := range sims { dev.CamStatus = "Stopped" dev.CamSource = "-" @@ -239,8 +239,6 @@ func (m camDashboardModel) Init() tea.Cmd { var cmds []tea.Cmd cmds = append(cmds, m.spinner.Tick, refreshCamDevicesCmd(), camTickCmd()) if len(m.simulators) > 0 { - m.selectedSimUDID = m.simulators[0].UDID - m.selectedSimName = m.simulators[0].Name cmds = append(cmds, fetchCamerasCmd(), fetchAppsCmd(m.selectedSimUDID)) } return tea.Batch(cmds...) @@ -258,7 +256,7 @@ func updateTableFocus(m *camDashboardModel) { m.simTable.Focus() m.cameraTable.Focus() m.appTable.Focus() - + if m.focused != paneSimulators { m.simTable.Blur() } @@ -334,11 +332,12 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd - if m.focused == paneSimulators { + switch m.focused { + case paneSimulators: m.simTable, cmd = m.simTable.Update(msg) - } else if m.focused == paneCameras { + case paneCameras: m.cameraTable, cmd = m.cameraTable.Update(msg) - } else if m.focused == paneApps { + case paneApps: m.appTable, cmd = m.appTable.Update(msg) } cmds = append(cmds, cmd) @@ -413,10 +412,11 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filterInput.Focus() } case "enter": - if m.focused == paneSimulators { + switch m.focused { + case paneSimulators: m.focused = paneCameras updateTableFocus(&m) - } else if m.focused == paneCameras { + case paneCameras: idx := m.cameraTable.Cursor() if idx >= 0 && idx < len(m.cameras) { camID := m.cameras[idx].UniqueID @@ -426,7 +426,7 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return startCameraForDevice(m.selectedSimUDID, true, "", camID, m.camWidth, m.camHeight) }, "Started camera feed for "+m.selectedSimName)) } - } else if m.focused == paneApps { + case paneApps: idx := m.appTable.Cursor() if idx >= 0 && idx < len(m.filteredApps) { bundleID := m.filteredApps[idx].BundleID @@ -490,7 +490,7 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { {Title: "Bundle ID", Width: aw2}, {Title: "Type", Width: aw3}, }) - + m.presetList.SetSize(60, 20) case camRefreshMsg: @@ -554,12 +554,12 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } oldSimUDID := m.selectedSimUDID - + if m.focused == paneSimulators && !m.showPopup { var tableCmd tea.Cmd m.simTable, tableCmd = m.simTable.Update(msg) cmds = append(cmds, tableCmd) - + idx := m.simTable.Cursor() if idx >= 0 && idx < len(m.simulators) { m.selectedSimUDID = m.simulators[idx].UDID @@ -585,7 +585,7 @@ func (m camDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m camDashboardModel) View() string { - activeColor := lipgloss.Color("62") // Purple highlight + activeColor := lipgloss.Color("62") // Purple highlight inactiveColor := lipgloss.Color("240") // Gray borderStyle := func(pane focusPane) lipgloss.Style { @@ -639,7 +639,7 @@ func (m camDashboardModel) View() string { 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(" ")) } @@ -682,7 +682,7 @@ func startCameraForDevice(udid string, useCamera bool, imagePath string, cameraI if err := c.Start(); err != nil { return err } - + setGlobalSimEnv(udid, DefaultCamFPS) return waitForFrameHostReady(c, statusFilePath(udid)) @@ -775,7 +775,7 @@ func runCamDashboard() error { ti.Placeholder = "Search..." ti.CharLimit = 156 ti.Width = 20 - + presetItems := []list.Item{ ResolutionPreset{title: "720p (16:9)", description: "1280 x 720 (Default)", width: 1280, height: 720}, ResolutionPreset{title: "1080p (16:9)", description: "1920 x 1080", width: 1920, height: 1080}, @@ -802,6 +802,11 @@ func runCamDashboard() error { camHeight: DefaultCamHeight, } + if len(initialSims) > 0 { + m.selectedSimUDID = initialSims[0].UDID + m.selectedSimName = initialSims[0].Name + } + if _, err := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil { return err } diff --git a/cmd/cam_test.go b/cmd/cam_test.go index 0c7d69e..350d265 100644 --- a/cmd/cam_test.go +++ b/cmd/cam_test.go @@ -77,7 +77,7 @@ func TestFrameHostFPS(t *testing.T) { if err != nil { t.Fatalf("marshal status: %v", err) } - if err := os.WriteFile(path, data, 0600); err != nil { + if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatalf("write status: %v", err) } if got := frameHostFPS(udid); got != 60 {