From 0d037cc30bf60124d0fe285645713d4d9cbf99d2 Mon Sep 17 00:00:00 2001
From: "expo-tuft[bot]" <288127324+expo-tuft[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:18:06 -0700
Subject: [PATCH 1/3] [menu-bar] Add Simulator camera MVP
Co-authored-by: gabrieldonadel <11707729+gabrieldonadel@users.noreply.github.com>
---
.../ExpoMenuBar-macOSRelease.entitlements | 5 +-
.../ExpoMenuBar.entitlements | 10 +
.../macos/ExpoMenuBar-macOS/Info.plist | 4 +-
.../ExpoMenuBar.xcodeproj/project.pbxproj | 23 +
apps/menu-bar/macos/Podfile.lock | 6 +
.../simulator-camera/expo-module.config.json | 6 +
.../modules/simulator-camera/index.ts | 24 +
.../ios/SimulatorCamera.podspec | 21 +
.../ios/SimulatorCameraModule.swift | 340 +++++++++++++
.../src/SimulatorCameraModule.ts | 5 +
.../src/SimulatorCameraModule.web.ts | 17 +
.../modules/simulator-camera/src/types.ts | 14 +
apps/menu-bar/package.json | 1 +
.../simulator-camera/OrbitSimulatorCamera.m | 448 ++++++++++++++++++
.../OrbitSimulatorCameraProtocol.h | 40 ++
.../simulator-camera/build-payload.sh | 35 ++
.../simulator-camera/tests/protocol_test.c | 28 ++
apps/menu-bar/simulator-camera/tests/run.sh | 9 +
apps/menu-bar/src/assets/icons/camera.svg | 3 +
apps/menu-bar/src/popover/Footer.tsx | 11 +-
apps/menu-bar/src/windows/SimulatorCamera.tsx | 127 +++++
apps/menu-bar/src/windows/index.ts | 13 +
22 files changed, 1186 insertions(+), 4 deletions(-)
create mode 100644 apps/menu-bar/modules/simulator-camera/expo-module.config.json
create mode 100644 apps/menu-bar/modules/simulator-camera/index.ts
create mode 100644 apps/menu-bar/modules/simulator-camera/ios/SimulatorCamera.podspec
create mode 100644 apps/menu-bar/modules/simulator-camera/ios/SimulatorCameraModule.swift
create mode 100644 apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.ts
create mode 100644 apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.web.ts
create mode 100644 apps/menu-bar/modules/simulator-camera/src/types.ts
create mode 100644 apps/menu-bar/simulator-camera/OrbitSimulatorCamera.m
create mode 100644 apps/menu-bar/simulator-camera/OrbitSimulatorCameraProtocol.h
create mode 100755 apps/menu-bar/simulator-camera/build-payload.sh
create mode 100644 apps/menu-bar/simulator-camera/tests/protocol_test.c
create mode 100755 apps/menu-bar/simulator-camera/tests/run.sh
create mode 100644 apps/menu-bar/src/assets/icons/camera.svg
create mode 100644 apps/menu-bar/src/windows/SimulatorCamera.tsx
diff --git a/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar-macOSRelease.entitlements b/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar-macOSRelease.entitlements
index 0c67376e..6cfee69b 100644
--- a/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar-macOSRelease.entitlements
+++ b/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar-macOSRelease.entitlements
@@ -1,5 +1,8 @@
-
+
+ com.apple.security.device.camera
+
+
diff --git a/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar.entitlements b/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar.entitlements
index 625af03d..a5c94aea 100644
--- a/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar.entitlements
+++ b/apps/menu-bar/macos/ExpoMenuBar-macOS/ExpoMenuBar.entitlements
@@ -4,6 +4,16 @@
com.apple.security.app-sandbox
+ com.apple.security.device.camera
+
+ com.apple.security.temporary-exception.files.home-relative-path.read-write
+
+ /.lldbinit-Xcode
+
+ com.apple.security.temporary-exception.files.absolute-path.read-write
+
+ /private/tmp/dev.expo.orbit.simulator-camera.frames
+
com.apple.security.files.user-selected.read-only
com.apple.security.network.client
diff --git a/apps/menu-bar/macos/ExpoMenuBar-macOS/Info.plist b/apps/menu-bar/macos/ExpoMenuBar-macOS/Info.plist
index f3c728fa..b71a7dd4 100644
--- a/apps/menu-bar/macos/ExpoMenuBar-macOS/Info.plist
+++ b/apps/menu-bar/macos/ExpoMenuBar-macOS/Info.plist
@@ -90,6 +90,8 @@
+ NSCameraUsageDescription
+ Expo Orbit uses your Mac camera when streaming it to an iOS Simulator app.
NSMainStoryboardFile
Main
NSPrincipalClass
@@ -103,4 +105,4 @@
SUFeedURL
https://raw.githubusercontent.com/expo/orbit/main/appcast.xml
-
\ No newline at end of file
+
diff --git a/apps/menu-bar/macos/ExpoMenuBar.xcodeproj/project.pbxproj b/apps/menu-bar/macos/ExpoMenuBar.xcodeproj/project.pbxproj
index 9eef56ed..5fdcf16e 100644
--- a/apps/menu-bar/macos/ExpoMenuBar.xcodeproj/project.pbxproj
+++ b/apps/menu-bar/macos/ExpoMenuBar.xcodeproj/project.pbxproj
@@ -275,6 +275,7 @@
C091563F2D7787B200FC1EB6 /* Archive and sign CLI */,
381D8A6F24576A6C00465D17 /* Start Packager */,
63FA8EA00DD165DC6FA0B487 /* [Expo] Configure project */,
+ A1B2C3D4E5F60718293A4B5C /* Build Simulator Camera payload */,
514201452437B4B30078DB4F /* Sources */,
514201462437B4B30078DB4F /* Frameworks */,
514201472437B4B30078DB4F /* Resources */,
@@ -473,6 +474,28 @@
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-ExpoMenuBar-macOS/expo-configure-project.sh\"\n";
};
+ A1B2C3D4E5F60718293A4B5C /* Build Simulator Camera payload */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "$(SRCROOT)/../simulator-camera/build-payload.sh",
+ "$(SRCROOT)/../simulator-camera/OrbitSimulatorCamera.m",
+ "$(SRCROOT)/../simulator-camera/OrbitSimulatorCameraProtocol.h",
+ );
+ name = "Build Simulator Camera payload";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/OrbitSimulatorCamera.dylib",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/bash;
+ shellScript = "\"$SRCROOT/../simulator-camera/build-payload.sh\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n";
+ };
C091563F2D7787B200FC1EB6 /* Archive and sign CLI */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
diff --git a/apps/menu-bar/macos/Podfile.lock b/apps/menu-bar/macos/Podfile.lock
index edd3e040..0451af86 100644
--- a/apps/menu-bar/macos/Podfile.lock
+++ b/apps/menu-bar/macos/Podfile.lock
@@ -2440,6 +2440,8 @@ PODS:
- SocketRocket
- Yoga
- Rudder (2.4.3)
+ - SimulatorCamera (1.0.0):
+ - ExpoModulesCore
- SocketRocket (0.7.1)
- Sparkle (2.5.2)
- Swifter (1.5.0)
@@ -2538,6 +2540,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native-macos/ReactCommon`)
- RNRudder (from `../modules/rudder/ios`)
- RNSVG (from `../../../node_modules/react-native-svg`)
+ - SimulatorCamera (from `../modules/simulator-camera/ios`)
- SocketRocket (~> 0.7.1)
- Swifter (~> 1.5.0)
- Yoga (from `../../../node_modules/react-native-macos/ReactCommon/yoga`)
@@ -2733,6 +2736,8 @@ EXTERNAL SOURCES:
:path: "../modules/rudder/ios"
RNSVG:
:path: "../../../node_modules/react-native-svg"
+ SimulatorCamera:
+ :path: "../modules/simulator-camera/ios"
Yoga:
:path: "../../../node_modules/react-native-macos/ReactCommon/yoga"
@@ -2829,6 +2834,7 @@ SPEC CHECKSUMS:
RNRudder: 43aceecc499fe6b69df88f5fb549bc344e4c9894
RNSVG: 681be694d501c0af971615811d4f2ea9baf58966
Rudder: 6d2c0775d5b606e05e00670615ee7f9c0c6d2813
+ SimulatorCamera: 215ea0bef47b2e24e66c2f51bc6cd18f591ff7d0
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Sparkle: 0e335e5c704ac28dbea18cc0383e15a3927c28dc
Swifter: e71dd674404923d7f03ebb03f3f222d1c570bc8e
diff --git a/apps/menu-bar/modules/simulator-camera/expo-module.config.json b/apps/menu-bar/modules/simulator-camera/expo-module.config.json
new file mode 100644
index 00000000..0bd14f55
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["apple", "web"],
+ "apple": {
+ "modules": ["SimulatorCameraModule"]
+ }
+}
diff --git a/apps/menu-bar/modules/simulator-camera/index.ts b/apps/menu-bar/modules/simulator-camera/index.ts
new file mode 100644
index 00000000..c44533cd
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/index.ts
@@ -0,0 +1,24 @@
+import SimulatorCameraModule from './src/SimulatorCameraModule';
+import { SimulatorCameraStatus } from './src/types';
+
+export type { SimulatorCameraStatus } from './src/types';
+
+export function getStatusAsync(): Promise {
+ return SimulatorCameraModule.getStatus();
+}
+
+export function installAsync(): Promise {
+ return SimulatorCameraModule.install();
+}
+
+export function uninstallAsync(): Promise {
+ return SimulatorCameraModule.uninstall();
+}
+
+export function startAsync(): Promise {
+ return SimulatorCameraModule.start();
+}
+
+export function stopAsync(): Promise {
+ return SimulatorCameraModule.stop();
+}
diff --git a/apps/menu-bar/modules/simulator-camera/ios/SimulatorCamera.podspec b/apps/menu-bar/modules/simulator-camera/ios/SimulatorCamera.podspec
new file mode 100644
index 00000000..38919d3e
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/ios/SimulatorCamera.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+ s.name = 'SimulatorCamera'
+ s.version = '1.0.0'
+ s.summary = 'Streams a Mac camera into iOS Simulator apps'
+ s.description = 'Orbit host and LLDB integration for the Simulator Camera feature.'
+ s.author = 'Expo'
+ s.homepage = 'https://github.com/expo/orbit'
+ s.platform = :osx, '14.0'
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+ s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
+end
diff --git a/apps/menu-bar/modules/simulator-camera/ios/SimulatorCameraModule.swift b/apps/menu-bar/modules/simulator-camera/ios/SimulatorCameraModule.swift
new file mode 100644
index 00000000..1f8d32a9
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/ios/SimulatorCameraModule.swift
@@ -0,0 +1,340 @@
+import AVFoundation
+import ExpoModulesCore
+
+private let framePath = "/private/tmp/dev.expo.orbit.simulator-camera.frames"
+private let frameMagic: UInt32 = 0x4f524243
+private let frameVersion: UInt32 = 1
+private let headerSize = 4096
+private let maxWidth = 1920
+private let maxHeight = 1080
+private let bytesPerPixel = 4
+private let slotSize = maxWidth * maxHeight * bytesPerPixel
+private let fileSize = headerSize + (slotSize * 2)
+private let lldbBlockStart = "# >>> Expo Orbit Simulator Camera >>>"
+private let lldbBlockEnd = "# <<< Expo Orbit Simulator Camera <<<"
+
+private final class SharedFrameWriter {
+ private var descriptor: Int32 = -1
+ private var memory: UnsafeMutableRawPointer?
+ private var sequence: UInt64 = 0
+
+ init() throws {
+ descriptor = Darwin.open(framePath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
+ guard descriptor >= 0 else {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ guard ftruncate(descriptor, off_t(fileSize)) == 0 else {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ let mapped = mmap(nil, fileSize, PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0)
+ guard mapped != MAP_FAILED else {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ memory = mapped
+ }
+
+ deinit {
+ if let memory {
+ munmap(memory, fileSize)
+ }
+ if descriptor >= 0 {
+ Darwin.close(descriptor)
+ }
+ }
+
+ func write(_ pixelBuffer: CVPixelBuffer, timestamp: CMTime) {
+ guard let memory else { return }
+ CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
+
+ guard let source = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
+ let width = min(CVPixelBufferGetWidth(pixelBuffer), maxWidth)
+ let height = min(CVPixelBufferGetHeight(pixelBuffer), maxHeight)
+ let sourceBytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
+ let destinationBytesPerRow = width * bytesPerPixel
+ sequence &+= 1
+ let activeSlot = Int(sequence % 2)
+
+ flock(descriptor, LOCK_EX)
+ defer { flock(descriptor, LOCK_UN) }
+
+ let destination = memory.advanced(by: headerSize + (activeSlot * slotSize))
+ for row in 0.. String {
+ guard let start = contents.range(of: lldbBlockStart),
+ let endMarker = contents.range(of: lldbBlockEnd, range: start.upperBound.. String {
+ "\"" + value.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"") + "\""
+ }
+
+ private static func pythonQuoted(_ value: String) -> String {
+ let data = try! JSONEncoder().encode(value)
+ return String(data: data, encoding: .utf8)!
+ }
+
+ private static func lldbScript(dylibPath: String) -> String {
+ """
+ import lldb
+
+ DYLIB_PATH = \(pythonQuoted(dylibPath))
+
+ def _orbit_load_simulator_camera(frame, bp_loc, _dict):
+ target = frame.GetThread().GetProcess().GetTarget()
+ triple = target.GetTriple() or ""
+ if "simulator" not in triple.lower():
+ return False
+ error = lldb.SBError()
+ frame.GetThread().GetProcess().LoadImage(lldb.SBFileSpec(DYLIB_PATH), error)
+ if error.Fail():
+ print("Expo Orbit Simulator Camera: " + error.GetCString())
+ return False
+
+ def __lldb_init_module(debugger, _dict):
+ target = debugger.GetSelectedTarget()
+ breakpoint = target.BreakpointCreateByName("main")
+ breakpoint.SetOneShot(True)
+ breakpoint.SetScriptCallbackFunction(
+ "orbit_simulator_camera._orbit_load_simulator_camera"
+ )
+ """
+ }
+}
+
+public class SimulatorCameraModule: Module {
+ private let streamer = CameraStreamer()
+ private var lastError: String?
+
+ public func definition() -> ModuleDefinition {
+ Name("SimulatorCamera")
+
+ AsyncFunction("getStatus") { status() }
+
+ AsyncFunction("install") { () -> [String: Any] in
+ do {
+ try InjectionInstaller.install()
+ lastError = nil
+ } catch {
+ lastError = error.localizedDescription
+ }
+ return status()
+ }
+
+ AsyncFunction("uninstall") { () -> [String: Any] in
+ streamer.stop()
+ do {
+ try InjectionInstaller.uninstall()
+ lastError = nil
+ } catch {
+ lastError = error.localizedDescription
+ }
+ return status()
+ }
+
+ AsyncFunction("start") { (promise: Promise) in
+ let beginStreaming = { [weak self] in
+ guard let self else { return }
+ do {
+ try streamer.start()
+ lastError = nil
+ } catch {
+ lastError = error.localizedDescription
+ }
+ promise.resolve(status())
+ }
+
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ beginStreaming()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ DispatchQueue.main.async {
+ if granted {
+ beginStreaming()
+ } else {
+ self.lastError = "Camera access was denied. Enable it in System Settings."
+ promise.resolve(self.status())
+ }
+ }
+ }
+ default:
+ lastError = "Camera access was denied. Enable it in System Settings."
+ promise.resolve(status())
+ }
+ }.runOnQueue(.main)
+
+ AsyncFunction("stop") { () -> [String: Any] in
+ streamer.stop()
+ lastError = nil
+ return status()
+ }.runOnQueue(.main)
+
+ OnDestroy {
+ streamer.stop()
+ }
+ }
+
+ private func status() -> [String: Any] {
+ var result: [String: Any] = [
+ "installed": InjectionInstaller.isInstalled,
+ "streaming": streamer.isStreaming,
+ ]
+ if let cameraName = streamer.cameraName { result["cameraName"] = cameraName }
+ if let lastError { result["error"] = lastError }
+ return result
+ }
+}
diff --git a/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.ts b/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.ts
new file mode 100644
index 00000000..92c70254
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.ts
@@ -0,0 +1,5 @@
+import { requireNativeModule } from 'expo-modules-core';
+
+import { NativeSimulatorCameraModule } from './types';
+
+export default requireNativeModule('SimulatorCamera');
diff --git a/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.web.ts b/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.web.ts
new file mode 100644
index 00000000..3f70477a
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/src/SimulatorCameraModule.web.ts
@@ -0,0 +1,17 @@
+import { NativeSimulatorCameraModule, SimulatorCameraStatus } from './types';
+
+const unsupportedStatus: SimulatorCameraStatus = {
+ installed: false,
+ streaming: false,
+ error: 'Simulator Camera is only available in Expo Orbit for macOS.',
+};
+
+const SimulatorCameraModule: NativeSimulatorCameraModule = {
+ getStatus: async () => unsupportedStatus,
+ install: async () => unsupportedStatus,
+ uninstall: async () => unsupportedStatus,
+ start: async () => unsupportedStatus,
+ stop: async () => unsupportedStatus,
+};
+
+export default SimulatorCameraModule;
diff --git a/apps/menu-bar/modules/simulator-camera/src/types.ts b/apps/menu-bar/modules/simulator-camera/src/types.ts
new file mode 100644
index 00000000..df68f8b8
--- /dev/null
+++ b/apps/menu-bar/modules/simulator-camera/src/types.ts
@@ -0,0 +1,14 @@
+export type SimulatorCameraStatus = {
+ installed: boolean;
+ streaming: boolean;
+ cameraName?: string;
+ error?: string;
+};
+
+export interface NativeSimulatorCameraModule {
+ getStatus(): Promise;
+ install(): Promise;
+ uninstall(): Promise;
+ start(): Promise;
+ stop(): Promise;
+}
diff --git a/apps/menu-bar/package.json b/apps/menu-bar/package.json
index b3ee11a6..032e47f2 100644
--- a/apps/menu-bar/package.json
+++ b/apps/menu-bar/package.json
@@ -13,6 +13,7 @@
"lint": "eslint .",
"start": "expo start",
"test": "jest",
+ "test:simulator-camera": "bash simulator-camera/tests/run.sh",
"update-cli": "mkdir -p cli && cp -R ../cli/dist/ cli/",
"gql": "graphql-codegen --config codegen.ts",
"typecheck": "tsc"
diff --git a/apps/menu-bar/simulator-camera/OrbitSimulatorCamera.m b/apps/menu-bar/simulator-camera/OrbitSimulatorCamera.m
new file mode 100644
index 00000000..f8045921
--- /dev/null
+++ b/apps/menu-bar/simulator-camera/OrbitSimulatorCamera.m
@@ -0,0 +1,448 @@
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+
+#import "OrbitSimulatorCameraProtocol.h"
+
+static const void *OrbitFakeSessionKey = &OrbitFakeSessionKey;
+static const void *OrbitSessionOutputsKey = &OrbitSessionOutputsKey;
+static const void *OrbitSessionRunningKey = &OrbitSessionRunningKey;
+static const void *OrbitPreviewSessionKey = &OrbitPreviewSessionKey;
+static dispatch_source_t OrbitFrameTimer;
+
+@interface OrbitCameraFormat : NSObject
+@property(nonatomic, readonly) CMFormatDescriptionRef formatDescription;
+@property(nonatomic, readonly) NSArray *videoSupportedFrameRateRanges;
+@end
+
+@implementation OrbitCameraFormat {
+ CMFormatDescriptionRef _formatDescription;
+}
+
+- (instancetype)init {
+ if ((self = [super init])) {
+ CMVideoFormatDescriptionCreate(kCFAllocatorDefault, kCVPixelFormatType_32BGRA,
+ 1280, 720, NULL, &_formatDescription);
+ }
+ return self;
+}
+
+- (void)dealloc {
+ if (_formatDescription) {
+ CFRelease(_formatDescription);
+ }
+}
+
+- (CMFormatDescriptionRef)formatDescription {
+ return _formatDescription;
+}
+
+- (NSArray *)videoSupportedFrameRateRanges {
+ return @[];
+}
+@end
+
+@interface OrbitCameraDevice : NSObject
+@property(nonatomic, readonly) NSString *uniqueID;
+@property(nonatomic, readonly) NSString *localizedName;
+@property(nonatomic, readonly) AVCaptureDevicePosition position;
+@property(nonatomic, readonly) AVCaptureDeviceType deviceType;
+@property(nonatomic, readonly) NSArray *formats;
+@property(nonatomic, strong) id activeFormat;
+@property(nonatomic) CMTime activeVideoMinFrameDuration;
+@property(nonatomic) CMTime activeVideoMaxFrameDuration;
+@property(nonatomic, readonly, getter=isConnected) BOOL connected;
+@property(nonatomic, readonly, getter=isSuspended) BOOL suspended;
+@end
+
+static OrbitCameraDevice *OrbitCameraDeviceInstance(void);
+static NSHashTable *OrbitCaptureSessions(void);
+
+@implementation OrbitCameraDevice
+
+- (instancetype)init {
+ if ((self = [super init])) {
+ _activeFormat = [[OrbitCameraFormat alloc] init];
+ _activeVideoMinFrameDuration = CMTimeMake(1, 30);
+ _activeVideoMaxFrameDuration = CMTimeMake(1, 30);
+ }
+ return self;
+}
+
+- (NSString *)uniqueID { return @"dev.expo.orbit.simulator-camera"; }
+- (NSString *)localizedName { return @"Expo Orbit Camera"; }
+- (AVCaptureDevicePosition)position { return AVCaptureDevicePositionBack; }
+- (AVCaptureDeviceType)deviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; }
+- (NSArray *)formats { return @[ self.activeFormat ]; }
+- (BOOL)isConnected { return YES; }
+- (BOOL)isSuspended { return NO; }
+- (BOOL)hasMediaType:(AVMediaType)mediaType { return [mediaType isEqualToString:AVMediaTypeVideo]; }
+- (BOOL)supportsAVCaptureSessionPreset:(AVCaptureSessionPreset)preset { return YES; }
+- (BOOL)lockForConfiguration:(NSError **)error { return YES; }
+- (void)unlockForConfiguration {}
+- (BOOL)isFocusModeSupported:(AVCaptureFocusMode)mode { return NO; }
+- (BOOL)isExposureModeSupported:(AVCaptureExposureMode)mode { return NO; }
+- (BOOL)isTorchModeSupported:(AVCaptureTorchMode)mode { return NO; }
+- (BOOL)hasTorch { return NO; }
+- (BOOL)hasFlash { return NO; }
+@end
+
+@interface OrbitCameraDiscoverySession : NSObject
+@property(nonatomic, readonly) NSArray *devices;
+@end
+@implementation OrbitCameraDiscoverySession
+- (NSArray *)devices { return @[ OrbitCameraDeviceInstance() ]; }
+@end
+
+@interface OrbitCameraInput : NSObject
+@property(nonatomic, readonly) id device;
+@property(nonatomic, readonly) NSArray *ports;
+@end
+@implementation OrbitCameraInput
+- (id)device { return OrbitCameraDeviceInstance(); }
+- (NSArray *)ports { return @[]; }
+@end
+
+@interface OrbitCameraConnection : NSObject
+@property(nonatomic, getter=isEnabled) BOOL enabled;
+@property(nonatomic, readonly, getter=isActive) BOOL active;
+@end
+@implementation OrbitCameraConnection
+- (instancetype)init { if ((self = [super init])) _enabled = YES; return self; }
+- (BOOL)isActive { return YES; }
+@end
+
+static OrbitCameraDevice *OrbitCameraDeviceInstance(void) {
+ static OrbitCameraDevice *device;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{ device = [[OrbitCameraDevice alloc] init]; });
+ return device;
+}
+
+static BOOL OrbitIsFakeDevice(id device) {
+ return [device isKindOfClass:[OrbitCameraDevice class]];
+}
+
+static void OrbitSwizzleClassMethod(Class cls, SEL original, SEL replacement) {
+ Method first = class_getClassMethod(cls, original);
+ Method second = class_getClassMethod(cls, replacement);
+ if (first && second) method_exchangeImplementations(first, second);
+}
+
+static void OrbitSwizzleInstanceMethod(Class cls, SEL original, SEL replacement) {
+ Method first = class_getInstanceMethod(cls, original);
+ Method second = class_getInstanceMethod(cls, replacement);
+ if (first && second) method_exchangeImplementations(first, second);
+}
+
+@interface AVCaptureDevice (OrbitSimulatorCamera)
+@end
+@implementation AVCaptureDevice (OrbitSimulatorCamera)
++ (AVCaptureDevice *)orbit_defaultDeviceWithMediaType:(AVMediaType)mediaType {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) return (id)OrbitCameraDeviceInstance();
+ return [self orbit_defaultDeviceWithMediaType:mediaType];
+}
++ (AVCaptureDevice *)orbit_defaultDeviceWithDeviceType:(AVCaptureDeviceType)deviceType
+ mediaType:(AVMediaType)mediaType
+ position:(AVCaptureDevicePosition)position {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) return (id)OrbitCameraDeviceInstance();
+ return [self orbit_defaultDeviceWithDeviceType:deviceType mediaType:mediaType position:position];
+}
++ (NSArray *)orbit_devicesWithMediaType:(AVMediaType)mediaType {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) return @[ (id)OrbitCameraDeviceInstance() ];
+ return [self orbit_devicesWithMediaType:mediaType];
+}
++ (AVAuthorizationStatus)orbit_authorizationStatusForMediaType:(AVMediaType)mediaType {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) return AVAuthorizationStatusAuthorized;
+ return [self orbit_authorizationStatusForMediaType:mediaType];
+}
++ (void)orbit_requestAccessForMediaType:(AVMediaType)mediaType
+ completionHandler:(void (^)(BOOL granted))handler {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ dispatch_async(dispatch_get_main_queue(), ^{ handler(YES); });
+ return;
+ }
+ [self orbit_requestAccessForMediaType:mediaType completionHandler:handler];
+}
+@end
+
+@interface AVCaptureDeviceDiscoverySession (OrbitSimulatorCamera)
+@end
+@implementation AVCaptureDeviceDiscoverySession (OrbitSimulatorCamera)
++ (AVCaptureDeviceDiscoverySession *)orbit_discoverySessionWithDeviceTypes:(NSArray *)deviceTypes
+ mediaType:(AVMediaType)mediaType
+ position:(AVCaptureDevicePosition)position {
+ if ([mediaType isEqualToString:AVMediaTypeVideo]) {
+ return (id)[[OrbitCameraDiscoverySession alloc] init];
+ }
+ return [self orbit_discoverySessionWithDeviceTypes:deviceTypes
+ mediaType:mediaType
+ position:position];
+}
+@end
+
+@interface AVCaptureDeviceInput (OrbitSimulatorCamera)
+@end
+@implementation AVCaptureDeviceInput (OrbitSimulatorCamera)
++ (instancetype)orbit_deviceInputWithDevice:(AVCaptureDevice *)device error:(NSError **)error {
+ if (OrbitIsFakeDevice(device)) return (id)[[OrbitCameraInput alloc] init];
+ return [self orbit_deviceInputWithDevice:device error:error];
+}
+- (instancetype)orbit_initWithDevice:(AVCaptureDevice *)device error:(NSError **)error {
+ if (OrbitIsFakeDevice(device)) return (id)[[OrbitCameraInput alloc] init];
+ return [self orbit_initWithDevice:device error:error];
+}
+@end
+
+@interface AVCaptureSession (OrbitSimulatorCamera)
+@end
+@implementation AVCaptureSession (OrbitSimulatorCamera)
+- (BOOL)orbit_canAddInput:(AVCaptureInput *)input {
+ return [input isKindOfClass:[OrbitCameraInput class]] || [self orbit_canAddInput:input];
+}
+- (void)orbit_addInput:(AVCaptureInput *)input {
+ if ([input isKindOfClass:[OrbitCameraInput class]]) {
+ objc_setAssociatedObject(self, OrbitFakeSessionKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ return;
+ }
+ [self orbit_addInput:input];
+}
+- (BOOL)orbit_canAddOutput:(AVCaptureOutput *)output {
+ if ([objc_getAssociatedObject(self, OrbitFakeSessionKey) boolValue]) return YES;
+ return [self orbit_canAddOutput:output];
+}
+- (void)orbit_addOutput:(AVCaptureOutput *)output {
+ if ([objc_getAssociatedObject(self, OrbitFakeSessionKey) boolValue]) {
+ NSMutableArray *outputs = objc_getAssociatedObject(self, OrbitSessionOutputsKey);
+ if (!outputs) {
+ outputs = [NSMutableArray array];
+ objc_setAssociatedObject(self, OrbitSessionOutputsKey, outputs, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ }
+ [outputs addObject:output];
+ return;
+ }
+ [self orbit_addOutput:output];
+}
+- (void)orbit_startRunning {
+ if ([objc_getAssociatedObject(self, OrbitFakeSessionKey) boolValue]) {
+ objc_setAssociatedObject(self, OrbitSessionRunningKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ @synchronized(OrbitCaptureSessions()) { [OrbitCaptureSessions() addObject:self]; }
+ return;
+ }
+ [self orbit_startRunning];
+}
+- (void)orbit_stopRunning {
+ if ([objc_getAssociatedObject(self, OrbitFakeSessionKey) boolValue]) {
+ objc_setAssociatedObject(self, OrbitSessionRunningKey, @NO, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ return;
+ }
+ [self orbit_stopRunning];
+}
+- (BOOL)orbit_isRunning {
+ if ([objc_getAssociatedObject(self, OrbitFakeSessionKey) boolValue]) {
+ return [objc_getAssociatedObject(self, OrbitSessionRunningKey) boolValue];
+ }
+ return [self orbit_isRunning];
+}
+@end
+
+static NSHashTable *OrbitCaptureSessions(void) {
+ static NSHashTable *sessions;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{ sessions = [NSHashTable weakObjectsHashTable]; });
+ return sessions;
+}
+
+static NSHashTable *OrbitPreviewLayers(void) {
+ static NSHashTable *layers;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{ layers = [NSHashTable weakObjectsHashTable]; });
+ return layers;
+}
+
+@interface AVCaptureVideoPreviewLayer (OrbitSimulatorCamera)
+@end
+@implementation AVCaptureVideoPreviewLayer (OrbitSimulatorCamera)
+- (void)orbit_setSession:(AVCaptureSession *)session {
+ if ([objc_getAssociatedObject(session, OrbitFakeSessionKey) boolValue]) {
+ objc_setAssociatedObject(self, OrbitPreviewSessionKey, session, OBJC_ASSOCIATION_ASSIGN);
+ @synchronized(OrbitPreviewLayers()) { [OrbitPreviewLayers() addObject:self]; }
+ self.contentsGravity = kCAGravityResizeAspectFill;
+ return;
+ }
+ [self orbit_setSession:session];
+}
+@end
+
+static CMSampleBufferRef OrbitCopyLatestSampleBuffer(void) {
+ static uint64_t lastSequence = 0;
+ int descriptor = open(ORBIT_SIMULATOR_CAMERA_PATH, O_RDONLY);
+ if (descriptor < 0) return NULL;
+ void *memory = mmap(NULL, ORBIT_SIMULATOR_CAMERA_FILE_SIZE, PROT_READ, MAP_SHARED,
+ descriptor, 0);
+ if (memory == MAP_FAILED) { close(descriptor); return NULL; }
+
+ flock(descriptor, LOCK_SH);
+ OrbitSimulatorCameraFrameHeader header = *(OrbitSimulatorCameraFrameHeader *)memory;
+ if (header.magic != ORBIT_SIMULATOR_CAMERA_MAGIC ||
+ header.version != ORBIT_SIMULATOR_CAMERA_VERSION ||
+ header.sequence == lastSequence || header.width == 0 || header.height == 0 ||
+ header.width > ORBIT_SIMULATOR_CAMERA_MAX_WIDTH ||
+ header.height > ORBIT_SIMULATOR_CAMERA_MAX_HEIGHT || header.activeSlot > 1) {
+ flock(descriptor, LOCK_UN);
+ munmap(memory, ORBIT_SIMULATOR_CAMERA_FILE_SIZE);
+ close(descriptor);
+ return NULL;
+ }
+
+ CVPixelBufferRef pixelBuffer = NULL;
+ NSDictionary *attributes = @{ (id)kCVPixelBufferIOSurfacePropertiesKey : @{} };
+ CVReturn result = CVPixelBufferCreate(kCFAllocatorDefault, header.width, header.height,
+ kCVPixelFormatType_32BGRA,
+ (__bridge CFDictionaryRef)attributes, &pixelBuffer);
+ if (result == kCVReturnSuccess && pixelBuffer) {
+ CVPixelBufferLockBaseAddress(pixelBuffer, 0);
+ uint8_t *destination = CVPixelBufferGetBaseAddress(pixelBuffer);
+ size_t destinationBytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer);
+ uint8_t *source = (uint8_t *)memory + ORBIT_SIMULATOR_CAMERA_HEADER_SIZE +
+ (header.activeSlot * ORBIT_SIMULATOR_CAMERA_SLOT_SIZE);
+ size_t copyBytes = MIN(destinationBytesPerRow, header.bytesPerRow);
+ for (uint32_t row = 0; row < header.height; row++) {
+ memcpy(destination + row * destinationBytesPerRow,
+ source + row * header.bytesPerRow, copyBytes);
+ }
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
+ lastSequence = header.sequence;
+ }
+ flock(descriptor, LOCK_UN);
+ munmap(memory, ORBIT_SIMULATOR_CAMERA_FILE_SIZE);
+ close(descriptor);
+ if (!pixelBuffer) return NULL;
+
+ CMVideoFormatDescriptionRef format = NULL;
+ CMSampleBufferRef sample = NULL;
+ CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, &format);
+ CMSampleTimingInfo timing = {
+ .duration = CMTimeMake(1, 30),
+ .presentationTimeStamp = CMClockGetTime(CMClockGetHostTimeClock()),
+ .decodeTimeStamp = kCMTimeInvalid,
+ };
+ CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixelBuffer, format,
+ &timing, &sample);
+ if (format) CFRelease(format);
+ CFRelease(pixelBuffer);
+ return sample;
+}
+
+static void OrbitDeliverFrame(CMSampleBufferRef sample) {
+ CVPixelBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sample);
+ CIImage *image = [CIImage imageWithCVPixelBuffer:imageBuffer];
+ static CIContext *context;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{ context = [CIContext contextWithOptions:nil]; });
+ CGImageRef cgImage = [context createCGImage:image fromRect:image.extent];
+
+ NSArray *sessions;
+ @synchronized(OrbitCaptureSessions()) { sessions = OrbitCaptureSessions().allObjects; }
+ for (AVCaptureSession *session in sessions) {
+ if (![objc_getAssociatedObject(session, OrbitSessionRunningKey) boolValue]) continue;
+ NSArray *outputs = [objc_getAssociatedObject(session, OrbitSessionOutputsKey) copy];
+ for (AVCaptureOutput *output in outputs) {
+ if (![output isKindOfClass:[AVCaptureVideoDataOutput class]]) continue;
+ AVCaptureVideoDataOutput *videoOutput = (AVCaptureVideoDataOutput *)output;
+ id delegate = videoOutput.sampleBufferDelegate;
+ dispatch_queue_t callbackQueue = videoOutput.sampleBufferCallbackQueue;
+ if (!delegate || !callbackQueue ||
+ ![delegate respondsToSelector:@selector(captureOutput:didOutputSampleBuffer:fromConnection:)]) {
+ continue;
+ }
+ CFRetain(sample);
+ dispatch_async(callbackQueue, ^{
+ [delegate captureOutput:videoOutput
+ didOutputSampleBuffer:sample
+ fromConnection:(id)[[OrbitCameraConnection alloc] init]];
+ CFRelease(sample);
+ });
+ }
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSArray *layers;
+ @synchronized(OrbitPreviewLayers()) { layers = OrbitPreviewLayers().allObjects; }
+ for (AVCaptureVideoPreviewLayer *layer in layers) {
+ AVCaptureSession *session = objc_getAssociatedObject(layer, OrbitPreviewSessionKey);
+ if ([objc_getAssociatedObject(session, OrbitSessionRunningKey) boolValue]) {
+ layer.contents = (__bridge id)cgImage;
+ }
+ }
+ CGImageRelease(cgImage);
+ });
+}
+
+static void OrbitStartFramePump(void) {
+ dispatch_queue_t queue = dispatch_queue_create("dev.expo.orbit.simulator-camera.frames", 0);
+ OrbitFrameTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
+ dispatch_source_set_timer(OrbitFrameTimer, DISPATCH_TIME_NOW, NSEC_PER_SEC / 30,
+ NSEC_PER_MSEC * 4);
+ dispatch_source_set_event_handler(OrbitFrameTimer, ^{
+ CMSampleBufferRef sample = OrbitCopyLatestSampleBuffer();
+ if (!sample) return;
+ OrbitDeliverFrame(sample);
+ CFRelease(sample);
+ });
+ dispatch_resume(OrbitFrameTimer);
+}
+
+__attribute__((constructor)) static void OrbitSimulatorCameraBootstrap(void) {
+ @autoreleasepool {
+ OrbitSwizzleClassMethod([AVCaptureDevice class], @selector(defaultDeviceWithMediaType:),
+ @selector(orbit_defaultDeviceWithMediaType:));
+ OrbitSwizzleClassMethod([AVCaptureDevice class],
+ @selector(defaultDeviceWithDeviceType:mediaType:position:),
+ @selector(orbit_defaultDeviceWithDeviceType:mediaType:position:));
+ OrbitSwizzleClassMethod([AVCaptureDevice class], @selector(devicesWithMediaType:),
+ @selector(orbit_devicesWithMediaType:));
+ OrbitSwizzleClassMethod([AVCaptureDevice class],
+ @selector(authorizationStatusForMediaType:),
+ @selector(orbit_authorizationStatusForMediaType:));
+ OrbitSwizzleClassMethod([AVCaptureDevice class],
+ @selector(requestAccessForMediaType:completionHandler:),
+ @selector(orbit_requestAccessForMediaType:completionHandler:));
+ OrbitSwizzleClassMethod([AVCaptureDeviceDiscoverySession class],
+ @selector(discoverySessionWithDeviceTypes:mediaType:position:),
+ @selector(orbit_discoverySessionWithDeviceTypes:mediaType:position:));
+ OrbitSwizzleClassMethod([AVCaptureDeviceInput class],
+ @selector(deviceInputWithDevice:error:),
+ @selector(orbit_deviceInputWithDevice:error:));
+ OrbitSwizzleInstanceMethod([AVCaptureDeviceInput class], @selector(initWithDevice:error:),
+ @selector(orbit_initWithDevice:error:));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(canAddInput:),
+ @selector(orbit_canAddInput:));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(addInput:),
+ @selector(orbit_addInput:));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(canAddOutput:),
+ @selector(orbit_canAddOutput:));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(addOutput:),
+ @selector(orbit_addOutput:));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(startRunning),
+ @selector(orbit_startRunning));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(stopRunning),
+ @selector(orbit_stopRunning));
+ OrbitSwizzleInstanceMethod([AVCaptureSession class], @selector(isRunning),
+ @selector(orbit_isRunning));
+ OrbitSwizzleInstanceMethod([AVCaptureVideoPreviewLayer class], @selector(setSession:),
+ @selector(orbit_setSession:));
+ OrbitStartFramePump();
+ }
+}
diff --git a/apps/menu-bar/simulator-camera/OrbitSimulatorCameraProtocol.h b/apps/menu-bar/simulator-camera/OrbitSimulatorCameraProtocol.h
new file mode 100644
index 00000000..ee1ca648
--- /dev/null
+++ b/apps/menu-bar/simulator-camera/OrbitSimulatorCameraProtocol.h
@@ -0,0 +1,40 @@
+#ifndef OrbitSimulatorCameraProtocol_h
+#define OrbitSimulatorCameraProtocol_h
+
+#include
+
+#define ORBIT_SIMULATOR_CAMERA_MAGIC 0x4F524243u
+#define ORBIT_SIMULATOR_CAMERA_VERSION 1u
+#define ORBIT_SIMULATOR_CAMERA_HEADER_SIZE 4096u
+#define ORBIT_SIMULATOR_CAMERA_MAX_WIDTH 1920u
+#define ORBIT_SIMULATOR_CAMERA_MAX_HEIGHT 1080u
+#define ORBIT_SIMULATOR_CAMERA_BYTES_PER_PIXEL 4u
+#define ORBIT_SIMULATOR_CAMERA_SLOT_SIZE \
+ (ORBIT_SIMULATOR_CAMERA_MAX_WIDTH * ORBIT_SIMULATOR_CAMERA_MAX_HEIGHT * \
+ ORBIT_SIMULATOR_CAMERA_BYTES_PER_PIXEL)
+#define ORBIT_SIMULATOR_CAMERA_SLOT_COUNT 2u
+#define ORBIT_SIMULATOR_CAMERA_FILE_SIZE \
+ (ORBIT_SIMULATOR_CAMERA_HEADER_SIZE + \
+ ORBIT_SIMULATOR_CAMERA_SLOT_SIZE * ORBIT_SIMULATOR_CAMERA_SLOT_COUNT)
+#define ORBIT_SIMULATOR_CAMERA_PATH \
+ "/private/tmp/dev.expo.orbit.simulator-camera.frames"
+
+typedef struct {
+ uint32_t magic;
+ uint32_t version;
+ uint64_t sequence;
+ uint32_t width;
+ uint32_t height;
+ uint32_t bytesPerRow;
+ uint32_t pixelFormat;
+ uint32_t slotSize;
+ uint32_t activeSlot;
+ uint64_t timestampNanoseconds;
+ uint8_t reserved[ORBIT_SIMULATOR_CAMERA_HEADER_SIZE - 48];
+} OrbitSimulatorCameraFrameHeader;
+
+_Static_assert(sizeof(OrbitSimulatorCameraFrameHeader) ==
+ ORBIT_SIMULATOR_CAMERA_HEADER_SIZE,
+ "The frame header must occupy exactly one page");
+
+#endif
diff --git a/apps/menu-bar/simulator-camera/build-payload.sh b/apps/menu-bar/simulator-camera/build-payload.sh
new file mode 100755
index 00000000..3aa1886d
--- /dev/null
+++ b/apps/menu-bar/simulator-camera/build-payload.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+set -euo pipefail
+
+SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_DIR="${1:?An output directory is required}"
+SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
+OUTPUT="$OUTPUT_DIR/OrbitSimulatorCamera.dylib"
+TEMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TEMP_DIR"' EXIT
+
+mkdir -p "$OUTPUT_DIR"
+for ARCH in arm64 x86_64; do
+ xcrun --sdk iphonesimulator clang \
+ -arch "$ARCH" \
+ -mios-simulator-version-min=15.0 \
+ -fobjc-arc \
+ -fmodules \
+ -dynamiclib \
+ -install_name @rpath/OrbitSimulatorCamera.dylib \
+ -isysroot "$SDK_PATH" \
+ -framework AVFoundation \
+ -framework CoreImage \
+ -framework CoreMedia \
+ -framework CoreVideo \
+ -framework Foundation \
+ -framework QuartzCore \
+ -framework UIKit \
+ "$SOURCE_DIR/OrbitSimulatorCamera.m" \
+ -o "$TEMP_DIR/OrbitSimulatorCamera-$ARCH.dylib"
+done
+
+lipo -create "$TEMP_DIR/OrbitSimulatorCamera-arm64.dylib" \
+ "$TEMP_DIR/OrbitSimulatorCamera-x86_64.dylib" -output "$OUTPUT"
+
+codesign --force --sign - "$OUTPUT"
diff --git a/apps/menu-bar/simulator-camera/tests/protocol_test.c b/apps/menu-bar/simulator-camera/tests/protocol_test.c
new file mode 100644
index 00000000..192f0ec8
--- /dev/null
+++ b/apps/menu-bar/simulator-camera/tests/protocol_test.c
@@ -0,0 +1,28 @@
+#include "../OrbitSimulatorCameraProtocol.h"
+
+#include
+#include
+#include
+
+int main(void) {
+ assert(sizeof(OrbitSimulatorCameraFrameHeader) == 4096);
+ assert(ORBIT_SIMULATOR_CAMERA_SLOT_SIZE == 1920 * 1080 * 4);
+ assert(ORBIT_SIMULATOR_CAMERA_FILE_SIZE ==
+ 4096 + (2 * 1920 * 1080 * 4));
+
+ unsigned char storage[ORBIT_SIMULATOR_CAMERA_HEADER_SIZE + 16] = {0};
+ OrbitSimulatorCameraFrameHeader *header =
+ (OrbitSimulatorCameraFrameHeader *)storage;
+ header->magic = ORBIT_SIMULATOR_CAMERA_MAGIC;
+ header->version = ORBIT_SIMULATOR_CAMERA_VERSION;
+ header->activeSlot = 1;
+ strcpy((char *)(storage + ORBIT_SIMULATOR_CAMERA_HEADER_SIZE), "frame");
+
+ assert(header->magic == 0x4F524243u);
+ assert(header->version == 1);
+ assert(header->activeSlot == 1);
+ assert(strcmp((char *)(header + 1), "frame") == 0);
+
+ puts("Simulator camera frame protocol tests passed");
+ return 0;
+}
diff --git a/apps/menu-bar/simulator-camera/tests/run.sh b/apps/menu-bar/simulator-camera/tests/run.sh
new file mode 100755
index 00000000..410fbb1d
--- /dev/null
+++ b/apps/menu-bar/simulator-camera/tests/run.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+set -euo pipefail
+
+TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT="$(mktemp -t orbit-simulator-camera-protocol)"
+trap 'rm -f "$OUTPUT"' EXIT
+
+clang -std=c11 -Wall -Wextra -Werror "$TESTS_DIR/protocol_test.c" -o "$OUTPUT"
+"$OUTPUT"
diff --git a/apps/menu-bar/src/assets/icons/camera.svg b/apps/menu-bar/src/assets/icons/camera.svg
new file mode 100644
index 00000000..6efffa87
--- /dev/null
+++ b/apps/menu-bar/src/assets/icons/camera.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/menu-bar/src/popover/Footer.tsx b/apps/menu-bar/src/popover/Footer.tsx
index e8084656..46a911fc 100644
--- a/apps/menu-bar/src/popover/Footer.tsx
+++ b/apps/menu-bar/src/popover/Footer.tsx
@@ -1,12 +1,12 @@
import { memo } from 'react';
-import { StyleSheet } from 'react-native';
+import { Platform, StyleSheet } from 'react-native';
import Item from './Item';
import { Divider, Text, View } from '../components';
import MenuBarModule from '../modules/MenuBarModule';
import { WindowsNavigator } from '../windows';
-export const FOOTER_HEIGHT = 62;
+export const FOOTER_HEIGHT = Platform.OS === 'macos' ? 86 : 62;
const Footer = () => {
return (
@@ -15,6 +15,13 @@ const Footer = () => {
+ {Platform.OS === 'macos' && (
+ - WindowsNavigator.open('SimulatorCamera')}
+ testID="simulator-camera-button">
+ Simulator Camera…
+
+ )}
- WindowsNavigator.open('Settings')} testID="settings-button">
Settings…
diff --git a/apps/menu-bar/src/windows/SimulatorCamera.tsx b/apps/menu-bar/src/windows/SimulatorCamera.tsx
new file mode 100644
index 00000000..6bc25669
--- /dev/null
+++ b/apps/menu-bar/src/windows/SimulatorCamera.tsx
@@ -0,0 +1,127 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, StyleSheet } from 'react-native';
+
+import {
+ SimulatorCameraStatus,
+ getStatusAsync,
+ installAsync,
+ startAsync,
+ stopAsync,
+ uninstallAsync,
+} from '../../modules/simulator-camera';
+import SimulatorCameraIcon from '../assets/icons/camera.svg';
+import { Row, Text, View } from '../components';
+import Button from '../components/Button';
+import { PlatformColor } from '../modules/PlatformColor';
+
+const initialStatus: SimulatorCameraStatus = {
+ installed: false,
+ streaming: false,
+};
+
+export default function SimulatorCamera() {
+ const [status, setStatus] = useState(initialStatus);
+ const [working, setWorking] = useState(true);
+
+ const perform = useCallback(async (action: () => Promise) => {
+ setWorking(true);
+ try {
+ setStatus(await action());
+ } finally {
+ setWorking(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ perform(getStatusAsync);
+ }, [perform]);
+
+ const toggleInstallation = () => perform(status.installed ? uninstallAsync : installAsync);
+ const toggleStreaming = () => perform(status.streaming ? stopAsync : startAsync);
+
+ return (
+
+
+
+
+
+
+
+ Simulator Camera
+
+
+ Use your Mac camera in iOS Simulator apps
+
+
+ {working && }
+
+
+
+
+
+ Xcode integration
+
+ {status.installed
+ ? 'Installed. Restart the app from Xcode after changing this setting.'
+ : 'Adds a removable hook to ~/.lldbinit-Xcode.'}
+
+
+
+
+
+
+
+
+
+ Mac camera
+
+ {status.streaming
+ ? `Streaming ${status.cameraName ?? 'camera'} at up to 1080p.`
+ : 'Frames stay on this Mac and are shared with Simulator processes.'}
+
+
+
+
+
+
+ {status.error && (
+
+
+ {status.error}
+
+
+ )}
+
+
+
+ Preview and video-frame capture are supported in this first version. Taking photos,
+ recording movies, and UIImagePickerController are not yet intercepted.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ iconBadge: {
+ width: 36,
+ height: 36,
+ backgroundColor: PlatformColor('controlBackgroundColor'),
+ },
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: PlatformColor('separatorColor'),
+ },
+ copyColumn: {
+ marginRight: 16,
+ },
+});
diff --git a/apps/menu-bar/src/windows/index.ts b/apps/menu-bar/src/windows/index.ts
index 6df66b9f..4e3f312d 100644
--- a/apps/menu-bar/src/windows/index.ts
+++ b/apps/menu-bar/src/windows/index.ts
@@ -2,6 +2,7 @@ import DebugMenu from './DebugMenu';
import Onboarding from './Onboarding';
import PairAndroidDevice from './PairAndroidDevice';
import Settings from './Settings';
+import SimulatorCamera from './SimulatorCamera';
import { WindowStyleMask, createWindowsNavigator } from '../modules/WindowManager';
export const WindowsNavigator = createWindowsNavigator({
@@ -17,6 +18,18 @@ export const WindowsNavigator = createWindowsNavigator({
},
},
},
+ SimulatorCamera: {
+ component: SimulatorCamera,
+ options: {
+ title: 'Simulator Camera',
+ windowStyle: {
+ mask: [WindowStyleMask.Titled, WindowStyleMask.Closable],
+ titlebarAppearsTransparent: true,
+ height: 390,
+ width: 500,
+ },
+ },
+ },
Onboarding: {
component: Onboarding,
options: {
From 4217fc32ffeff0722d1f4d3611031b695405934c Mon Sep 17 00:00:00 2001
From: "expo-tuft[bot]" <288127324+expo-tuft[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:21:24 -0700
Subject: [PATCH 2/3] [menu-bar] Add Simulator camera changelog entry
Co-authored-by: gabrieldonadel <11707729+gabrieldonadel@users.noreply.github.com>
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef71f100..7e6e7ebe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
### 🎉 New features
+- Add Simulator camera streaming on macOS. ([#369](https://github.com/expo/orbit/pull/369) by [@gabrieldonadel](https://github.com/gabrieldonadel))
+
### 🐛 Bug fixes
- Fix Android code pairing input width on Windows and Linux. ([#357](https://github.com/expo/orbit/pull/357) by [@gabrieldonadel](https://github.com/gabrieldonadel))
From 17804e33c45bb0395d52cd9947a17817b15a4660 Mon Sep 17 00:00:00 2001
From: "expo-tuft[bot]" <288127324+expo-tuft[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:53:59 -0700
Subject: [PATCH 3/3] [menu-bar] Move Simulator camera into Settings
Co-authored-by: gabrieldonadel <11707729+gabrieldonadel@users.noreply.github.com>
---
apps/menu-bar/src/popover/Footer.tsx | 11 ++---------
apps/menu-bar/src/windows/Settings.tsx | 2 ++
apps/menu-bar/src/windows/SimulatorCamera.tsx | 15 +++++++++++----
apps/menu-bar/src/windows/index.ts | 13 -------------
4 files changed, 15 insertions(+), 26 deletions(-)
diff --git a/apps/menu-bar/src/popover/Footer.tsx b/apps/menu-bar/src/popover/Footer.tsx
index 46a911fc..e8084656 100644
--- a/apps/menu-bar/src/popover/Footer.tsx
+++ b/apps/menu-bar/src/popover/Footer.tsx
@@ -1,12 +1,12 @@
import { memo } from 'react';
-import { Platform, StyleSheet } from 'react-native';
+import { StyleSheet } from 'react-native';
import Item from './Item';
import { Divider, Text, View } from '../components';
import MenuBarModule from '../modules/MenuBarModule';
import { WindowsNavigator } from '../windows';
-export const FOOTER_HEIGHT = Platform.OS === 'macos' ? 86 : 62;
+export const FOOTER_HEIGHT = 62;
const Footer = () => {
return (
@@ -15,13 +15,6 @@ const Footer = () => {
- {Platform.OS === 'macos' && (
- - WindowsNavigator.open('SimulatorCamera')}
- testID="simulator-camera-button">
- Simulator Camera…
-
- )}
- WindowsNavigator.open('Settings')} testID="settings-button">
Settings…
diff --git a/apps/menu-bar/src/windows/Settings.tsx b/apps/menu-bar/src/windows/Settings.tsx
index d2ae96fa..c13e3f32 100644
--- a/apps/menu-bar/src/windows/Settings.tsx
+++ b/apps/menu-bar/src/windows/Settings.tsx
@@ -5,6 +5,7 @@ import { SymbolView } from 'expo-symbols';
import React, { Fragment, useEffect, useState } from 'react';
import { StyleSheet, TouchableOpacity, Platform, ScrollView } from 'react-native';
+import SimulatorCamera from './SimulatorCamera';
import { WindowsNavigator } from './index';
import AutoUpdater from '../../modules/auto-updater';
import {
@@ -344,6 +345,7 @@ const Settings = () => {
/>
+ {Platform.OS === 'macos' && }
Platforms
diff --git a/apps/menu-bar/src/windows/SimulatorCamera.tsx b/apps/menu-bar/src/windows/SimulatorCamera.tsx
index 6bc25669..c1360540 100644
--- a/apps/menu-bar/src/windows/SimulatorCamera.tsx
+++ b/apps/menu-bar/src/windows/SimulatorCamera.tsx
@@ -19,7 +19,11 @@ const initialStatus: SimulatorCameraStatus = {
streaming: false,
};
-export default function SimulatorCamera() {
+type SimulatorCameraProps = {
+ groupWrapperStyle: object;
+};
+
+export default function SimulatorCamera({ groupWrapperStyle }: SimulatorCameraProps) {
const [status, setStatus] = useState(initialStatus);
const [working, setWorking] = useState(true);
@@ -40,8 +44,8 @@ export default function SimulatorCamera() {
const toggleStreaming = () => perform(status.streaming ? stopAsync : startAsync);
return (
-
-
+
+
@@ -56,7 +60,7 @@ export default function SimulatorCamera() {
{working && }
-
+
Xcode integration
@@ -124,4 +128,7 @@ const styles = StyleSheet.create({
copyColumn: {
marginRight: 16,
},
+ headerSpacing: {
+ paddingLeft: 10,
+ },
});
diff --git a/apps/menu-bar/src/windows/index.ts b/apps/menu-bar/src/windows/index.ts
index 4e3f312d..6df66b9f 100644
--- a/apps/menu-bar/src/windows/index.ts
+++ b/apps/menu-bar/src/windows/index.ts
@@ -2,7 +2,6 @@ import DebugMenu from './DebugMenu';
import Onboarding from './Onboarding';
import PairAndroidDevice from './PairAndroidDevice';
import Settings from './Settings';
-import SimulatorCamera from './SimulatorCamera';
import { WindowStyleMask, createWindowsNavigator } from '../modules/WindowManager';
export const WindowsNavigator = createWindowsNavigator({
@@ -18,18 +17,6 @@ export const WindowsNavigator = createWindowsNavigator({
},
},
},
- SimulatorCamera: {
- component: SimulatorCamera,
- options: {
- title: 'Simulator Camera',
- windowStyle: {
- mask: [WindowStyleMask.Titled, WindowStyleMask.Closable],
- titlebarAppearsTransparent: true,
- height: 390,
- width: 500,
- },
- },
- },
Onboarding: {
component: Onboarding,
options: {