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.'} + + +