diff --git a/.github/workflows/release-app.yaml b/.github/workflows/release-app.yaml index 618b30e..0258828 100644 --- a/.github/workflows/release-app.yaml +++ b/.github/workflows/release-app.yaml @@ -67,6 +67,11 @@ jobs: security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN" security set-key-partition-list -S apple-tool:,apple: -k ci "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" + # --deep skips Mach-O files in Resources: sign the simulator + # camera injector first. + codesign --force --timestamp --options runtime \ + --sign "$MACOS_SIGNING_IDENTITY" \ + "$APP/Contents/Resources/libSimCameraInjector.dylib" codesign --force --deep --timestamp --options runtime \ --entitlements app/macos/Runner/Release.entitlements \ --sign "$MACOS_SIGNING_IDENTITY" "$APP" diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 7f96ee4..9c4fdf7 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 used from the next start) or the camera button on a running emulator's stream (restarts it). SimUtil asks for Camera access before an emulator uses your webcam. Agents can set it over MCP too. +- Give iOS simulator apps a camera (macOS): pick your Mac's webcam, an + image or a looping video from the device menu (Camera) or the camera + button on its stream, then open an app with it; switching the source + updates running apps live, and closing its stream or shutting it down + turns the camera off. Agents can pass + a camera to launch_app over MCP. Uses serve-sim's camera injector. ### Fixed diff --git a/app/lib/main.dart b/app/lib/main.dart index bd231da..f54415d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -20,6 +20,7 @@ import 'package:simutil_app/src/mcp/tools/tool_context.dart'; import 'package:simutil_app/src/recording/grid_recorder.dart'; import 'package:simutil_app/src/scenarios/scenario_runs_cubit.dart'; import 'package:simutil_app/src/settings/view_settings_cubit.dart'; +import 'package:simutil_app/src/stream/ios/sim_camera.dart'; import 'package:simutil_app/src/stream/streams_cubit.dart'; import 'package:simutil_app/src/stream/touch_marks.dart'; import 'package:simutil_app/src/update/app_update_cubit.dart'; @@ -41,7 +42,10 @@ Future main() async { getIt().check(); final devices = getIt()..start(); final streams = getIt(); - devices.state.subscribe((state) => streams.onDevices(state.all)); + devices.state.subscribe((state) { + streams.onDevices(state.all); + SimCamera.onDevices(state.all); + }); // Closing the window quits, after streams and recordings are released. windowManager.addListener(_QuitOnClose(quitApp)); await windowManager.setPreventClose(true); diff --git a/app/lib/src/mcp/tools/input_tools.dart b/app/lib/src/mcp/tools/input_tools.dart index de3b8a0..c2ec15a 100644 --- a/app/lib/src/mcp/tools/input_tools.dart +++ b/app/lib/src/mcp/tools/input_tools.dart @@ -6,6 +6,7 @@ import 'package:simutil_app/src/mcp/mcp_server.dart'; import 'package:simutil_app/src/mcp/tools/tool_context.dart'; import 'package:simutil_app/src/scenarios/scenario_runs_cubit.dart'; import 'package:simutil_app/src/scenarios/scenario_yaml.dart'; +import 'package:simutil_app/src/stream/ios/sim_camera.dart'; import 'package:simutil_apple/simutil_apple.dart'; import 'package:simutil_core/simutil_core.dart'; @@ -69,20 +70,49 @@ List inputTools(ToolContext c) => [ description: 'Launch an app by Android package name or iOS bundle id ' '(e.g. com.example.app), stopping it first if it runs; clear_state ' - 'also wipes its data (simulators and Android).', + 'also wipes its data (simulators and Android). camera (iOS ' + 'simulators, macOS app) feeds its camera from webcam, ' + 'webcam:, image: or video:.', properties: { ...deviceArg, 'app': {'type': 'string'}, 'clear_state': {'type': 'boolean', 'default': false}, + 'camera': {'type': 'string'}, }, required: ['device', 'app'], handler: (args) async { final app = args['app']; if (app is! String || app.isEmpty) throw ArgumentError('app required'); final clear = args['clear_state'] == true; - await c.automation.launch(c.find(args), app, clearState: clear); + final camera = switch (args['camera']) { + final String value when value.isNotEmpty => SimCamera.parse(value), + _ => null, + }; + final device = c.find(args); + if (camera == null) { + await c.automation.launch(device, app, clearState: clear); + } else { + if (device.os != DeviceOs.ios || device.type.isPhysical) { + throw ArgumentError( + 'camera needs an iOS simulator; pick Android emulator cameras ' + 'with start_device cameraBack / cameraFront', + ); + } + if (clear) await c.automation.launch(device, app, clearState: true); + await SimCamera.launch( + getIt(), + device, + app, + source: camera.source, + arg: camera.arg, + ); + } return c.text( - clear ? 'Launched $app with a clean state' : 'Launched $app', + [ + 'Launched $app', + if (clear) 'with a clean state', + if (camera != null) 'with its camera on ${args['camera']}', + ].join(' '), ); }, ), diff --git a/app/lib/src/stream/ios/ios_sim_session.dart b/app/lib/src/stream/ios/ios_sim_session.dart index 2f38748..c7b20d5 100644 --- a/app/lib/src/stream/ios/ios_sim_session.dart +++ b/app/lib/src/stream/ios/ios_sim_session.dart @@ -7,6 +7,7 @@ import 'package:simutil_core/simutil_core.dart'; import 'package:simutil_app/src/stream/stream_fps.dart'; import 'package:simutil_app/src/stream/ios/apple_chrome.dart'; +import 'package:simutil_app/src/stream/ios/sim_camera.dart'; /// [DeviceSession] for a booted iOS simulator, backed by the native /// `SimStreamPlugin` (macOS): the framebuffer is a Flutter texture and input @@ -248,6 +249,8 @@ class IosSimSession implements DeviceSession { if (_active[udid] == this) _active.remove(udid); removeFrameCounter(this, _framesShown); await _channel.invokeMethod('stop', {'udid': udid}); + // Closing the stream turns the simulator's camera (and webcam) off too. + await SimCamera.stop(udid); await _statusController.close(); } } diff --git a/app/lib/src/stream/ios/sim_camera.dart b/app/lib/src/stream/ios/sim_camera.dart new file mode 100644 index 0000000..c433a3e --- /dev/null +++ b/app/lib/src/stream/ios/sim_camera.dart @@ -0,0 +1,107 @@ +import 'package:flutter/services.dart'; +import 'package:simutil_apple/simutil_apple.dart'; +import 'package:simutil_core/simutil_core.dart'; + +/// Camera of an iOS simulator app: the native `SimCameraPlugin` (macOS) +/// renders a webcam, image or looping video into a shared feed per +/// simulator, and apps are launched with the serve-sim camera injector +/// reading it. +abstract final class SimCamera { + static const _channel = MethodChannel('simutil/sim_camera'); + + /// Simulators with a running feed; the host webcam stays on until stopped. + static final _running = {}; + + /// Host cameras as (unique id, name). + static Future> webcams() async => [ + for (final cam in await _channel.invokeListMethod('webcams') ?? []) + (id: cam['id'] as String, name: cam['name'] as String), + ]; + + /// The source feeding simulator [udid]: `webcam`, `image` or `video` and + /// its webcam id / file path; null when no feed runs. + static Future<({String source, String? arg})?> status(String udid) async { + final reply = await _channel.invokeMapMethod('status', { + 'udid': udid, + }); + final source = reply?['source']; + if (source is! String) return null; + return (source: source, arg: reply!['arg'] as String?); + } + + /// Feeds simulator [udid] from [source] (see [status]); a running feed + /// switches without relaunching its apps. + static Future<({String shm, String dylib})> setSource( + String udid, + String source, [ + String? arg, + ]) async { + try { + final reply = (await _channel.invokeMapMethod('start', { + 'udid': udid, + 'source': source, + 'arg': ?arg, + }))!; + _running.add(udid); + return (shm: reply['shm']!, dylib: reply['dylib']!); + } on PlatformException catch (e) { + throw StateError(e.message ?? e.code); + } + } + + /// (Re)launches [appId] on simulator [device] reading its camera from + /// [source], the running source when null (a webcam when none runs). + static Future launch( + IosAutomation automation, + Device device, + String appId, { + String? source, + String? arg, + }) async { + final current = source == null ? await status(device.id) : null; + final feed = await setSource( + device.id, + source ?? current?.source ?? 'webcam', + source != null ? arg : current?.arg, + ); + await automation.launchWithCamera( + device, + appId, + dylib: feed.dylib, + shmName: feed.shm, + ); + } + + /// Stops simulator [udid]'s feed (a no-op without one); apps keep their + /// last frame. + static Future stop(String udid) async { + if (!_running.remove(udid)) return; + await _channel.invokeMethod('stop', {'udid': udid}); + } + + /// Stops the feed of every simulator in [devices]' update that is no + /// longer booted, so its webcam turns off with it. + static void onDevices(List devices) { + final booted = { + for (final d in devices) + if (d.state == DeviceState.booted) d.id, + }; + _running.where((udid) => !booted.contains(udid)).toList().forEach(stop); + } + + /// Parses an MCP / CLI camera value: `webcam`, `webcam:`, + /// `image:` or `video:`. + static ({String source, String? arg}) parse(String value) { + final colon = value.indexOf(':'); + final source = colon < 0 ? value : value.substring(0, colon); + final arg = colon < 0 ? null : value.substring(colon + 1); + if (!const {'webcam', 'image', 'video'}.contains(source) || + (source != 'webcam' && (arg == null || arg.isEmpty))) { + throw FormatException( + 'camera must be webcam, webcam:, image: or video:', + value, + ); + } + return (source: source, arg: arg); + } +} diff --git a/app/lib/src/ui/shared/device_context_menu.dart b/app/lib/src/ui/shared/device_context_menu.dart index d2005a7..540c018 100644 --- a/app/lib/src/ui/shared/device_context_menu.dart +++ b/app/lib/src/ui/shared/device_context_menu.dart @@ -5,12 +5,14 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:simutil_adb/simutil_adb.dart'; +import 'package:simutil_apple/simutil_apple.dart'; import 'package:simutil_app/src/di.dart'; import 'package:simutil_app/src/devices/slim_mode_cubit.dart'; import 'package:simutil_app/src/recording/grid_recorder.dart'; import 'package:simutil_app/src/devices/devices_cubit.dart'; import 'package:simutil_app/src/settings/device_settings_cubit.dart'; import 'package:simutil_app/src/settings/recordings_dir.dart'; +import 'package:simutil_app/src/stream/ios/sim_camera.dart'; import 'package:simutil_app/src/stream/streams_cubit.dart'; import 'package:simutil_app/src/ui/shared/device_actions.dart'; import 'package:simutil_app/src/ui/shared/native_menu.dart'; @@ -20,13 +22,40 @@ import 'package:tray_manager/tray_manager.dart' as native; // ponytail: listed once per run, so a camera plugged in later needs an app // restart; re-list on every menu open if that matters. Future>? _webcams; +Future>? _hostWebcams; + +/// What an iOS simulator's Camera submenu shows: the host webcams, the +/// running feed and the apps that can be launched with it. +typedef _SimCameraState = ({ + List<({String id, String name})> webcams, + ({String source, String? arg})? current, + List<({String id, String name})> apps, +}); + +Future<_SimCameraState> _simCameraState(Device device) async { + final apps = getIt() + .installedApps(device) + .catchError((Object _) => <({String id, String name})>[]); + return ( + webcams: await (_hostWebcams ??= SimCamera.webcams()), + current: await SimCamera.status(device.id), + apps: await apps, + ); +} Future> _listWebcams() => _webcams ??= getIt().listWebcams(); -/// The camera sources of a running Android emulator as their own menu (from -/// its stream header); a pick restarts it so the camera changes right away. +/// The camera sources of a running device as their own menu (from its +/// stream header). An Android emulator restarts so the camera changes right +/// away; an iOS simulator's feed switches live. Future showCameraMenu(BuildContext context, Device device) async { + if (device.os == DeviceOs.ios) { + final state = await _simCameraState(device); + if (!context.mounted) return; + showNativeMenu(() => [_simCameraItems(context, device, state)]); + return; + } final webcams = await _listWebcams(); if (!context.mounted) return; final settingsCubit = context.read(); @@ -47,6 +76,13 @@ Future showDeviceContextMenu(BuildContext context, Device device) async { final webcams = emulator ? await _listWebcams() : const <({String id, String name})>[]; + final simCamera = + Platform.isMacOS && + device.os == DeviceOs.ios && + !device.type.isPhysical && + device.state == DeviceState.booted + ? await _simCameraState(device) + : null; if (!context.mounted) return; final settingsCubit = context.read(); void set(DeviceSettings Function(DeviceSettings) change) => @@ -65,6 +101,8 @@ Future showDeviceContextMenu(BuildContext context, Device device) async { _cameraSides(settingsCubit.of(device), webcams, set), ), ], + if (simCamera != null) + [menuSubmenu('Camera', _simCameraItems(context, device, simCamera))], _streamItems(context, device, set), if (!device.type.isPhysical && !busy) _deleteItems(context, device), ], @@ -221,6 +259,77 @@ List _cameraSides( ]; } +/// Camera of an iOS simulator: pick a host webcam, image or video to feed +/// it, then launch an app reading that feed (serve-sim's camera injector). +List _simCameraItems( + BuildContext context, + Device device, + _SimCameraState state, +) { + final devices = context.read(); + final current = state.current; + Future guard(String done, Future Function() action) async { + try { + await action(); + devices.notify(done); + } on Object catch (e) { + final message = e is StateError ? e.message : '$e'; + devices.notify('Camera: $message', error: true); + } + } + + void feed(String source, String? arg, String label) => guard( + 'Simulator camera: $label', + () => SimCamera.setSource(device.id, source, arg), + ); + + native.MenuItem file(String kind, String title, List extensions) { + final picked = current?.source == kind; + return menuCheck( + picked + ? '$title: ${current!.arg!.split(Platform.pathSeparator).last}' + : '$title…', + picked, + () async { + final file = await openFile( + acceptedTypeGroups: [ + XTypeGroup(label: title, extensions: extensions), + ], + ); + if (file != null) feed(kind, file.path, file.name); + }, + ); + } + + return [ + for (final cam in state.webcams) + menuCheck( + cam.name, + current?.source == 'webcam' && current?.arg == cam.id, + () => feed('webcam', cam.id, cam.name), + ), + file('image', 'Image', const ['png', 'jpg', 'jpeg', 'heic']), + file('video', 'Video', const ['mp4', 'mov', 'm4v']), + if (current != null) ...[ + menuSubmenu('Open app with camera', [ + for (final app in state.apps) + menuItem( + app.name, + () => guard( + 'Launched ${app.name} with the camera', + () => SimCamera.launch(getIt(), device, app.id), + ), + ), + if (state.apps.isEmpty) menuItem('No apps installed', null), + ]), + menuItem( + 'Turn off camera', + () => guard('Simulator camera off', () => SimCamera.stop(device.id)), + ), + ], + ]; +} + /// Device frame, and the scrcpy options of an Android stream; they apply /// the next time the stream opens. List _streamItems( diff --git a/app/lib/src/ui/streams/stream_tile.dart b/app/lib/src/ui/streams/stream_tile.dart index d016b72..bd454c5 100644 --- a/app/lib/src/ui/streams/stream_tile.dart +++ b/app/lib/src/ui/streams/stream_tile.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:bloc_signals_flutter/bloc_signals_flutter.dart'; @@ -355,10 +356,13 @@ List _tileActions( (s) => s.copyWith(showFrame: !s.showFrame), ), ), - if (device.os == DeviceOs.android && !device.type.isPhysical) + if (!device.type.isPhysical && + (device.os == DeviceOs.android || Platform.isMacOS)) TileAction( icon: LucideIcons.webcam, - label: 'Camera source (restarts the emulator)', + label: device.os == DeviceOs.android + ? 'Camera source (restarts the emulator)' + : 'Camera source', priority: 3, onPressed: live ? () => showCameraMenu(context, device) : null, ), diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj index 03b3944..bff5ecd 100644 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,8 @@ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 774E23D8DCFAF6CB00396293 /* SimStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6748D658158903B23D1E42C0 /* SimStream.swift */; }; A1C0DE0000000000000000B1 /* WindowRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F1 /* WindowRecorder.swift */; }; + A1C0DE0000000000000000B3 /* SimCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F3 /* SimCamera.swift */; }; + A1C0DE0000000000000000B4 /* SimCameraFeed.m in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F4 /* SimCameraFeed.m */; }; A1C0DE0000000000000000B2 /* DeviceStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F2 /* DeviceStream.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ @@ -82,6 +84,10 @@ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 6748D658158903B23D1E42C0 /* SimStream.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimStream.swift; sourceTree = ""; }; A1C0DE0000000000000000F1 /* WindowRecorder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WindowRecorder.swift; sourceTree = ""; }; + A1C0DE0000000000000000F3 /* SimCamera.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimCamera.swift; sourceTree = ""; }; + A1C0DE0000000000000000F4 /* SimCameraFeed.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SimCameraFeed.m; sourceTree = ""; }; + A1C0DE0000000000000000F5 /* SimCameraFeed.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SimCameraFeed.h; sourceTree = ""; }; + A1C0DE0000000000000000F6 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; A1C0DE0000000000000000F2 /* DeviceStream.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceStream.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; @@ -180,6 +186,10 @@ 6748D658158903B23D1E42C0 /* SimStream.swift */, A1C0DE0000000000000000F1 /* WindowRecorder.swift */, A1C0DE0000000000000000F2 /* DeviceStream.swift */, + A1C0DE0000000000000000F3 /* SimCamera.swift */, + A1C0DE0000000000000000F5 /* SimCameraFeed.h */, + A1C0DE0000000000000000F4 /* SimCameraFeed.m */, + A1C0DE0000000000000000F6 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; @@ -212,6 +222,7 @@ 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, + A1C0DE0000000000000000C1 /* Build SimCameraInjector */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, ); @@ -302,6 +313,25 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + A1C0DE0000000000000000C1 /* Build SimCameraInjector */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build SimCameraInjector"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\"$PROJECT_DIR/SimCameraInjector/build.sh\" \"$DERIVED_FILE_DIR/simcam\"\nDEST=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/libSimCameraInjector.dylib\"\ncp \"$DERIVED_FILE_DIR/simcam/libSimCameraInjector.dylib\" \"$DEST\"\ncodesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY:--}\" \"$DEST\"\n"; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -361,6 +391,8 @@ 774E23D8DCFAF6CB00396293 /* SimStream.swift in Sources */, A1C0DE0000000000000000B1 /* WindowRecorder.swift in Sources */, A1C0DE0000000000000000B2 /* DeviceStream.swift in Sources */, + A1C0DE0000000000000000B3 /* SimCamera.swift in Sources */, + A1C0DE0000000000000000B4 /* SimCameraFeed.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -498,6 +530,7 @@ "@executable_path/../Frameworks", ); PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; name = Profile; @@ -630,6 +663,7 @@ "@executable_path/../Frameworks", ); PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; @@ -650,6 +684,7 @@ "@executable_path/../Frameworks", ); PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist index 9a182d6..3aedf8b 100644 --- a/app/macos/Runner/Info.plist +++ b/app/macos/Runner/Info.plist @@ -21,7 +21,7 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) NSCameraUsageDescription - SimUtil shows the screen of iPhones and iPads connected by USB. + SimUtil shows the screen of iPhones and iPads connected by USB, and can feed your webcam to iOS simulator apps. LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift index ef4b23c..c0d3159 100644 --- a/app/macos/Runner/MainFlutterWindow.swift +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -13,6 +13,8 @@ class MainFlutterWindow: NSWindow { with: flutterViewController.registrar(forPlugin: "SimStreamPlugin")) DeviceStreamPlugin.register( with: flutterViewController.registrar(forPlugin: "DeviceStreamPlugin")) + SimCameraPlugin.register( + with: flutterViewController.registrar(forPlugin: "SimCameraPlugin")) WindowRecorderPlugin.register( with: flutterViewController.registrar(forPlugin: "WindowRecorderPlugin")) diff --git a/app/macos/Runner/Runner-Bridging-Header.h b/app/macos/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..f6a8214 --- /dev/null +++ b/app/macos/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "SimCameraFeed.h" diff --git a/app/macos/Runner/SimCamera.swift b/app/macos/Runner/SimCamera.swift new file mode 100644 index 0000000..c17b7c0 --- /dev/null +++ b/app/macos/Runner/SimCamera.swift @@ -0,0 +1,102 @@ +// Camera feeds for iOS Simulator apps. A simulator has no camera, so an app +// is launched with libSimCameraInjector.dylib (serve-sim, see +// ../SimCameraInjector/NOTICE) inserted; the dylib swizzles AVFoundation and +// reads frames from a SimCameraFeed this plugin runs, one per simulator. +// +// Channel `simutil/sim_camera`: +// webcams → [{id, name}] +// start(udid, source, arg) → {shm, dylib}; source webcam / image / video, +// arg the webcam id or file path. Hot-swaps a running feed. +// status(udid) → {source, arg} or nil +// stop(udid) + +import AVFoundation +import Cocoa +import FlutterMacOS + +final class SimCameraPlugin: NSObject, FlutterPlugin { + /// Touched only on [queue]: switching sources blocks on AVCaptureSession, + /// which needs the main thread free. + private var feeds: [String: SimCameraFeed] = [:] + private let queue = DispatchQueue(label: "simutil.camera.plugin") + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "simutil/sim_camera", binaryMessenger: registrar.messenger) + let plugin = SimCameraPlugin() + registrar.addMethodCallDelegate(plugin, channel: channel) + NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: nil + ) { _ in + // Unlink the shm names so they do not outlive the app. + plugin.queue.sync { + plugin.feeds.values.forEach { $0.stop() } + plugin.feeds.removeAll() + } + } + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [:] + let reply: (Any?) -> Void = { value in DispatchQueue.main.async { result(value) } } + switch call.method { + case "webcams": + result(SimCameraFeed.webcams()) + case "start": + guard let udid = args["udid"] as? String, let source = args["source"] as? String else { + return result(FlutterError(code: "args", message: "udid and source required", details: nil)) + } + let arg = args["arg"] as? String + Task { + if source == "webcam", !(await AVCaptureDevice.requestAccess(for: .video)) { + return reply( + FlutterError( + code: "camera", + message: + "SimUtil needs Camera access to use your webcam: allow it in System Settings › Privacy & Security › Camera", + details: nil)) + } + queue.async { reply(self.start(udid: udid, source: source, arg: arg)) } + } + case "status": + guard let udid = args["udid"] as? String else { return result(nil) } + queue.async { + reply(self.feeds[udid].map { ["source": $0.source, "arg": $0.arg] as [String: Any?] }) + } + case "stop": + guard let udid = args["udid"] as? String else { return result(nil) } + queue.async { + self.feeds.removeValue(forKey: udid)?.stop() + reply(nil) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func start(udid: String, source: String, arg: String?) -> Any { + guard + let dylib = Bundle.main.path(forResource: "libSimCameraInjector", ofType: "dylib") + else { + return FlutterError(code: "start", message: "libSimCameraInjector.dylib is missing", details: nil) + } + // ponytail: POSIX shm names are capped at 31 chars, so the UDID's first + // 8 hex digits; hash the full UDID if two simulators ever collide. + let shm = "/simutil-cam-\(udid.prefix(8))" + let feed: SimCameraFeed + if let running = feeds[udid] { + feed = running + } else if let created = SimCameraFeed(shmName: shm) { + feed = created + } else { + return FlutterError( + code: "start", message: "Could not create the camera feed", details: nil) + } + if let error = feed.switch(toSource: source, arg: arg) { + if feeds[udid] == nil { feed.stop() } + return FlutterError(code: "start", message: error, details: nil) + } + feeds[udid] = feed + return ["shm": shm, "dylib": dylib] + } +} diff --git a/app/macos/Runner/SimCameraFeed.h b/app/macos/Runner/SimCameraFeed.h new file mode 100644 index 0000000..b2a005d --- /dev/null +++ b/app/macos/Runner/SimCameraFeed.h @@ -0,0 +1,38 @@ +// Host side of the iOS Simulator camera: owns the shared-memory control +// region and IOSurface ring that libSimCameraInjector.dylib reads inside a +// simulator app, and renders a webcam, image or looping video into it. +// +// Adapted from serve-sim (https://github.com/EvanBacon/serve-sim), +// Copyright Evan Bacon, Apache License 2.0: Sources/SimCameraHelper/main.m, +// moved in-process (one instance per simulator, no control socket, no +// placeholder source). + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SimCameraFeed : NSObject + +/// Creates the shm region [shmName] (a POSIX name such as +/// `/simutil-cam-1a2b3c4d`) and its IOSurfaces; nil when that fails. +- (nullable instancetype)initWithShmName:(NSString *)shmName; +- (instancetype)init NS_UNAVAILABLE; + +@property(nonatomic, readonly) NSString *shmName; +/// `webcam`, `image` or `video`; nil before the first switch. +@property(nonatomic, readonly, nullable) NSString *source; +/// Webcam unique ID / name, or the file path of the current source. +@property(nonatomic, readonly, nullable) NSString *arg; + +/// Replaces the running source; returns an error message, nil on success. +- (nullable NSString *)switchToSource:(NSString *)source arg:(nullable NSString *)arg; + +/// Stops the source, unlinks the shm name and frees the surfaces. +- (void)stop; + +/// Host cameras as `{id, name}`. ++ (NSArray *> *)webcams; + +@end + +NS_ASSUME_NONNULL_END diff --git a/app/macos/Runner/SimCameraFeed.m b/app/macos/Runner/SimCameraFeed.m new file mode 100644 index 0000000..daf5877 --- /dev/null +++ b/app/macos/Runner/SimCameraFeed.m @@ -0,0 +1,535 @@ +// Adapted from serve-sim (https://github.com/EvanBacon/serve-sim), +// Copyright Evan Bacon, Apache License 2.0: Sources/SimCameraHelper/main.m. +// The process globals became instance state so several simulators can each +// have a feed; the control socket, placeholder source and main() are gone. + +#import "SimCameraFeed.h" + +#import +#import +#import +#import +#import +#import + +#include +#include +#include +#include +#include + +#include "../SimCameraInjector/include/SimCamShared.h" + +static const CGBitmapInfo kSimCamBGRA = + (CGBitmapInfo)kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little; + +static uint64_t MachAbsToNs(uint64_t t) { + static mach_timebase_info_data_t tb = {0, 0}; + if (tb.denom == 0) mach_timebase_info(&tb); + return t * tb.numer / tb.denom; +} + +static NSArray *HostCameras(void) { + NSArray *types; + if (@available(macOS 14.0, *)) { + types = @[ + AVCaptureDeviceTypeBuiltInWideAngleCamera, AVCaptureDeviceTypeExternal, + AVCaptureDeviceTypeContinuityCamera + ]; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + types = @[ AVCaptureDeviceTypeBuiltInWideAngleCamera, AVCaptureDeviceTypeExternalUnknown ]; +#pragma clang diagnostic pop + } + return [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:types + mediaType:AVMediaTypeVideo + position:AVCaptureDevicePositionUnspecified] + .devices; +} + +@interface SimCameraFeed () +@end + +@implementation SimCameraFeed { + SimCamShmHeader *_header; + SimCamSurfaceTable *_table; + IOSurfaceRef _surfaces[SIMCAM_SURFACE_RING]; + uint32_t _writeIndex; + uint32_t _width; + uint32_t _height; + atomic_uint_fast64_t _frameSeq; + atomic_bool _stopped; + dispatch_queue_t _sourceQueue; // serial; owns source lifecycle + // Serializes PublishFrame against -stop releasing the ring. + NSLock *_publishLock; + + AVCaptureSession *_webcamSession; + dispatch_queue_t _webcamQueue; + uint8_t *_webcamScratch; + size_t _webcamScratchSize; + + dispatch_queue_t _videoQueue; + atomic_bool _videoCancelled; + dispatch_semaphore_t _videoStopped; // signaled when the loop exits +} + +- (nullable instancetype)initWithShmName:(NSString *)shmName { + if (!(self = [super init])) return nil; + _shmName = [shmName copy]; + _width = SIMCAM_DEFAULT_WIDTH; + _height = SIMCAM_DEFAULT_HEIGHT; + _sourceQueue = dispatch_queue_create("simutil.camera.source", DISPATCH_QUEUE_SERIAL); + _publishLock = [NSLock new]; + if (![self openShm]) { + [self releaseSurfaces]; + return nil; + } + return self; +} + +- (void)dealloc { + [self stop]; + free(_webcamScratch); +} + +#pragma mark - Shm + surfaces + +- (BOOL)createSurfaces { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSDictionary *props = @{ + (id)kIOSurfaceWidth : @(_width), + (id)kIOSurfaceHeight : @(_height), + (id)kIOSurfaceBytesPerElement : @4, + (id)kIOSurfacePixelFormat : @((uint32_t)kCVPixelFormatType_32BGRA), + // Global so the simulator process can resolve the surface by ID. + (id)kIOSurfaceIsGlobal : @YES, + }; +#pragma clang diagnostic pop + for (uint32_t i = 0; i < SIMCAM_SURFACE_RING; i++) { + IOSurfaceRef s = IOSurfaceCreate((__bridge CFDictionaryRef)props); + if (!s) return NO; + _surfaces[i] = s; + _table->ids[i] = IOSurfaceGetID(s); + } + _table->surfaceCount = SIMCAM_SURFACE_RING; + _table->latestIndex = 0; + return YES; +} + +- (void)releaseSurfaces { + for (uint32_t i = 0; i < SIMCAM_SURFACE_RING; i++) { + if (_surfaces[i]) { + CFRelease(_surfaces[i]); + _surfaces[i] = NULL; + } + } +} + +- (BOOL)openShm { + const char *name = _shmName.UTF8String; + size_t size = (size_t)SimCamControlSize(); + shm_unlink(name); + int fd = shm_open(name, O_CREAT | O_RDWR, 0644); + if (fd < 0) return NO; + if (ftruncate(fd, (off_t)size) < 0) { + close(fd); + return NO; + } + void *map = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + if (map == MAP_FAILED) return NO; + memset(map, 0, size); + _header = (SimCamShmHeader *)map; + _table = (SimCamSurfaceTable *)((uint8_t *)map + sizeof(SimCamShmHeader)); + if (![self createSurfaces]) { + munmap(map, size); + _header = NULL; + _table = NULL; + shm_unlink(name); + return NO; + } + _header->magic = SIMCAM_SHM_MAGIC; + _header->version = 2; + _header->width = _width; + _header->height = _height; + _header->pixelFormat = SIMCAM_PIXEL_BGRA; + _header->bytesPerRow = (uint32_t)IOSurfaceGetBytesPerRow(_surfaces[0]); + _header->pixelByteSize = (uint64_t)_width * _height * 4; + _header->mirrorMode = SIMCAM_MIRROR_UNSET; // dylib falls back to env + return YES; +} + +// Publish a fully-prepared BGRA frame (_width x _height, packed at _width*4 +// bytes per row) into the next free ring surface, so latestIndex/frameSeq +// stay coherent for the dylib's tear-detection check. +- (void)publishFrame:(const uint8_t *)bgra { + if (!bgra) return; + [_publishLock lock]; + if (!_header || !_table || _table->surfaceCount == 0) { + [_publishLock unlock]; + return; + } + uint32_t count = _table->surfaceCount; + // Render into a surface the reader isn't holding and isn't the one it last + // published, so an in-flight frame is never overwritten mid-read. + uint32_t latest = _table->latestIndex; + uint32_t idx = _writeIndex; + BOOL found = NO; + for (uint32_t tries = 0; tries < count; tries++) { + idx = (idx + 1) % count; + if (idx == latest || !_surfaces[idx]) continue; + if (!IOSurfaceIsInUse(_surfaces[idx])) { + found = YES; + break; + } + } + if (!found) { + [_publishLock unlock]; + return; + } + IOSurfaceRef surface = _surfaces[idx]; + _writeIndex = idx; + IOSurfaceLock(surface, 0, NULL); + uint8_t *dst = (uint8_t *)IOSurfaceGetBaseAddress(surface); + size_t dstStride = IOSurfaceGetBytesPerRow(surface); + size_t srcStride = (size_t)_width * 4; + if (dstStride == srcStride) { + memcpy(dst, bgra, srcStride * _height); + } else { + for (uint32_t y = 0; y < _height; y++) { + memcpy(dst + (size_t)y * dstStride, bgra + (size_t)y * srcStride, srcStride); + } + } + IOSurfaceUnlock(surface, 0, NULL); + + _table->latestIndex = idx; + _header->timestampNs = MachAbsToNs(mach_absolute_time()); + atomic_thread_fence(memory_order_release); + uint64_t next = atomic_fetch_add(&_frameSeq, 1) + 1; + atomic_store_explicit(&_header->frameSeq, next, memory_order_release); + [_publishLock unlock]; +} + +#pragma mark - Webcam source + ++ (NSArray *> *)webcams { + NSMutableArray *out = [NSMutableArray array]; + for (AVCaptureDevice *d in HostCameras()) { + [out addObject:@{@"id" : d.uniqueID, @"name" : d.localizedName}]; + } + return out; +} + +static AVCaptureDevice *PickWebcamDevice(NSString *idOrName) { + NSArray *devices = HostCameras(); + if (!idOrName.length) { + for (AVCaptureDevice *d in devices) + if (d.position == AVCaptureDevicePositionFront) return d; + return devices.firstObject; + } + for (AVCaptureDevice *d in devices) + if ([d.uniqueID isEqualToString:idOrName]) return d; + for (AVCaptureDevice *d in devices) + if ([d.localizedName.lowercaseString containsString:idOrName.lowercaseString]) return d; + return nil; +} + +- (void)captureOutput:(AVCaptureOutput *)out + didOutputSampleBuffer:(CMSampleBufferRef)sb + fromConnection:(AVCaptureConnection *)conn { + CVImageBufferRef pb = CMSampleBufferGetImageBuffer(sb); + if (!pb) return; + if (CVPixelBufferGetPixelFormatType(pb) != kCVPixelFormatType_32BGRA) return; + CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly); + size_t srcW = CVPixelBufferGetWidth(pb); + size_t srcH = CVPixelBufferGetHeight(pb); + size_t srcStride = CVPixelBufferGetBytesPerRow(pb); + void *src = CVPixelBufferGetBaseAddress(pb); + size_t need = (size_t)_width * _height * 4; + if (_webcamScratchSize < need) { + free(_webcamScratch); + _webcamScratch = malloc(need); + _webcamScratchSize = need; + } + vImage_Buffer s = {src, srcH, srcW, srcStride}; + vImage_Buffer d = {_webcamScratch, _height, _width, (size_t)_width * 4}; + vImage_Error verr = vImageScale_ARGB8888(&s, &d, NULL, kvImageHighQualityResampling); + CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly); + if (verr == kvImageNoError) [self publishFrame:_webcamScratch]; +} + +- (nullable NSString *)startWebcam:(NSString *)deviceArg { + AVCaptureDevice *device = PickWebcamDevice(deviceArg); + if (!device) return @"No matching camera"; + NSError *e = nil; + AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&e]; + if (!input) return e.localizedDescription ?: @"Could not open the camera"; + AVCaptureSession *sess = [AVCaptureSession new]; + sess.sessionPreset = AVCaptureSessionPreset1280x720; + if (![sess canAddInput:input]) return @"The camera is in use"; + [sess addInput:input]; + AVCaptureVideoDataOutput *out = [AVCaptureVideoDataOutput new]; + out.alwaysDiscardsLateVideoFrames = YES; + out.videoSettings = @{(id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA)}; + if (!_webcamQueue) { + _webcamQueue = dispatch_queue_create("simutil.camera.webcam", DISPATCH_QUEUE_SERIAL); + } + [out setSampleBufferDelegate:self queue:_webcamQueue]; + if (![sess canAddOutput:out]) return @"The camera has no video output"; + [sess addOutput:out]; + [sess startRunning]; + _webcamSession = sess; + return nil; +} + +- (void)stopWebcam { + if (!_webcamSession) return; + [_webcamSession stopRunning]; + _webcamSession = nil; + // Wait out a frame still being written on the delegate queue. + if (_webcamQueue) dispatch_sync(_webcamQueue, ^{}); +} + +#pragma mark - Image source + +- (nullable NSString *)startImage:(NSString *)path { + if (!path.length) return @"The image source needs a file"; + CGImageSourceRef src = + CGImageSourceCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:path], NULL); + if (!src) return @"Could not open the image"; + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + if (!img) return @"Could not decode the image"; + + size_t bpr = (size_t)_width * 4; + uint8_t *buf = calloc(1, bpr * _height); + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(buf, _width, _height, 8, bpr, cs, + kSimCamBGRA); + CGColorSpaceRelease(cs); + size_t iw = CGImageGetWidth(img), ih = CGImageGetHeight(img); + double sx = (double)_width / iw, sy = (double)_height / ih; + // Aspect-fit file sources so the full source frame remains visible. + double s = MIN(sx, sy); + double dw = iw * s, dh = ih * s; + CGContextDrawImage(ctx, CGRectMake((_width - dw) / 2.0, (_height - dh) / 2.0, dw, dh), img); + CGContextRelease(ctx); + CGImageRelease(img); + + [self publishFrame:buf]; + free(buf); + return nil; +} + +#pragma mark - Video source (looping playback via AVAssetReader) + +// Looping AVAsset playback at native FPS. Frames are decoded as BGRA on a +// background queue, aspect-fit into the ring, then paced against the track's +// presentation timestamps so playback runs at real time. When the reader +// completes it is recreated with a fresh wall-clock anchor. + +static AVAssetReaderTrackOutput *MakeVideoOutput(AVAssetReader **outReader, AVAssetTrack *track, + NSString **errOut) { + NSError *e = nil; + AVAsset *asset = track.asset; + AVAssetReader *reader = asset ? [AVAssetReader assetReaderWithAsset:asset error:&e] : nil; + if (!reader) { + if (errOut) *errOut = e.localizedDescription ?: @"AVAssetReader init failed"; + return nil; + } + AVAssetReaderTrackOutput *out = [AVAssetReaderTrackOutput + assetReaderTrackOutputWithTrack:track + outputSettings:@{ + (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), + (id)kCVPixelBufferIOSurfacePropertiesKey : @{}, + }]; + out.alwaysCopiesSampleData = NO; + if (![reader canAddOutput:out]) { + if (errOut) *errOut = @"reader rejected BGRA output"; + return nil; + } + [reader addOutput:out]; + if (![reader startReading]) { + if (errOut) *errOut = reader.error.localizedDescription ?: @"reader failed to start"; + return nil; + } + *outReader = reader; + return out; +} + +// Aspect-fit a source pixel buffer into a new BGRA buffer of the ring size; +// the caller frees it. +- (uint8_t *)renderPixelBuffer:(CVPixelBufferRef)pb { + size_t srcW = CVPixelBufferGetWidth(pb); + size_t srcH = CVPixelBufferGetHeight(pb); + if (srcW == 0 || srcH == 0) return NULL; + CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly); + uint8_t *src = CVPixelBufferGetBaseAddress(pb); + size_t srcBPR = CVPixelBufferGetBytesPerRow(pb); + if (!src) { + CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly); + return NULL; + } + + size_t bpr = (size_t)_width * 4; + uint8_t *out = calloc(1, bpr * _height); + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(out, _width, _height, 8, bpr, cs, + kSimCamBGRA); + CGColorSpaceRelease(cs); + + CGDataProviderRef dp = CGDataProviderCreateWithData(NULL, src, srcBPR * srcH, NULL); + CGColorSpaceRef imgCs = CGColorSpaceCreateDeviceRGB(); + CGImageRef img = CGImageCreate(srcW, srcH, 8, 32, srcBPR, imgCs, + kSimCamBGRA, dp, NULL, + false, kCGRenderingIntentDefault); + CGColorSpaceRelease(imgCs); + CGDataProviderRelease(dp); + + double sx = (double)_width / srcW, sy = (double)_height / srcH; + // Keep video file playback letterboxed instead of cropping the source. + double s = MIN(sx, sy); + double dw = srcW * s, dh = srcH * s; + CGContextDrawImage(ctx, CGRectMake((_width - dw) / 2.0, (_height - dh) / 2.0, dw, dh), img); + CGImageRelease(img); + CGContextRelease(ctx); + CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly); + return out; +} + +- (void)runVideoLoop:(NSString *)path stopped:(dispatch_semaphore_t)stopped { + AVAsset *asset = [AVAsset assetWithURL:[NSURL fileURLWithPath:path]]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + AVAssetTrack *track = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject; +#pragma clang diagnostic pop + while (track && !atomic_load(&_videoCancelled)) { + NSString *err = nil; + AVAssetReader *reader = nil; + AVAssetReaderTrackOutput *out = MakeVideoOutput(&reader, track, &err); + if (!out) { + NSLog(@"[SimCamera] video reader failed: %@", err); + break; + } + + uint64_t loopStartNs = MachAbsToNs(mach_absolute_time()); + while (!atomic_load(&_videoCancelled)) { + CMSampleBufferRef sb = [out copyNextSampleBuffer]; + if (!sb) break; // end of track or read error → loop or exit + CMTime pts = CMSampleBufferGetPresentationTimeStamp(sb); + CVPixelBufferRef pb = CMSampleBufferGetImageBuffer(sb); + uint8_t *frame = pb ? [self renderPixelBuffer:pb] : NULL; + if (frame) { + // Pace against wall clock: don't publish until the frame's PTS has + // caught up, in 100ms slices so cancellation stays responsive. + if (CMTIME_IS_VALID(pts) && pts.timescale > 0) { + uint64_t targetNs = loopStartNs + (uint64_t)((double)pts.value * 1e9 / pts.timescale); + uint64_t nowNs = MachAbsToNs(mach_absolute_time()); + uint64_t sleepNs = targetNs > nowNs ? targetNs - nowNs : 0; + while (sleepNs > 0 && !atomic_load(&_videoCancelled)) { + uint64_t slice = sleepNs > 100000000ULL ? 100000000ULL : sleepNs; + struct timespec ts = { + .tv_sec = (time_t)(slice / 1000000000ULL), + .tv_nsec = (long)(slice % 1000000000ULL), + }; + nanosleep(&ts, NULL); + sleepNs -= slice; + } + } + [self publishFrame:frame]; + free(frame); + } + CFRelease(sb); + } + + AVAssetReaderStatus status = reader.status; + [reader cancelReading]; + if (status == AVAssetReaderStatusFailed) { + NSLog(@"[SimCamera] video reader failed mid-loop: %@", reader.error.localizedDescription); + break; + } + // Otherwise rewind by re-creating the reader on the next iteration. + } + dispatch_semaphore_signal(stopped); +} + +- (nullable NSString *)startVideo:(NSString *)path { + if (!path.length) return @"The video source needs a file"; + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + return [NSString stringWithFormat:@"Video file not found: %@", path]; + } + if (!_videoQueue) { + _videoQueue = dispatch_queue_create("simutil.camera.video", DISPATCH_QUEUE_SERIAL); + } + atomic_store(&_videoCancelled, false); + dispatch_semaphore_t stopped = dispatch_semaphore_create(0); + _videoStopped = stopped; + NSString *captured = [path copy]; + dispatch_async(_videoQueue, ^{ + [self runVideoLoop:captured stopped:stopped]; + }); + return nil; +} + +- (void)stopVideoWaiting:(dispatch_time_t)deadline { + if (!_videoStopped) return; + atomic_store(&_videoCancelled, true); + dispatch_semaphore_wait(_videoStopped, deadline); + _videoStopped = nil; +} + +#pragma mark - Switch / stop + +- (void)stopActiveSource { + [self stopWebcam]; + // A 1s cap so a wedged decoder cannot stall a hot-swap. + [self stopVideoWaiting:dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC)]; + _source = nil; + _arg = nil; +} + +- (nullable NSString *)switchToSource:(NSString *)source arg:(nullable NSString *)arg { + __block NSString *err = nil; + dispatch_sync(_sourceQueue, ^{ + if (atomic_load(&self->_stopped)) { + err = @"The camera feed was stopped"; + return; + } + [self stopActiveSource]; + if ([source isEqualToString:@"webcam"]) { + err = [self startWebcam:arg ?: @""]; + } else if ([source isEqualToString:@"image"]) { + err = [self startImage:arg ?: @""]; + } else if ([source isEqualToString:@"video"]) { + err = [self startVideo:arg ?: @""]; + } else { + err = [NSString stringWithFormat:@"Unknown camera source %@", source]; + } + if (!err) { + self->_source = [source copy]; + self->_arg = [arg copy]; + } + }); + return err; +} + +- (void)stop { + if (atomic_exchange(&_stopped, true)) return; + // Unlink the name first: if a source teardown crashes, the name must not + // stay resolvable. + shm_unlink(_shmName.UTF8String); + dispatch_sync(_sourceQueue, ^{ + [self stopWebcam]; + [self stopVideoWaiting:DISPATCH_TIME_FOREVER]; + }); + [_publishLock lock]; + [self releaseSurfaces]; + if (_header) munmap(_header, (size_t)SimCamControlSize()); + _header = NULL; + _table = NULL; + [_publishLock unlock]; +} + +@end diff --git a/app/macos/SimCameraInjector/LICENSE b/app/macos/SimCameraInjector/LICENSE new file mode 100644 index 0000000..1d3e4e4 --- /dev/null +++ b/app/macos/SimCameraInjector/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Evan Bacon + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/macos/SimCameraInjector/NOTICE b/app/macos/SimCameraInjector/NOTICE new file mode 100644 index 0000000..9897ebd --- /dev/null +++ b/app/macos/SimCameraInjector/NOTICE @@ -0,0 +1,17 @@ +SimCameraInjector + +The Objective-C sources in this directory (everything except build.sh, +NOTICE and LICENSE) are copied from serve-sim +(https://github.com/EvanBacon/serve-sim, packages/serve-sim/Sources/ +SimCameraInjector at commit a30d70a55e05654d974c86803b68d4f675be77e3, +serve-sim 0.1.46), Copyright Evan Bacon, licensed under the Apache +License, Version 2.0 (see LICENSE in this directory). + +Modifications by SimUtil: the fake camera names (SimCamFakes.m), its +manufacturer (SimCamFakes.m) and the fallback image text +(SimCamFrameSource.m) say SimUtil instead of serve-sim. + +SimUtil builds them into libSimCameraInjector.dylib (build.sh, run by the +Runner target) and ships it in the macOS app bundle's Resources. The +host-side feed in ../Runner/SimCameraFeed.m is adapted from serve-sim's +Sources/SimCameraHelper/main.m. diff --git a/app/macos/SimCameraInjector/SimCamFakes.h b/app/macos/SimCameraInjector/SimCamFakes.h new file mode 100644 index 0000000..d6e3b59 --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamFakes.h @@ -0,0 +1,89 @@ +#pragma once + +#import +#import + +#pragma mark - Mirror mode + +typedef NS_ENUM(NSInteger, SimCamMirrorMode) { + SimCamMirrorAuto = 0, + SimCamMirrorForceOn, + SimCamMirrorForceOff, +}; + +SimCamMirrorMode SimCamGetMirrorMode(void); +void SimCamSetMirrorMode(SimCamMirrorMode m); +BOOL SimCamShouldMirror(AVCaptureDevicePosition p); + +void SimCamReadMirrorModeFromEnv(void); + +#pragma mark - Position tag + +AVCaptureDevicePosition SimCamPositionOf(id obj); +void SimCamSetPosition(id obj, AVCaptureDevicePosition p); + +#pragma mark - Camera-in-use sticky flag + +BOOL SimCamCameraIsInUse(void); +void SimCamMarkCameraInUse(void); +void SimCamMarkSessionUsingFakeCamera(id session, BOOL usingFakeCamera); + +#pragma mark - AVF runtime-error notification suppression + +BOOL SimCamShouldSwallowAVFRuntimeError(NSNotificationName name, id object); +void SimCamLogSwallowedRuntimeError(NSString *via, id object, NSDictionary *userInfo); + +#pragma mark - Fake objects + +@interface SimCamWeakRef : NSObject +@property (nonatomic, weak) id target; +@end + +@interface SimCamFakeFormat : AVCaptureDeviceFormat +@end +@interface SimCamFakeDevice : AVCaptureDevice +@end +@interface SimCamFakeConnection : AVCaptureConnection +@end +@interface SimCamFakeResolvedPhotoSettings : AVCaptureResolvedPhotoSettings +@end + +@interface SimCamFakePhoto : AVCapturePhoto ++ (instancetype)photoFromImage:(CGImageRef)cgImage + jpegQuality:(CGFloat)q + mirrored:(BOOL)mirrored; +@end + +@interface SimCamAttitude : CMAttitude +@end +@interface SimCamAccelerometerData : CMAccelerometerData +@end +@interface SimCamGyroData : CMGyroData +@end +@interface SimCamMagnetometerData : CMMagnetometerData +@end +@interface SimCamDeviceMotion : CMDeviceMotion +@end + +#pragma mark - Fake-object factories / caches + +AVCaptureDeviceFormat *SimCamSharedFakeFormat(void); +AVCaptureDevice *SimCamFakeDeviceForPosition(AVCaptureDevicePosition p); +AVCaptureDeviceInput *SimCamFakeInputForPosition(AVCaptureDevicePosition p); +NSArray *SimCamFakePortsForInput(AVCaptureInput *input); + +AVCaptureConnection *SimCamFakeConnectionForOutput(AVCaptureOutput *out); +void SimCamSetOutputInput(AVCaptureOutput *out, AVCaptureInput *input); +AVCaptureInput *SimCamOutputInput(AVCaptureOutput *out); + +CMAttitude *SimCamSharedAttitude(void); +CMAccelerometerData *SimCamSharedAccelerometerData(void); +CMGyroData *SimCamSharedGyroData(void); +CMMagnetometerData *SimCamSharedMagnetometerData(void); +CMDeviceMotion *SimCamSharedDeviceMotion(void); + +#pragma mark - Fake-input marking (used by AVCaptureDeviceInput swizzle) + +void SimCamMarkFakeInput(id input, AVCaptureDevice *fakeDevice); +BOOL SimCamIsFakeInput(id input); +AVCaptureDevice *SimCamFakeInputDevice(id input); diff --git a/app/macos/SimCameraInjector/SimCamFakes.m b/app/macos/SimCameraInjector/SimCamFakes.m new file mode 100644 index 0000000..6c14469 --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamFakes.m @@ -0,0 +1,709 @@ +#import "SimCamFakes.h" +#import "SimCamLog.h" + +#import +#import +#import +#import +#import +#import +#include +#include + +#pragma mark - Mirror mode + +static SimCamMirrorMode gMirrorMode = SimCamMirrorAuto; + +SimCamMirrorMode SimCamGetMirrorMode(void) { return gMirrorMode; } +void SimCamSetMirrorMode(SimCamMirrorMode m) { gMirrorMode = m; } + +BOOL SimCamShouldMirror(AVCaptureDevicePosition p) { + if (gMirrorMode == SimCamMirrorForceOn) return YES; + if (gMirrorMode == SimCamMirrorForceOff) return NO; + return p == AVCaptureDevicePositionFront; +} + +void SimCamReadMirrorModeFromEnv(void) { + const char *m = getenv("SIMCAM_MIRROR_MODE"); + if (!m) return; + if (!strcasecmp(m, "on") || !strcmp(m, "1") || !strcasecmp(m, "true")) { + gMirrorMode = SimCamMirrorForceOn; + simcam_log(@"mirror mode forced ON"); + } else if (!strcasecmp(m, "off") || !strcmp(m, "0") || !strcasecmp(m, "false")) { + gMirrorMode = SimCamMirrorForceOff; + simcam_log(@"mirror mode forced OFF"); + } else if (!strcasecmp(m, "auto")) { + gMirrorMode = SimCamMirrorAuto; + } +} + +#pragma mark - Position tag tracking + +static char kSimCamPositionKey; + +AVCaptureDevicePosition SimCamPositionOf(id obj) { + if (!obj) return AVCaptureDevicePositionFront; + NSNumber *n = objc_getAssociatedObject(obj, &kSimCamPositionKey); + return n ? (AVCaptureDevicePosition)n.intValue : AVCaptureDevicePositionFront; +} +void SimCamSetPosition(id obj, AVCaptureDevicePosition p) { + objc_setAssociatedObject(obj, &kSimCamPositionKey, @(p), OBJC_ASSOCIATION_RETAIN); +} + +#pragma mark - Camera-in-use sticky flag + +static atomic_int gSimCamCameraInUse = 0; +static char kSimCamSessionUsingFakeCameraKey; + +BOOL SimCamCameraIsInUse(void) { + return atomic_load_explicit(&gSimCamCameraInUse, memory_order_relaxed) > 0; +} +void SimCamMarkCameraInUse(void) { + atomic_store_explicit(&gSimCamCameraInUse, 1, memory_order_relaxed); +} +void SimCamMarkSessionUsingFakeCamera(id session, BOOL usingFakeCamera) { + if (!session) return; + objc_setAssociatedObject(session, + &kSimCamSessionUsingFakeCameraKey, + usingFakeCamera ? @YES : nil, + OBJC_ASSOCIATION_RETAIN); +} +static BOOL SimCamSessionUsesFakeCamera(id session) { + if (!session) return NO; + if (![session isKindOfClass:[AVCaptureSession class]]) return NO; + return [objc_getAssociatedObject(session, &kSimCamSessionUsingFakeCameraKey) boolValue]; +} + +#pragma mark - AVF runtime-error notification suppression + +BOOL SimCamShouldSwallowAVFRuntimeError(NSNotificationName name, id object) { + if (!name) return NO; + if (![name isEqualToString:AVCaptureSessionRuntimeErrorNotification]) return NO; + return SimCamSessionUsesFakeCamera(object); +} + +void SimCamLogSwallowedRuntimeError(NSString *via, id object, NSDictionary *userInfo) { + NSError *err = userInfo[AVCaptureSessionErrorKey]; + simcam_log(@"SWALLOW AVCaptureSessionRuntimeError via %@ object=%@ code=%ld domain=%@ desc=%@ underlying=%@", + via, + object ? NSStringFromClass([object class]) : @"", + (long)err.code, + err.domain ?: @"", + err.localizedDescription ?: @"", + err.userInfo[NSUnderlyingErrorKey] ?: @""); +} + +#pragma mark - Weak delegate ref + +@implementation SimCamWeakRef +@end + +#pragma mark - SimCamFakeFrameRateRange + +@interface SimCamFakeFrameRateRange : AVFrameRateRange +@end +@implementation SimCamFakeFrameRateRange +- (Float64)minFrameRate { return 1.0; } +- (Float64)maxFrameRate { return 60.0; } +- (CMTime)minFrameDuration { return CMTimeMake(1, 60); } +- (CMTime)maxFrameDuration { return CMTimeMake(1, 1); } +@end + +#pragma mark - SimCamFakeFormat + +@implementation SimCamFakeFormat { + CMVideoFormatDescriptionRef _fd; + NSArray *_ranges; +} +- (CMFormatDescriptionRef)formatDescription { + if (!_fd) { + CMVideoFormatDescriptionCreate(kCFAllocatorDefault, + kCVPixelFormatType_32BGRA, 1280, 720, NULL, &_fd); + } + return _fd; +} +- (NSArray *)videoSupportedFrameRateRanges { + if (!_ranges) { + AVFrameRateRange *r = (AVFrameRateRange *)class_createInstance( + [SimCamFakeFrameRateRange class], 0); + _ranges = r ? @[r] : @[]; + } + return _ranges; +} +- (NSString *)mediaType { return AVMediaTypeVideo; } +- (FourCharCode)mediaSubType { return kCVPixelFormatType_32BGRA; } +- (CMVideoDimensions)highResolutionStillImageDimensions { + return (CMVideoDimensions){ 1280, 720 }; +} +- (NSArray *)supportedMaxPhotoDimensions { + CMVideoDimensions dims = { 1920, 1080 }; + return @[ [NSValue valueWithBytes:&dims objCType:@encode(CMVideoDimensions)] ]; +} +- (BOOL)isHighestPhotoQualitySupported { return YES; } +- (BOOL)isVideoBinned { return NO; } +- (BOOL)isVideoStabilizationModeSupported:(AVCaptureVideoStabilizationMode)m { return NO; } +- (CGFloat)videoMaxZoomFactor { return 16.0; } +- (CGFloat)videoZoomFactorUpscaleThreshold { return 1.0; } +- (AVCaptureAutoFocusSystem)autoFocusSystem { return AVCaptureAutoFocusSystemNone; } +- (BOOL)isMultiCamSupported { return NO; } +- (NSArray *)supportedColorSpaces { return @[]; } +- (NSArray *)supportedDepthDataFormats { return @[]; } +- (BOOL)isPortraitEffectSupported { return NO; } +- (NSArray *)unsupportedCaptureOutputClasses { return @[]; } +- (BOOL)isStreamingDisparitySupported { return NO; } +- (float)minISO { return 25.0f; } +- (float)maxISO { return 6400.0f; } +- (CMTime)minExposureDuration { return CMTimeMake(1, 8000); } +- (CMTime)maxExposureDuration { return CMTimeMake(1, 30); } +- (id)figCaptureSourceVideoFormat { return nil; } +- (void)dealloc { if (_fd) CFRelease(_fd); } +@end + +AVCaptureDeviceFormat *SimCamSharedFakeFormat(void) { + static AVCaptureDeviceFormat *f = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + f = (AVCaptureDeviceFormat *)class_createInstance([SimCamFakeFormat class], 0); + }); + return f; +} + +#pragma mark - SimCamFakeDevice + +static char kFakePositionKey; + +@implementation SimCamFakeDevice +- (AVCaptureDevicePosition)position { + NSNumber *n = objc_getAssociatedObject(self, &kFakePositionKey); + return n ? (AVCaptureDevicePosition)n.intValue : AVCaptureDevicePositionFront; +} +- (NSString *)uniqueID { + return self.position == AVCaptureDevicePositionBack + ? @"sim-cam-fake-back-0" : @"sim-cam-fake-front-0"; +} +- (NSString *)modelID { return @"SimCamFakeCamera"; } +- (NSString *)localizedName { + return self.position == AVCaptureDevicePositionBack + ? @"SimUtil Back Camera" + : @"SimUtil Front Camera"; +} +- (NSString *)manufacturer { return @"SimUtil"; } +- (BOOL)hasMediaType:(AVMediaType)mediaType { return [mediaType isEqualToString:AVMediaTypeVideo]; } +- (BOOL)supportsAVCaptureSessionPreset:(AVCaptureSessionPreset)preset { return YES; } +- (AVCaptureDeviceType)deviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; } +- (NSArray *)formats { + AVCaptureDeviceFormat *f = SimCamSharedFakeFormat(); + return f ? @[f] : @[]; +} +- (BOOL)isConnected { return YES; } +- (BOOL)isSuspended { return NO; } +- (BOOL)lockForConfiguration:(NSError **)e { return YES; } +- (void)unlockForConfiguration { } +- (AVCaptureDeviceFormat *)activeFormat { return SimCamSharedFakeFormat(); } +- (CMTime)activeVideoMinFrameDuration { return CMTimeMake(1, 30); } +- (CMTime)activeVideoMaxFrameDuration { return CMTimeMake(1, 30); } +- (CGFloat)videoZoomFactor { return 1.0; } +- (void)setVideoZoomFactor:(CGFloat)v { (void)v; } +- (void)rampToVideoZoomFactor:(CGFloat)f withRate:(float)r { (void)f; (void)r; } +- (void)cancelVideoZoomRamp { } +- (BOOL)isRampingVideoZoom { return NO; } +- (CGFloat)minAvailableVideoZoomFactor { return 1.0; } +- (CGFloat)maxAvailableVideoZoomFactor { return 16.0; } +- (CGFloat)dualCameraSwitchOverVideoZoomFactor { return 2.0; } +- (NSArray *)virtualDeviceSwitchOverVideoZoomFactors { return @[]; } +- (NSArray *)constituentDevices { return @[]; } +- (BOOL)isVirtualDevice { return NO; } +- (BOOL)hasTorch { return NO; } +- (BOOL)hasFlash { return NO; } +- (BOOL)isTorchAvailable { return NO; } +- (BOOL)isTorchActive { return NO; } +- (AVCaptureTorchMode)torchMode { return AVCaptureTorchModeOff; } +- (void)setTorchMode:(AVCaptureTorchMode)m { (void)m; } +- (BOOL)isTorchModeSupported:(AVCaptureTorchMode)m { (void)m; return NO; } +- (BOOL)setTorchModeOnWithLevel:(float)l error:(NSError **)e { (void)l; if (e) *e = nil; return YES; } +- (AVCaptureFocusMode)focusMode { return AVCaptureFocusModeContinuousAutoFocus; } +- (void)setFocusMode:(AVCaptureFocusMode)m { (void)m; } +- (BOOL)isFocusModeSupported:(AVCaptureFocusMode)m { (void)m; return YES; } +- (CGPoint)focusPointOfInterest { return CGPointMake(0.5, 0.5); } +- (void)setFocusPointOfInterest:(CGPoint)p { (void)p; } +- (BOOL)isFocusPointOfInterestSupported { return YES; } +- (BOOL)isAdjustingFocus { return NO; } +- (BOOL)isSmoothAutoFocusEnabled { return NO; } +- (void)setSmoothAutoFocusEnabled:(BOOL)b { (void)b; } +- (BOOL)isSmoothAutoFocusSupported { return NO; } +- (AVCaptureAutoFocusRangeRestriction)autoFocusRangeRestriction { return AVCaptureAutoFocusRangeRestrictionNone; } +- (void)setAutoFocusRangeRestriction:(AVCaptureAutoFocusRangeRestriction)r { (void)r; } +- (BOOL)isAutoFocusRangeRestrictionSupported { return NO; } +- (AVCaptureExposureMode)exposureMode { return AVCaptureExposureModeContinuousAutoExposure; } +- (void)setExposureMode:(AVCaptureExposureMode)m { (void)m; } +- (BOOL)isExposureModeSupported:(AVCaptureExposureMode)m { (void)m; return YES; } +- (CGPoint)exposurePointOfInterest { return CGPointMake(0.5, 0.5); } +- (void)setExposurePointOfInterest:(CGPoint)p { (void)p; } +- (BOOL)isExposurePointOfInterestSupported { return YES; } +- (BOOL)isAdjustingExposure { return NO; } +- (float)exposureTargetBias { return 0.0f; } +- (float)minExposureTargetBias { return -8.0f; } +- (float)maxExposureTargetBias { return 8.0f; } +- (CMTime)exposureDuration { return CMTimeMake(1, 30); } +- (float)ISO { return 100.0f; } +- (float)minISO { return 25.0f; } +- (float)maxISO { return 6400.0f; } +- (CMTime)activeMinExposureDuration { return CMTimeMake(1, 8000); } +- (CMTime)activeMaxExposureDuration { return CMTimeMake(1, 30); } +- (AVCaptureWhiteBalanceMode)whiteBalanceMode { return AVCaptureWhiteBalanceModeContinuousAutoWhiteBalance; } +- (void)setWhiteBalanceMode:(AVCaptureWhiteBalanceMode)m { (void)m; } +- (BOOL)isWhiteBalanceModeSupported:(AVCaptureWhiteBalanceMode)m { (void)m; return YES; } +- (BOOL)isAdjustingWhiteBalance { return NO; } +- (BOOL)isFlashAvailable { return NO; } +- (BOOL)videoHDREnabled { return NO; } +- (void)setVideoHDREnabled:(BOOL)b { (void)b; } +- (BOOL)automaticallyAdjustsVideoHDREnabled { return NO; } +- (void)setAutomaticallyAdjustsVideoHDREnabled:(BOOL)b { (void)b; } +- (BOOL)isLowLightBoostSupported { return NO; } +- (BOOL)isLowLightBoostEnabled { return NO; } +- (BOOL)automaticallyEnablesLowLightBoostWhenAvailable { return NO; } +- (void)setAutomaticallyEnablesLowLightBoostWhenAvailable:(BOOL)b { (void)b; } +- (NSArray *)linkedDevices { return @[]; } +@end + +AVCaptureDevice *SimCamFakeDeviceForPosition(AVCaptureDevicePosition p) { + static AVCaptureDevice *front = nil; + static AVCaptureDevice *back = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + front = (AVCaptureDevice *)class_createInstance([SimCamFakeDevice class], 0); + objc_setAssociatedObject(front, &kFakePositionKey, + @(AVCaptureDevicePositionFront), OBJC_ASSOCIATION_RETAIN); + back = (AVCaptureDevice *)class_createInstance([SimCamFakeDevice class], 0); + objc_setAssociatedObject(back, &kFakePositionKey, + @(AVCaptureDevicePositionBack), OBJC_ASSOCIATION_RETAIN); + }); + return p == AVCaptureDevicePositionBack ? back : front; +} + +#pragma mark - SimCamFakeConnection + +@interface AVCaptureConnection (SimCamPrivate) +- (BOOL)sourcesFromExternalCamera; +- (AVCaptureVideoOrientation)_videoOrientation; +@end + +static AVCaptureInputPort *SimCamFakeInputPortForInput(AVCaptureInput *input, AVCaptureDevicePosition position); + +@implementation SimCamFakeConnection { + __weak AVCaptureOutput *_outputRef; + AVCaptureDevicePosition _position; + AVCaptureVideoOrientation _orientation; + BOOL _videoMirrored; + BOOL _automaticallyAdjustsVideoMirroring; + BOOL _enabled; +} ++ (instancetype)allocWithZone:(NSZone *)zone { + return class_createInstance([SimCamFakeConnection class], 0); +} ++ (instancetype)connectionForOutput:(AVCaptureOutput *)output + position:(AVCaptureDevicePosition)pos { + SimCamFakeConnection *c = [self alloc]; + if (c) { + c->_outputRef = output; + c->_position = pos; + c->_orientation = AVCaptureVideoOrientationPortrait; + c->_automaticallyAdjustsVideoMirroring = YES; + c->_videoMirrored = SimCamShouldMirror(pos); + c->_enabled = YES; + } + return c; +} +- (AVCaptureOutput *)output { return _outputRef; } +- (NSArray *)inputPorts { + AVCaptureInput *input = SimCamOutputInput(_outputRef) ?: SimCamFakeInputForPosition(_position); + return SimCamFakePortsForInput(input); +} +- (AVCaptureInput *)input { return SimCamOutputInput(_outputRef) ?: SimCamFakeInputForPosition(_position); } +- (AVCaptureVideoPreviewLayer *)videoPreviewLayer { return nil; } +- (BOOL)isEnabled { return _enabled; } +- (void)setEnabled:(BOOL)e { _enabled = e; } +- (BOOL)isActive { return YES; } +- (NSArray *)audioChannels { return @[]; } +- (AVMediaType)mediaType { return AVMediaTypeVideo; } + +- (AVCaptureDevice *)sourceDevice { return SimCamFakeDeviceForPosition(_position); } +- (AVCaptureDeviceType)sourceDeviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; } +- (AVCaptureDevicePosition)sourceDevicePosition { return _position; } +- (AVCaptureSession *)originatingSession { return nil; } + +- (AVCaptureDeviceInput *)deviceInput { + return SimCamFakeInputForPosition(_position); +} + +- (BOOL)isVideoOrientationSupported { return YES; } +- (AVCaptureVideoOrientation)videoOrientation { return _orientation; } +- (void)setVideoOrientation:(AVCaptureVideoOrientation)o { _orientation = o; } + +- (BOOL)isVideoRotationAngleSupported:(CGFloat)angle { (void)angle; return YES; } +- (CGFloat)videoRotationAngle { + switch (_orientation) { + case AVCaptureVideoOrientationPortrait: return 90.0; + case AVCaptureVideoOrientationPortraitUpsideDown: return 270.0; + case AVCaptureVideoOrientationLandscapeRight: return 0.0; + case AVCaptureVideoOrientationLandscapeLeft: return 180.0; + default: return 90.0; + } +} +- (void)setVideoRotationAngle:(CGFloat)angle { + long a = ((long)angle % 360 + 360) % 360; + if (a == 0) _orientation = AVCaptureVideoOrientationLandscapeRight; + else if (a == 90) _orientation = AVCaptureVideoOrientationPortrait; + else if (a == 180) _orientation = AVCaptureVideoOrientationLandscapeLeft; + else if (a == 270) _orientation = AVCaptureVideoOrientationPortraitUpsideDown; +} + +- (BOOL)isVideoMirroringSupported { return YES; } +- (BOOL)isVideoMirrored { + if (_automaticallyAdjustsVideoMirroring) return SimCamShouldMirror(_position); + return _videoMirrored; +} +- (void)setVideoMirrored:(BOOL)m { + _videoMirrored = m; + _automaticallyAdjustsVideoMirroring = NO; +} +- (BOOL)automaticallyAdjustsVideoMirroring { return _automaticallyAdjustsVideoMirroring; } +- (void)setAutomaticallyAdjustsVideoMirroring:(BOOL)b { + _automaticallyAdjustsVideoMirroring = b; + if (b) _videoMirrored = SimCamShouldMirror(_position); +} + +- (BOOL)isVideoMinFrameDurationSupported { return NO; } +- (BOOL)isVideoMaxFrameDurationSupported { return NO; } +- (CMTime)videoMinFrameDuration { return kCMTimeInvalid; } +- (CMTime)videoMaxFrameDuration { return kCMTimeInvalid; } +- (void)setVideoMinFrameDuration:(CMTime)d { (void)d; } +- (void)setVideoMaxFrameDuration:(CMTime)d { (void)d; } + +- (BOOL)isVideoStabilizationSupported { return NO; } +- (AVCaptureVideoStabilizationMode)preferredVideoStabilizationMode { return AVCaptureVideoStabilizationModeOff; } +- (void)setPreferredVideoStabilizationMode:(AVCaptureVideoStabilizationMode)m { (void)m; } +- (AVCaptureVideoStabilizationMode)activeVideoStabilizationMode { return AVCaptureVideoStabilizationModeOff; } +- (BOOL)isVideoStabilizationEnabled { return NO; } +- (BOOL)enablesVideoStabilizationWhenAvailable { return NO; } +- (void)setEnablesVideoStabilizationWhenAvailable:(BOOL)b { (void)b; } + +- (BOOL)isCameraIntrinsicMatrixDeliverySupported { return NO; } +- (BOOL)isCameraIntrinsicMatrixDeliveryEnabled { return NO; } +- (void)setCameraIntrinsicMatrixDeliveryEnabled:(BOOL)b { (void)b; } + +- (BOOL)isVideoFieldModeSupported { return NO; } +- (CGFloat)videoMaxScaleAndCropFactor { return 1.0; } +- (CGFloat)videoScaleAndCropFactor { return 1.0; } +- (void)setVideoScaleAndCropFactor:(CGFloat)v { (void)v; } + +- (AVCaptureVideoOrientation)_videoOrientation { return _orientation; } +- (BOOL)sourcesFromExternalCamera { return NO; } +@end + +static char kSimCamOutputConnectionKey; + +AVCaptureConnection *SimCamFakeConnectionForOutput(AVCaptureOutput *out) { + if (!out) return nil; + AVCaptureConnection *conn = objc_getAssociatedObject(out, &kSimCamOutputConnectionKey); + if (!conn) { + conn = (AVCaptureConnection *)[SimCamFakeConnection + connectionForOutput:out + position:SimCamPositionOf(out)]; + if (conn) { + objc_setAssociatedObject(out, &kSimCamOutputConnectionKey, conn, + OBJC_ASSOCIATION_RETAIN); + } + } + return conn; +} + +#pragma mark - SimCamFakeInputPort + +@interface SimCamFakeInputPort : AVCaptureInputPort +@end + +@implementation SimCamFakeInputPort { + __weak AVCaptureInput *_inputRef; + AVCaptureDevicePosition _position; +} ++ (instancetype)allocWithZone:(NSZone *)zone { + return class_createInstance([SimCamFakeInputPort class], 0); +} ++ (instancetype)portForInput:(AVCaptureInput *)input position:(AVCaptureDevicePosition)position { + SimCamFakeInputPort *p = [self alloc]; + if (p) { + p->_inputRef = input; + p->_position = position; + } + return p; +} +- (AVCaptureInput *)input { return _inputRef; } +- (AVMediaType)mediaType { return AVMediaTypeVideo; } +- (AVCaptureDeviceType)sourceDeviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; } +- (AVCaptureDevicePosition)sourceDevicePosition { return _position; } +- (CMFormatDescriptionRef)formatDescription { return SimCamSharedFakeFormat().formatDescription; } +- (BOOL)isEnabled { return YES; } +- (void)setEnabled:(BOOL)enabled { (void)enabled; } +@end + +static char kSimCamOutputInputRefKey; +static char kSimCamFakeInputPortKey; + +void SimCamSetOutputInput(AVCaptureOutput *out, AVCaptureInput *input) { + if (!out) return; + if (!input) { + objc_setAssociatedObject(out, &kSimCamOutputInputRefKey, nil, OBJC_ASSOCIATION_RETAIN); + return; + } + SimCamWeakRef *ref = [SimCamWeakRef new]; + ref.target = input; + objc_setAssociatedObject(out, &kSimCamOutputInputRefKey, ref, OBJC_ASSOCIATION_RETAIN); +} + +AVCaptureInput *SimCamOutputInput(AVCaptureOutput *out) { + if (!out) return nil; + SimCamWeakRef *ref = objc_getAssociatedObject(out, &kSimCamOutputInputRefKey); + return ref.target; +} + +static AVCaptureInputPort *SimCamFakeInputPortForInput(AVCaptureInput *input, AVCaptureDevicePosition position) { + if (!input) return nil; + AVCaptureInputPort *port = objc_getAssociatedObject(input, &kSimCamFakeInputPortKey); + if (!port) { + port = (AVCaptureInputPort *)[SimCamFakeInputPort portForInput:input position:position]; + if (port) { + objc_setAssociatedObject(input, &kSimCamFakeInputPortKey, port, OBJC_ASSOCIATION_RETAIN); + } + } + return port; +} + +NSArray *SimCamFakePortsForInput(AVCaptureInput *input) { + if (!SimCamIsFakeInput(input)) return @[]; + AVCaptureInputPort *port = SimCamFakeInputPortForInput(input, SimCamPositionOf(input)); + return port ? @[port] : @[]; +} + +#pragma mark - SimCamFakeInput marking + +static char kSimCamFakeInputKey; +static char kSimCamFakeInputDeviceKey; + +void SimCamMarkFakeInput(id input, AVCaptureDevice *fakeDevice) { + if (!input) return; + objc_setAssociatedObject(input, &kSimCamFakeInputKey, @YES, OBJC_ASSOCIATION_RETAIN); + if (fakeDevice) { + objc_setAssociatedObject(input, &kSimCamFakeInputDeviceKey, fakeDevice, OBJC_ASSOCIATION_RETAIN); + } +} +BOOL SimCamIsFakeInput(id input) { + if (!input) return NO; + return [objc_getAssociatedObject(input, &kSimCamFakeInputKey) boolValue]; +} +AVCaptureDevice *SimCamFakeInputDevice(id input) { + if (!input) return nil; + return objc_getAssociatedObject(input, &kSimCamFakeInputDeviceKey); +} + +AVCaptureDeviceInput *SimCamFakeInputForPosition(AVCaptureDevicePosition p) { + static AVCaptureDeviceInput *front = nil; + static AVCaptureDeviceInput *back = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + front = (AVCaptureDeviceInput *)class_createInstance([AVCaptureDeviceInput class], 0); + SimCamMarkFakeInput(front, SimCamFakeDeviceForPosition(AVCaptureDevicePositionFront)); + SimCamSetPosition(front, AVCaptureDevicePositionFront); + + back = (AVCaptureDeviceInput *)class_createInstance([AVCaptureDeviceInput class], 0); + SimCamMarkFakeInput(back, SimCamFakeDeviceForPosition(AVCaptureDevicePositionBack)); + SimCamSetPosition(back, AVCaptureDevicePositionBack); + }); + return p == AVCaptureDevicePositionBack ? back : front; +} + +#pragma mark - SimCamFakeResolvedPhotoSettings + +@interface SimCamFakeResolvedPhotoSettings (SimCamFactory) ++ (instancetype)settingsWithDimensions:(CMVideoDimensions)dims; +@end + +@implementation SimCamFakeResolvedPhotoSettings { + CMVideoDimensions _photoDims; +} ++ (instancetype)allocWithZone:(NSZone *)zone { + return class_createInstance([SimCamFakeResolvedPhotoSettings class], 0); +} ++ (instancetype)settingsWithDimensions:(CMVideoDimensions)dims { + SimCamFakeResolvedPhotoSettings *s = [self alloc]; + if (s) s->_photoDims = dims; + return s; +} +- (CMVideoDimensions)photoDimensions { return _photoDims; } +- (CMVideoDimensions)rawPhotoDimensions { return (CMVideoDimensions){0, 0}; } +- (CMVideoDimensions)previewDimensions { return _photoDims; } +- (CMVideoDimensions)embeddedThumbnailDimensions { return (CMVideoDimensions){0, 0}; } +- (CMVideoDimensions)portraitEffectsMatteDimensions { return (CMVideoDimensions){0, 0}; } +- (CMVideoDimensions)rawEmbeddedThumbnailDimensions { return (CMVideoDimensions){0, 0}; } +- (int64_t)uniqueID { return 1; } +- (BOOL)isFlashEnabled { return NO; } +- (BOOL)isRedEyeReductionEnabled { return NO; } +- (BOOL)isContentAwareDistortionCorrectionEnabled { return NO; } +- (BOOL)isStillImageStabilizationEnabled { return NO; } +- (BOOL)isVirtualDeviceFusionEnabled { return NO; } +- (BOOL)isAutoVirtualDeviceFusionEnabled { return NO; } +- (BOOL)isDualCameraFusionEnabled { return NO; } +- (BOOL)isAutoDualCameraFusionEnabled { return NO; } +- (BOOL)isDepthDataDeliveryEnabled { return NO; } +- (BOOL)isPortraitEffectsMatteDeliveryEnabled { return NO; } +- (BOOL)isCameraCalibrationDataDeliveryEnabled { return NO; } +- (NSArray *)enabledSemanticSegmentationMatteTypes { return @[]; } +- (CMTimeRange)photoProcessingTimeRange { + return CMTimeRangeMake(kCMTimeZero, kCMTimeZero); +} +- (CMTime)expectedPhotoCaptureDuration { return kCMTimeInvalid; } +- (NSURL *)deferredPhotoProxyDataFileURL { return nil; } +- (NSDictionary *)dimensionsRepresentation { return @{}; } +@end + +#pragma mark - SimCamFakePhoto + +@implementation SimCamFakePhoto { + NSData *_jpegData; + CGImageRef _cgImage; + NSDictionary *_metadata; + AVCaptureResolvedPhotoSettings *_resolvedSettings; +} ++ (instancetype)allocWithZone:(NSZone *)zone { + return class_createInstance([SimCamFakePhoto class], 0); +} ++ (instancetype)photoFromImage:(CGImageRef)cgImage + jpegQuality:(CGFloat)q + mirrored:(BOOL)mirrored { + if (!cgImage) return nil; + SimCamFakePhoto *p = [SimCamFakePhoto alloc]; + if (p) { + p->_cgImage = CGImageRetain(cgImage); + UIImage *ui = [UIImage imageWithCGImage:cgImage]; + p->_jpegData = UIImageJPEGRepresentation(ui, q); + UInt32 exifOrient = mirrored ? 2u : 1u; + p->_metadata = @{ + (NSString *)kCGImagePropertyOrientation: @(exifOrient), + }; + CMVideoDimensions dims = { + (int32_t)CGImageGetWidth(cgImage), + (int32_t)CGImageGetHeight(cgImage), + }; + p->_resolvedSettings = (AVCaptureResolvedPhotoSettings *) + [SimCamFakeResolvedPhotoSettings settingsWithDimensions:dims]; + } + return p; +} +- (NSData *)fileDataRepresentation { return _jpegData; } +- (NSData *)fileDataRepresentationWithCustomizer:(id)c { return _jpegData; } +- (NSData *)fileDataRepresentationWithReplacementMetadata:(NSDictionary *)m + replacementEmbeddedThumbnailPhotoFormat:(NSDictionary *)t + replacementEmbeddedThumbnailPixelBuffer:(CVPixelBufferRef)pb + replacementDepthData:(id)d { return _jpegData; } +- (CGImageRef)CGImageRepresentation { return _cgImage; } +- (CGImageRef)previewCGImageRepresentation { return _cgImage; } +- (NSDictionary *)metadata { return _metadata; } +- (CVPixelBufferRef)pixelBuffer { return NULL; } +- (CVPixelBufferRef)previewPixelBuffer { return NULL; } +- (AVDepthData *)depthData { return nil; } +- (AVCameraCalibrationData *)cameraCalibrationData { return nil; } +- (NSData *)bracketSettings { return nil; } +- (AVCaptureBracketedStillImageSettings *)bracketedSettings { return nil; } +- (NSData *)embeddedThumbnailPhotoFormat { return nil; } +- (NSInteger)photoCount { return 1; } +- (NSInteger)sequenceCount { return 1; } +- (CMTime)timestamp { return CMTimeMake(0, 30); } +- (BOOL)isRawPhoto { return NO; } +- (AVCaptureResolvedPhotoSettings *)resolvedSettings { return _resolvedSettings; } +- (NSString *)sourceDeviceType { return AVCaptureDeviceTypeBuiltInWideAngleCamera; } +- (NSArray *)availableRawEmbeddedThumbnailPhotoCodecTypes { return @[]; } +- (NSArray *)availableEmbeddedThumbnailPhotoCodecTypes { return @[]; } +- (void)dealloc { if (_cgImage) CGImageRelease(_cgImage); } +@end + +#pragma mark - CoreMotion fakes + +@implementation SimCamAttitude +- (double)pitch { return 0.0; } +- (double)roll { return 0.0; } +- (double)yaw { return 0.0; } +- (CMRotationMatrix)rotationMatrix { + return (CMRotationMatrix){ 1, 0, 0, 0, 1, 0, 0, 0, 1 }; +} +- (CMQuaternion)quaternion { return (CMQuaternion){ 0, 0, 0, 1 }; } +- (void)multiplyByInverseOfAttitude:(CMAttitude *)attitude { (void)attitude; } +@end + +@implementation SimCamAccelerometerData +- (CMAcceleration)acceleration { return (CMAcceleration){ 0.0, -1.0, 0.0 }; } +- (NSTimeInterval)timestamp { return [NSProcessInfo processInfo].systemUptime; } +@end + +@implementation SimCamGyroData +- (CMRotationRate)rotationRate { return (CMRotationRate){ 0.0, 0.0, 0.0 }; } +- (NSTimeInterval)timestamp { return [NSProcessInfo processInfo].systemUptime; } +@end + +@implementation SimCamMagnetometerData +- (CMMagneticField)magneticField { return (CMMagneticField){ 0.0, 0.0, 0.0 }; } +- (NSTimeInterval)timestamp { return [NSProcessInfo processInfo].systemUptime; } +@end + +@implementation SimCamDeviceMotion +- (CMAttitude *)attitude { return SimCamSharedAttitude(); } +- (CMAcceleration)gravity { return (CMAcceleration){ 0.0, -1.0, 0.0 }; } +- (CMAcceleration)userAcceleration { return (CMAcceleration){ 0.0, 0.0, 0.0 }; } +- (CMRotationRate)rotationRate { return (CMRotationRate){ 0.0, 0.0, 0.0 }; } +- (CMCalibratedMagneticField)magneticField { + return (CMCalibratedMagneticField){ { 0.0, 0.0, 0.0 }, + CMMagneticFieldCalibrationAccuracyUncalibrated }; +} +- (double)heading { return 0.0; } +- (NSTimeInterval)timestamp { return [NSProcessInfo processInfo].systemUptime; } +@end + +CMAttitude *SimCamSharedAttitude(void) { + static CMAttitude *att = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + att = (CMAttitude *)class_createInstance([SimCamAttitude class], 0); + }); + return att; +} +CMAccelerometerData *SimCamSharedAccelerometerData(void) { + static CMAccelerometerData *d = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + d = (CMAccelerometerData *)class_createInstance([SimCamAccelerometerData class], 0); + }); + return d; +} +CMGyroData *SimCamSharedGyroData(void) { + static CMGyroData *d = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + d = (CMGyroData *)class_createInstance([SimCamGyroData class], 0); + }); + return d; +} +CMMagnetometerData *SimCamSharedMagnetometerData(void) { + static CMMagnetometerData *d = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + d = (CMMagnetometerData *)class_createInstance([SimCamMagnetometerData class], 0); + }); + return d; +} +CMDeviceMotion *SimCamSharedDeviceMotion(void) { + static CMDeviceMotion *d = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + d = (CMDeviceMotion *)class_createInstance([SimCamDeviceMotion class], 0); + }); + return d; +} diff --git a/app/macos/SimCameraInjector/SimCamFrameSource.h b/app/macos/SimCameraInjector/SimCamFrameSource.h new file mode 100644 index 0000000..f691c53 --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamFrameSource.h @@ -0,0 +1,26 @@ +#pragma once + +#import + +@interface SimCamRegistry : NSObject ++ (instancetype)shared; + +- (void)addOutput:(AVCaptureVideoDataOutput *)out + delegate:(id)delegate + queue:(dispatch_queue_t)queue; +- (void)removeOutput:(AVCaptureVideoDataOutput *)out; +- (void)addPreviewLayer:(AVCaptureVideoPreviewLayer *)layer; +- (void)reapplyMirrorToLayers; + +- (CVPixelBufferRef)currentPixelBuffer CF_RETURNS_RETAINED; + +- (NSData *)currentSnapshotJPEGAtQuality:(CGFloat)q; + +- (void)startPumpingIfNeeded; +- (void)stopPumping; +@end + +void SimCamFrameSourceLoadImage(void); +void SimCamFrameSourceOpenShmIfRequested(void); + +BOOL SimCamFrameSourceIsShmAttached(void); diff --git a/app/macos/SimCameraInjector/SimCamFrameSource.m b/app/macos/SimCameraInjector/SimCamFrameSource.m new file mode 100644 index 0000000..b7d8a89 --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamFrameSource.m @@ -0,0 +1,577 @@ +#import "SimCamFrameSource.h" +#import "SimCamFakes.h" +#import "SimCamLog.h" +#include "include/SimCamShared.h" + +#import +#import +#import +#import +#import +#import +#import +#import +#include +#include +#include +#include +#include +#include + +#pragma mark - Source globals + +static UIImage *gSourceImage = nil; +static CGImageRef gSourceCGImage = NULL; +static size_t kFrameWidth = 1280; +static size_t kFrameHeight = 720; +static const double kFrameRate = 30.0; + +static SimCamShmHeader *gShmHeader = NULL; +static SimCamSurfaceTable *gSurfaceTable = NULL; +static IOSurfaceRef gSurfaces[SIMCAM_SURFACE_RING]; // resolved from global IDs +static uint64_t gLastSeenSeq = 0; + +#pragma mark - Last-frame cache + +static CVPixelBufferRef gLastFramePB = NULL; +static CGImageRef gLastFrameCGImage = NULL; +static NSLock *gFrameCacheLock = nil; +static dispatch_once_t gFrameCacheOnce; + +static inline NSLock *SimCamFrameCacheLock(void) { + dispatch_once(&gFrameCacheOnce, ^{ gFrameCacheLock = [NSLock new]; }); + return gFrameCacheLock; +} + +static void SimCamCacheFrame(CVPixelBufferRef pb) { + if (!pb) return; + CIImage *ci = [CIImage imageWithCVPixelBuffer:pb]; + static CIContext *ctx = nil; static dispatch_once_t ctxOnce; + dispatch_once(&ctxOnce, ^{ ctx = [CIContext contextWithOptions:nil]; }); + CGImageRef cg = [ctx createCGImage:ci fromRect:ci.extent]; + NSLock *lock = SimCamFrameCacheLock(); + [lock lock]; + CVPixelBufferRef oldPB = gLastFramePB; + CGImageRef oldCG = gLastFrameCGImage; + gLastFramePB = (CVPixelBufferRef)CFRetain(pb); + gLastFrameCGImage = cg; + [lock unlock]; + if (oldPB) CVPixelBufferRelease(oldPB); + if (oldCG) CGImageRelease(oldCG); +} + +static CVPixelBufferRef SimCamAcquireCachedPB(void) CF_RETURNS_RETAINED { + NSLock *lock = SimCamFrameCacheLock(); + [lock lock]; + CVPixelBufferRef pb = gLastFramePB; + if (pb) CFRetain(pb); + [lock unlock]; + return pb; +} +static CGImageRef SimCamAcquireCachedCGImage(void) CF_RETURNS_RETAINED { + NSLock *lock = SimCamFrameCacheLock(); + [lock lock]; + CGImageRef cg = gLastFrameCGImage; + if (cg) CGImageRetain(cg); + [lock unlock]; + return cg; +} + +#pragma mark - Output delegate registry + +@implementation SimCamRegistry { + NSMutableArray *_entries; + NSHashTable *_layers; + dispatch_source_t _timer; + dispatch_queue_t _timerQueue; + NSLock *_lock; +} + ++ (instancetype)shared { + static SimCamRegistry *s; static dispatch_once_t o; + dispatch_once(&o, ^{ s = [SimCamRegistry new]; }); + return s; +} + +- (instancetype)init { + if ((self = [super init])) { + _entries = [NSMutableArray new]; + _layers = [NSHashTable weakObjectsHashTable]; + _timerQueue = dispatch_queue_create("dev.servesim.simcam.pump", DISPATCH_QUEUE_SERIAL); + _lock = [NSLock new]; + } + return self; +} + +- (void)addOutput:(AVCaptureVideoDataOutput *)out + delegate:(id)delegate + queue:(dispatch_queue_t)queue { + if (!out || !delegate) return; + SimCamWeakRef *ref = [SimCamWeakRef new]; + ref.target = delegate; + [_lock lock]; + NSMutableIndexSet *toRemove = [NSMutableIndexSet new]; + [_entries enumerateObjectsUsingBlock:^(NSDictionary *e, NSUInteger i, BOOL *stop) { + if (e[@"out"] == out) [toRemove addIndex:i]; + }]; + [_entries removeObjectsAtIndexes:toRemove]; + [_entries addObject:@{ + @"out": out, + @"del": ref, + @"queue": queue ?: dispatch_get_main_queue(), + }]; + NSUInteger entryCount = _entries.count; + [_lock unlock]; + simcam_log(@"addOutput delegate=%p out=%p queue=%p pos=%d (entries=%lu, replaced=%lu)", + delegate, out, queue, (int)SimCamPositionOf(out), + (unsigned long)entryCount, (unsigned long)toRemove.count); + + CVPixelBufferRef cached = SimCamAcquireCachedPB(); + if (cached) { + CMVideoFormatDescriptionRef fd = NULL; + CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, cached, &fd); + CMSampleBufferRef sb = NULL; + CMSampleTimingInfo timing = { + .duration = CMTimeMake(1, (int32_t)kFrameRate), + .presentationTimeStamp = CMTimeMake(0, (int32_t)kFrameRate), + .decodeTimeStamp = kCMTimeInvalid, + }; + if (fd) { + CMSampleBufferCreateForImageBuffer(kCFAllocatorDefault, cached, true, + NULL, NULL, fd, &timing, &sb); + CFRelease(fd); + } + if (sb) { + AVCaptureVideoDataOutput *outRef = out; + dispatch_queue_t q = queue ?: dispatch_get_main_queue(); + __weak SimCamWeakRef *weakRef = ref; + dispatch_async(q, ^{ + id del = weakRef.target; + if (del && [del respondsToSelector:@selector(captureOutput:didOutputSampleBuffer:fromConnection:)]) { + AVCaptureConnection *conn = SimCamFakeConnectionForOutput(outRef); + [del captureOutput:outRef didOutputSampleBuffer:sb fromConnection:conn]; + } + CFRelease(sb); + }); + } + CVPixelBufferRelease(cached); + } + + [self startPumpingIfNeeded]; +} + +- (void)removeOutput:(AVCaptureVideoDataOutput *)out { + [_lock lock]; + NSMutableIndexSet *toRemove = [NSMutableIndexSet new]; + [_entries enumerateObjectsUsingBlock:^(NSDictionary *e, NSUInteger i, BOOL *stop) { + if (e[@"out"] == out) [toRemove addIndex:i]; + }]; + [_entries removeObjectsAtIndexes:toRemove]; + [_lock unlock]; +} + +- (void)addPreviewLayer:(AVCaptureVideoPreviewLayer *)layer { + if (!layer) return; + [_lock lock]; + [_layers addObject:layer]; + [_lock unlock]; + BOOL mirror = SimCamShouldMirror(SimCamPositionOf(layer)); + CGImageRef primed = SimCamAcquireCachedCGImage(); + if (!primed && gSourceCGImage && !gShmHeader) { + primed = CGImageRetain(gSourceCGImage); + } + dispatch_async(dispatch_get_main_queue(), ^{ + layer.contentsGravity = kCAGravityResizeAspectFill; + if (mirror) layer.transform = CATransform3DMakeScale(-1.f, 1.f, 1.f); + if (primed) { + layer.contents = (__bridge id)primed; + CGImageRelease(primed); + } + }); + simcam_log(@"addPreviewLayer %p (mirror=%d, primed=%s, shm=%s)", + layer, (int)mirror, primed ? "yes" : "no", gShmHeader ? "yes" : "no"); + [self startPumpingIfNeeded]; +} + +- (void)reapplyMirrorToLayers { + NSArray *layerSnapshot; + [_lock lock]; layerSnapshot = _layers.allObjects; [_lock unlock]; + if (layerSnapshot.count == 0) return; + dispatch_async(dispatch_get_main_queue(), ^{ + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + for (AVCaptureVideoPreviewLayer *l in layerSnapshot) { + BOOL m = SimCamShouldMirror(SimCamPositionOf(l)); + l.transform = m ? CATransform3DMakeScale(-1.f, 1.f, 1.f) + : CATransform3DIdentity; + } + [CATransaction commit]; + }); +} + +- (void)pushFrameToLayers:(CVPixelBufferRef)pb { + if (!pb) return; + NSArray *layerSnapshot; + [_lock lock]; layerSnapshot = _layers.allObjects; [_lock unlock]; + if (layerSnapshot.count == 0) return; + + CGImageRef cg = NULL; + NSLock *lock = SimCamFrameCacheLock(); + [lock lock]; + if (pb == gLastFramePB && gLastFrameCGImage) { + cg = CGImageRetain(gLastFrameCGImage); + } + [lock unlock]; + if (!cg) { + CIImage *ci = [CIImage imageWithCVPixelBuffer:pb]; + static CIContext *ciCtx = nil; static dispatch_once_t once; + dispatch_once(&once, ^{ ciCtx = [CIContext contextWithOptions:nil]; }); + cg = [ciCtx createCGImage:ci fromRect:ci.extent]; + if (!cg) return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + for (AVCaptureVideoPreviewLayer *l in layerSnapshot) { + l.contents = (__bridge id)cg; + } + CGImageRelease(cg); + }); +} + +- (CVPixelBufferRef)newPixelBufferFromSurface CF_RETURNS_RETAINED { + return [self newPixelBufferFromSurfaceForceFresh:NO]; +} + +// Wrap the latest shared IOSurface as a CVPixelBuffer — zero copy. Holding the +// pixel buffer keeps the surface in use, so the host writer renders into a +// different ring slot until we release it. +- (CVPixelBufferRef)newPixelBufferFromSurfaceForceFresh:(BOOL)force CF_RETURNS_RETAINED { + if (!gShmHeader || !gSurfaceTable) return NULL; + if (gShmHeader->magic != SIMCAM_SHM_MAGIC) return NULL; + uint64_t seqA = atomic_load_explicit(&gShmHeader->frameSeq, memory_order_acquire); + if (seqA == 0) return NULL; + if (!force && seqA == gLastSeenSeq) return NULL; + + uint32_t count = gSurfaceTable->surfaceCount; + if (count == 0 || count > SIMCAM_SURFACE_RING) return NULL; + uint32_t idx = gSurfaceTable->latestIndex; + if (idx >= count) return NULL; + IOSurfaceRef surface = gSurfaces[idx]; + if (!surface) { + simcam_log(@"missing IOSurface at latest index %u/%u", idx, count); + return NULL; + } + + CVPixelBufferRef pb = NULL; + NSDictionary *attrs = @{ (id)kCVPixelBufferIOSurfacePropertiesKey: @{} }; + CVReturn r = CVPixelBufferCreateWithIOSurface(kCFAllocatorDefault, surface, + (__bridge CFDictionaryRef)attrs, &pb); + if (r != kCVReturnSuccess || !pb) return NULL; + + uint64_t seqB = atomic_load_explicit(&gShmHeader->frameSeq, memory_order_acquire); + if (!force && seqA != seqB) { + CVPixelBufferRelease(pb); + return NULL; + } + gLastSeenSeq = seqA; + return pb; +} + +- (CVPixelBufferRef)currentPixelBuffer CF_RETURNS_RETAINED { + CVPixelBufferRef pb = [self newPixelBufferFromSurfaceForceFresh:YES]; + if (!pb) pb = [self newPixelBufferFromImage]; + return pb; +} + +- (NSData *)currentSnapshotJPEGAtQuality:(CGFloat)q { + CVPixelBufferRef pb = [self currentPixelBuffer]; + if (!pb) return nil; + CIImage *ci = [CIImage imageWithCVPixelBuffer:pb]; + if (SimCamShouldMirror(AVCaptureDevicePositionFront)) { + ci = [ci imageByApplyingOrientation:kCGImagePropertyOrientationUpMirrored]; + } + static CIContext *ctx = nil; static dispatch_once_t once; + dispatch_once(&once, ^{ ctx = [CIContext contextWithOptions:nil]; }); + CGImageRef cg = [ctx createCGImage:ci fromRect:ci.extent]; + CVPixelBufferRelease(pb); + if (!cg) return nil; + UIImage *ui = [UIImage imageWithCGImage:cg]; + NSData *data = UIImageJPEGRepresentation(ui, q); + CGImageRelease(cg); + return data; +} + +- (CVPixelBufferRef)newPixelBufferFromImage CF_RETURNS_RETAINED { + if (!gSourceCGImage) return NULL; + CVPixelBufferRef pb = NULL; + NSDictionary *attrs = @{ (id)kCVPixelBufferIOSurfacePropertiesKey: @{} }; + CVReturn r = CVPixelBufferCreate(kCFAllocatorDefault, kFrameWidth, kFrameHeight, + kCVPixelFormatType_32BGRA, (__bridge CFDictionaryRef)attrs, &pb); + if (r != kCVReturnSuccess || !pb) return NULL; + CVPixelBufferLockBaseAddress(pb, 0); + void *base = CVPixelBufferGetBaseAddress(pb); + size_t bpr = CVPixelBufferGetBytesPerRow(pb); + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(base, kFrameWidth, kFrameHeight, 8, bpr, cs, + kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little); + CGContextSetFillColorWithColor(ctx, [UIColor blackColor].CGColor); + CGContextFillRect(ctx, CGRectMake(0, 0, kFrameWidth, kFrameHeight)); + size_t iw = CGImageGetWidth(gSourceCGImage), ih = CGImageGetHeight(gSourceCGImage); + double sx = (double)kFrameWidth / iw, sy = (double)kFrameHeight / ih; + double s = MAX(sx, sy); + double dw = iw * s, dh = ih * s; + CGRect dst = CGRectMake((kFrameWidth - dw)/2.0, (kFrameHeight - dh)/2.0, dw, dh); + CGContextDrawImage(ctx, dst, gSourceCGImage); + CGContextRelease(ctx); + CGColorSpaceRelease(cs); + CVPixelBufferUnlockBaseAddress(pb, 0); + return pb; +} + +- (CVPixelBufferRef)newPixelBufferNoSignal CF_RETURNS_RETAINED { + CVPixelBufferRef pb = NULL; + NSDictionary *attrs = @{ (id)kCVPixelBufferIOSurfacePropertiesKey: @{} }; + CVReturn r = CVPixelBufferCreate(kCFAllocatorDefault, kFrameWidth, kFrameHeight, + kCVPixelFormatType_32BGRA, (__bridge CFDictionaryRef)attrs, &pb); + if (r != kCVReturnSuccess || !pb) return NULL; + CVPixelBufferLockBaseAddress(pb, 0); + uint8_t *base = (uint8_t *)CVPixelBufferGetBaseAddress(pb); + size_t bpr = CVPixelBufferGetBytesPerRow(pb); + for (size_t y = 0; y < kFrameHeight; y++) { + uint8_t *row = base + y * bpr; + for (size_t x = 0; x < kFrameWidth; x++) { + row[x * 4 + 0] = 0x18; + row[x * 4 + 1] = 0x18; + row[x * 4 + 2] = 0x18; + row[x * 4 + 3] = 0xFF; + } + } + CVPixelBufferUnlockBaseAddress(pb, 0); + return pb; +} + +- (CMSampleBufferRef)newSampleBufferAtTime:(CMTime)pts CF_RETURNS_RETAINED { + CVPixelBufferRef pb = [self newPixelBufferFromSurface]; + if (!pb) pb = [self newPixelBufferFromImage]; + if (pb) { + SimCamCacheFrame(pb); + } else { + pb = SimCamAcquireCachedPB(); + } + if (!pb) { + static dispatch_once_t logOnce; + dispatch_once(&logOnce, ^{ + simcam_log(@"no-signal fallback: shm=%@ frameSeq=%llu cache=empty", + gShmHeader ? @"attached" : @"unattached", + (unsigned long long)(gShmHeader ? atomic_load_explicit(&gShmHeader->frameSeq, memory_order_acquire) : 0)); + }); + pb = [self newPixelBufferNoSignal]; + } + if (!pb) return NULL; + + CMVideoFormatDescriptionRef fd = NULL; + CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pb, &fd); + CMSampleTimingInfo timing = { + .duration = CMTimeMake(1, (int32_t)kFrameRate), + .presentationTimeStamp = pts, + .decodeTimeStamp = kCMTimeInvalid, + }; + CMSampleBufferRef sb = NULL; + CMSampleBufferCreateForImageBuffer(kCFAllocatorDefault, pb, true, NULL, NULL, fd, &timing, &sb); + if (fd) CFRelease(fd); + CVPixelBufferRelease(pb); + return sb; +} + +- (void)startPumpingIfNeeded { + [_lock lock]; + if (_timer) { [_lock unlock]; return; } + _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _timerQueue); + uint64_t intervalNs = (uint64_t)(NSEC_PER_SEC / kFrameRate); + dispatch_source_set_timer(_timer, dispatch_time(DISPATCH_TIME_NOW, 0), intervalNs, intervalNs / 10); + __weak __typeof(self) weakSelf = self; + __block int64_t frameIdx = 0; + __block uint8_t lastMirrorByte = SIMCAM_MIRROR_UNSET; + dispatch_source_set_event_handler(_timer, ^{ + __strong __typeof(weakSelf) self = weakSelf; if (!self) return; + if (gShmHeader) { + uint8_t m = gShmHeader->mirrorMode; + if (m != lastMirrorByte) { + lastMirrorByte = m; + if (m != SIMCAM_MIRROR_UNSET) { + SimCamMirrorMode prev = SimCamGetMirrorMode(); + SimCamMirrorMode next = prev; + if (m == SIMCAM_MIRROR_ON) next = SimCamMirrorForceOn; + else if (m == SIMCAM_MIRROR_OFF) next = SimCamMirrorForceOff; + else next = SimCamMirrorAuto; + if (prev != next) { + SimCamSetMirrorMode(next); + simcam_log(@"mirror mode → %d (from shm)", (int)next); + [self reapplyMirrorToLayers]; + } + } + } + } + CMTime pts = CMTimeMake(frameIdx++, (int32_t)kFrameRate); + CMSampleBufferRef sb = [self newSampleBufferAtTime:pts]; + if (!sb) return; + CVImageBufferRef pb = CMSampleBufferGetImageBuffer(sb); + if (gShmHeader || gLastFramePB) [self pushFrameToLayers:pb]; + NSArray *snapshot; + [self->_lock lock]; snapshot = [self->_entries copy]; [self->_lock unlock]; + BOOL anyDead = NO; + for (NSDictionary *e in snapshot) { + AVCaptureVideoDataOutput *out = e[@"out"]; + SimCamWeakRef *ref = e[@"del"]; + id del = ref.target; + dispatch_queue_t q = e[@"queue"]; + if (!del) { anyDead = YES; continue; } + if (!out) continue; + CFRetain(sb); + __weak SimCamWeakRef *weakRef = ref; + dispatch_async(q, ^{ + id d = weakRef.target; + if (d && [d respondsToSelector:@selector(captureOutput:didOutputSampleBuffer:fromConnection:)]) { + AVCaptureConnection *conn = SimCamFakeConnectionForOutput(out); + [d captureOutput:out didOutputSampleBuffer:sb fromConnection:conn]; + } + CFRelease(sb); + }); + } + if (anyDead) { + [self->_lock lock]; + NSMutableIndexSet *idx = [NSMutableIndexSet new]; + [self->_entries enumerateObjectsUsingBlock:^(NSDictionary *entry, NSUInteger i, BOOL *stop) { + SimCamWeakRef *r = entry[@"del"]; + if (!r.target) [idx addIndex:i]; + }]; + if (idx.count) { + NSUInteger before = self->_entries.count; + [self->_entries removeObjectsAtIndexes:idx]; + simcam_log(@"pump: pruned %lu dead delegate entr%@ (%lu→%lu)", + (unsigned long)idx.count, idx.count == 1 ? @"y" : @"ies", + (unsigned long)before, (unsigned long)self->_entries.count); + } + [self->_lock unlock]; + } + CFRelease(sb); + }); + dispatch_resume(_timer); + [_lock unlock]; + simcam_log(@"started frame pump @ %.0f fps", kFrameRate); +} + +- (void)stopPumping { + [_lock lock]; + if (_timer) { dispatch_source_cancel(_timer); _timer = NULL; } + [_lock unlock]; +} +@end + +#pragma mark - Source loaders + +BOOL SimCamFrameSourceIsShmAttached(void) { + return gShmHeader != NULL; +} + +void SimCamFrameSourceLoadImage(void) { + const char *envPath = getenv("SIMCAM_IMAGE_PATH"); + NSString *path = envPath ? [NSString stringWithUTF8String:envPath] : nil; + if (!path.length) { + simcam_log(@"SIMCAM_IMAGE_PATH not set — generating gradient placeholder"); + UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] + initWithSize:CGSizeMake(kFrameWidth, kFrameHeight)]; + gSourceImage = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + CGContextRef c = ctx.CGContext; + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGFloat colors[] = {0.10,0.45,0.95,1.0, 0.95,0.20,0.55,1.0}; + CGFloat locs[] = {0.0, 1.0}; + CGGradientRef g = CGGradientCreateWithColorComponents(cs, colors, locs, 2); + CGContextDrawLinearGradient(c, g, CGPointZero, + CGPointMake(kFrameWidth, kFrameHeight), 0); + CGGradientRelease(g); + CGColorSpaceRelease(cs); + NSDictionary *attrs = @{ + NSFontAttributeName: [UIFont boldSystemFontOfSize:96], + NSForegroundColorAttributeName: UIColor.whiteColor, + }; + [@"SimUtil camera" drawAtPoint:CGPointMake(60, 60) withAttributes:attrs]; + }]; + } else { + gSourceImage = [UIImage imageWithContentsOfFile:path]; + if (!gSourceImage) { + simcam_log(@"failed to load image at %@", path); + return; + } + simcam_log(@"loaded source image %@ (%.0fx%.0f)", path, + gSourceImage.size.width, gSourceImage.size.height); + } + if (gSourceImage.CGImage) { + gSourceCGImage = CGImageRetain(gSourceImage.CGImage); + } +} + +void SimCamFrameSourceOpenShmIfRequested(void) { + const char *shmName = getenv("SIMCAM_SHM_NAME"); + if (!shmName || !*shmName) return; + int fd = shm_open(shmName, O_RDONLY, 0); + if (fd < 0) { + simcam_log(@"shm_open(%s) failed: %s", shmName, strerror(errno)); + return; + } + size_t size = (size_t)SimCamControlSize(); + struct stat st; + if (fstat(fd, &st) < 0 || (size_t)st.st_size < size) { + simcam_log(@"shm fstat failed or too small"); + close(fd); + return; + } + void *map = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (map == MAP_FAILED) { + simcam_log(@"shm mmap failed: %s", strerror(errno)); + return; + } + SimCamShmHeader *hdr = (SimCamShmHeader *)map; + if (hdr->magic != SIMCAM_SHM_MAGIC) { + simcam_log(@"shm magic mismatch: 0x%x", hdr->magic); + munmap(map, size); + return; + } + SimCamSurfaceTable *table = + (SimCamSurfaceTable *)((uint8_t *)map + sizeof(SimCamShmHeader)); + uint32_t count = table->surfaceCount; + if (count == 0 || count > SIMCAM_SURFACE_RING) { + simcam_log(@"shm surface table invalid (count=%u)", count); + munmap(map, size); + return; + } + + // Resolve each global IOSurface ID to a local reference. + uint32_t resolved = 0; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + for (uint32_t i = 0; i < count; i++) { + IOSurfaceRef s = IOSurfaceLookup(table->ids[i]); + gSurfaces[i] = s; + if (s) resolved++; + } +#pragma clang diagnostic pop + if (resolved != count) { + for (uint32_t i = 0; i < count; i++) { + if (gSurfaces[i]) { + CFRelease(gSurfaces[i]); + gSurfaces[i] = NULL; + } + } + simcam_log(@"only %u/%u IOSurfaces resolved from shm \"%s\"", resolved, count, shmName); + munmap(map, size); + return; + } + + gShmHeader = hdr; + gSurfaceTable = table; + kFrameWidth = hdr->width; + kFrameHeight = hdr->height; + simcam_log(@"shm \"%s\" attached (%ux%u, %u/%u IOSurfaces resolved)", + shmName, hdr->width, hdr->height, resolved, count); +} diff --git a/app/macos/SimCameraInjector/SimCamLog.h b/app/macos/SimCameraInjector/SimCamLog.h new file mode 100644 index 0000000..db076b5 --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamLog.h @@ -0,0 +1,5 @@ +#pragma once + +#import + +void simcam_log(NSString *fmt, ...) NS_FORMAT_FUNCTION(1, 2); diff --git a/app/macos/SimCameraInjector/SimCamLog.m b/app/macos/SimCameraInjector/SimCamLog.m new file mode 100644 index 0000000..082473a --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamLog.m @@ -0,0 +1,9 @@ +#import "SimCamLog.h" + +void simcam_log(NSString *fmt, ...) { + va_list args; va_start(args, fmt); + NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args]; + va_end(args); + fprintf(stderr, "[SimCam] %s\n", msg.UTF8String); + NSLog(@"[SimCam] %@", msg); +} diff --git a/app/macos/SimCameraInjector/SimCamSwizzles.h b/app/macos/SimCameraInjector/SimCamSwizzles.h new file mode 100644 index 0000000..7f287ea --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamSwizzles.h @@ -0,0 +1,3 @@ +#pragma once + +void SimCamInstallSwizzles(void); diff --git a/app/macos/SimCameraInjector/SimCamSwizzles.m b/app/macos/SimCameraInjector/SimCamSwizzles.m new file mode 100644 index 0000000..bbf1f1b --- /dev/null +++ b/app/macos/SimCameraInjector/SimCamSwizzles.m @@ -0,0 +1,1485 @@ +#import "SimCamSwizzles.h" +#import "SimCamFakes.h" +#import "SimCamFrameSource.h" +#import "SimCamLog.h" + +#import +#import +#import +#import +#import +#import +#import +#import +#include +#include +#include +#include + +#pragma mark - Swizzling helpers + +static BOOL SwizzleClassMethod(Class cls, SEL orig, SEL swiz) { + Method o = class_getClassMethod(cls, orig); + Method s = class_getClassMethod(cls, swiz); + if (!o) { + simcam_log(@"swizzle FAILED: +[%@ %@] (orig method not found)", + NSStringFromClass(cls), NSStringFromSelector(orig)); + return NO; + } + if (!s) { + simcam_log(@"swizzle FAILED: +[%@ %@] (replacement %@ not found)", + NSStringFromClass(cls), NSStringFromSelector(orig), + NSStringFromSelector(swiz)); + return NO; + } + method_exchangeImplementations(o, s); + return YES; +} +static BOOL SwizzleInstanceMethod(Class cls, SEL orig, SEL swiz) { + Method o = class_getInstanceMethod(cls, orig); + Method s = class_getInstanceMethod(cls, swiz); + if (!o) { + simcam_log(@"swizzle FAILED: -[%@ %@] (orig method not found)", + NSStringFromClass(cls), NSStringFromSelector(orig)); + return NO; + } + if (!s) { + simcam_log(@"swizzle FAILED: -[%@ %@] (replacement %@ not found)", + NSStringFromClass(cls), NSStringFromSelector(orig), + NSStringFromSelector(swiz)); + return NO; + } + // `orig` may be inherited rather than implemented directly on `cls` (e.g. + // on iOS 26 UIImagePickerController no longer overrides viewDidAppear:). + // A plain method_exchangeImplementations would then mutate the *superclass* + // Method, clobbering that selector for every subclass — and our `swiz` + // selector only exists on `cls`, so unrelated controllers crash with + // "unrecognized selector simcam_…". Install the override directly on `cls` + // instead: add `orig` pointing at the swizzled IMP, and if that succeeds + // (no direct impl existed) repoint `swiz` at the inherited original so the + // [self simcam_…] call still reaches it. + IMP origIMP = method_getImplementation(o); + IMP swizIMP = method_getImplementation(s); + if (class_addMethod(cls, orig, swizIMP, method_getTypeEncoding(s))) { + class_replaceMethod(cls, swiz, origIMP, method_getTypeEncoding(o)); + } else { + method_exchangeImplementations(o, s); + } + return YES; +} + +#pragma mark - NSNotificationCenter swizzle (suppress AVF runtime error) + +@interface NSNotificationCenter (SimCam) +@end +@implementation NSNotificationCenter (SimCam) + +- (void)simcam_postNotificationName:(NSNotificationName)name + object:(id)object + userInfo:(NSDictionary *)userInfo { + if (SimCamShouldSwallowAVFRuntimeError(name, object)) { + SimCamLogSwallowedRuntimeError(@"postNotificationName:object:userInfo:", object, userInfo); + return; + } + [self simcam_postNotificationName:name object:object userInfo:userInfo]; +} + +- (void)simcam_postNotificationName:(NSNotificationName)name object:(id)object { + if (SimCamShouldSwallowAVFRuntimeError(name, object)) { + SimCamLogSwallowedRuntimeError(@"postNotificationName:object:", object, nil); + return; + } + [self simcam_postNotificationName:name object:object]; +} + +- (void)simcam_postNotification:(NSNotification *)note { + if (SimCamShouldSwallowAVFRuntimeError(note.name, note.object)) { + SimCamLogSwallowedRuntimeError(@"postNotification:", note.object, note.userInfo); + return; + } + [self simcam_postNotification:note]; +} + +@end + +#pragma mark - AVCaptureDevice swizzles + +@interface AVCaptureDevice (SimCam) +@end +@implementation AVCaptureDevice (SimCam) ++ (AVCaptureDevice *)simcam_defaultDeviceWithDeviceType:(AVCaptureDeviceType)t + mediaType:(AVMediaType)m + position:(AVCaptureDevicePosition)p { + if ([m isEqualToString:AVMediaTypeVideo] || m == nil) { + AVCaptureDevicePosition resolved = + (p == AVCaptureDevicePositionBack) ? AVCaptureDevicePositionBack + : AVCaptureDevicePositionFront; + simcam_log(@"defaultDeviceWithDeviceType: %@ position: %d → fake", + t, (int)resolved); + return SimCamFakeDeviceForPosition(resolved); + } + return [self simcam_defaultDeviceWithDeviceType:t mediaType:m position:p]; +} ++ (NSArray *)simcam_devicesWithMediaType:(AVMediaType)m { + if ([m isEqualToString:AVMediaTypeVideo]) { + return @[ + SimCamFakeDeviceForPosition(AVCaptureDevicePositionFront), + SimCamFakeDeviceForPosition(AVCaptureDevicePositionBack), + ]; + } + return [self simcam_devicesWithMediaType:m]; +} ++ (NSArray *)simcam_devices { + NSArray *real = [self simcam_devices]; + NSArray *fakes = @[ + SimCamFakeDeviceForPosition(AVCaptureDevicePositionFront), + SimCamFakeDeviceForPosition(AVCaptureDevicePositionBack), + ]; + return [fakes arrayByAddingObjectsFromArray:real ?: @[]]; +} +@end + +#pragma mark - AVCaptureDeviceDiscoverySession swizzles + +@interface AVCaptureDeviceDiscoverySession (SimCam) +@end +@implementation AVCaptureDeviceDiscoverySession (SimCam) ++ (AVCaptureDeviceDiscoverySession *)simcam_discoverySessionWithDeviceTypes:(NSArray *)types + mediaType:(AVMediaType)m + position:(AVCaptureDevicePosition)p { + AVCaptureDeviceDiscoverySession *real = + [self simcam_discoverySessionWithDeviceTypes:types mediaType:m position:p]; + if ([m isEqualToString:AVMediaTypeVideo] || m == nil) { + NSMutableArray *list = [NSMutableArray new]; + if (p == AVCaptureDevicePositionUnspecified || p == AVCaptureDevicePositionFront) + [list addObject:SimCamFakeDeviceForPosition(AVCaptureDevicePositionFront)]; + if (p == AVCaptureDevicePositionUnspecified || p == AVCaptureDevicePositionBack) + [list addObject:SimCamFakeDeviceForPosition(AVCaptureDevicePositionBack)]; + @try { + [real setValue:list forKey:@"devices"]; + } @catch (__unused id e) { + simcam_log(@"could not override discovery session devices"); + } + } + return real; +} +@end + +#pragma mark - AVCaptureDeviceInput swizzle + +@interface AVCaptureDeviceInput (SimCam) +@end +@implementation AVCaptureDeviceInput (SimCam) +- (instancetype)simcam_initWithDevice:(AVCaptureDevice *)device error:(NSError **)err { + if ([device isKindOfClass:[SimCamFakeDevice class]]) { + if (err) *err = nil; + struct objc_super sup = { self, [NSObject class] }; + id obj = ((id (*)(struct objc_super *, SEL))objc_msgSendSuper)(&sup, @selector(init)); + if (obj) { + SimCamMarkFakeInput(obj, device); + SimCamSetPosition(obj, device.position); + SimCamMarkCameraInUse(); + } + return obj; + } + return [self simcam_initWithDevice:device error:err]; +} +- (AVCaptureDevice *)simcam_device { + AVCaptureDevice *fake = SimCamFakeInputDevice(self); + if (fake) return fake; + return [self simcam_device]; +} +- (NSArray *)simcam_ports { + if (SimCamIsFakeInput(self)) return SimCamFakePortsForInput(self); + return [self simcam_ports]; +} +@end + +#pragma mark - AVCaptureSession swizzles + +static char kSimCamSessionRunningKey; +static char kSimCamSessionInputsKey; +static char kSimCamSessionOutputsKey; +static char kSimCamSessionConnectionsKey; +static char kSimCamOutputAttachedToFakeSessionKey; +static char kSimCamOutputSessionKey; +static char kSimCamMaxPhotoDimensionsKey; + +static NSMutableArray *SimCamSessionTrackedInputs(AVCaptureSession *s) { + NSMutableArray *arr = objc_getAssociatedObject(s, &kSimCamSessionInputsKey); + if (!arr) { + arr = [NSMutableArray new]; + objc_setAssociatedObject(s, &kSimCamSessionInputsKey, arr, OBJC_ASSOCIATION_RETAIN); + } + return arr; +} +static NSMutableArray *SimCamSessionTrackedOutputs(AVCaptureSession *s) { + NSMutableArray *arr = objc_getAssociatedObject(s, &kSimCamSessionOutputsKey); + if (!arr) { + arr = [NSMutableArray new]; + objc_setAssociatedObject(s, &kSimCamSessionOutputsKey, arr, OBJC_ASSOCIATION_RETAIN); + } + return arr; +} +static NSMutableArray *SimCamSessionTrackedConnections(AVCaptureSession *s) { + NSMutableArray *arr = objc_getAssociatedObject(s, &kSimCamSessionConnectionsKey); + if (!arr) { + arr = [NSMutableArray new]; + objc_setAssociatedObject(s, &kSimCamSessionConnectionsKey, arr, OBJC_ASSOCIATION_RETAIN); + } + return arr; +} + +static AVCaptureInput *SimCamFirstFakeInputForSession(AVCaptureSession *s) { + for (AVCaptureInput *candidate in SimCamSessionTrackedInputs(s)) { + if (SimCamIsFakeInput(candidate)) return candidate; + } + return nil; +} + +// Real AVFoundation only exposes output connections after an output has been +// attached to a session. Keep this per-output so newly created outputs still +// look disconnected during client-side session configuration checks. +static void SimCamUnmarkOutputAttachedToFakeSession(AVCaptureOutput *output) { + if (!output) return; + objc_setAssociatedObject(output, &kSimCamOutputAttachedToFakeSessionKey, nil, OBJC_ASSOCIATION_RETAIN); + objc_setAssociatedObject(output, &kSimCamOutputSessionKey, nil, OBJC_ASSOCIATION_RETAIN); + objc_setAssociatedObject(output, &kSimCamMaxPhotoDimensionsKey, nil, OBJC_ASSOCIATION_RETAIN); + SimCamSetOutputInput(output, nil); +} +static void SimCamMarkOutputAttachedToFakeSession(AVCaptureSession *s, AVCaptureOutput *output) { + if (!output) return; + AVCaptureInput *input = SimCamFirstFakeInputForSession(s); + if (!input) { + SimCamUnmarkOutputAttachedToFakeSession(output); + return; + } + objc_setAssociatedObject(output, &kSimCamOutputAttachedToFakeSessionKey, @YES, OBJC_ASSOCIATION_RETAIN); + SimCamWeakRef *sessionRef = [SimCamWeakRef new]; + sessionRef.target = s; + objc_setAssociatedObject(output, &kSimCamOutputSessionKey, sessionRef, OBJC_ASSOCIATION_RETAIN); + SimCamSetOutputInput(output, input); +} +static BOOL SimCamOutputAttachedToFakeSession(AVCaptureOutput *output) { + if (!output) return NO; + return [objc_getAssociatedObject(output, &kSimCamOutputAttachedToFakeSessionKey) boolValue]; +} +static void SimCamRefreshAttachedOutputInputsForSession(AVCaptureSession *s) { + for (AVCaptureOutput *output in SimCamSessionTrackedOutputs(s)) { + SimCamMarkOutputAttachedToFakeSession(s, output); + } +} +static AVCaptureSession *SimCamFakeSessionForOutput(AVCaptureOutput *output) { + SimCamWeakRef *ref = objc_getAssociatedObject(output, &kSimCamOutputSessionKey); + return ref.target; +} +static BOOL SimCamConnectionUsesInput(AVCaptureConnection *connection, AVCaptureInput *input) { + for (AVCaptureInputPort *port in connection.inputPorts) { + if (port.input == input) return YES; + } + return NO; +} +static BOOL SimCamConnectionUsesFakeInput(AVCaptureConnection *connection) { + for (AVCaptureInputPort *port in connection.inputPorts) { + if (SimCamIsFakeInput(port.input)) return YES; + } + return NO; +} +static BOOL SimCamConnectionBelongsToSession( + AVCaptureConnection *connection, + AVCaptureSession *session +) { + NSArray *ports = connection.inputPorts; + if (ports.count == 0) return NO; + NSArray *inputs = SimCamSessionTrackedInputs(session); + for (AVCaptureInputPort *port in ports) { + if (![inputs containsObject:port.input]) return NO; + } + AVCaptureOutput *output = connection.output; + return !output || [SimCamSessionTrackedOutputs(session) containsObject:output]; +} +static NSArray *SimCamConnectionsForOutput(AVCaptureOutput *output) { + AVCaptureSession *session = SimCamFakeSessionForOutput(output); + if (!session) return @[]; + NSMutableArray *result = [NSMutableArray new]; + for (AVCaptureConnection *connection in SimCamSessionTrackedConnections(session)) { + if (connection.output == output) [result addObject:connection]; + } + return [result copy]; +} +static void SimCamRecordConnection(AVCaptureSession *session, AVCaptureConnection *connection) { + if (!connection) return; + NSMutableArray *connections = SimCamSessionTrackedConnections(session); + if (![connections containsObject:connection]) [connections addObject:connection]; +} +static void SimCamRemoveConnectionsForOutput(AVCaptureSession *session, AVCaptureOutput *output) { + NSMutableArray *connections = SimCamSessionTrackedConnections(session); + for (AVCaptureConnection *connection in [connections copy]) { + if (connection.output == output) [connections removeObject:connection]; + } +} +static void SimCamRemoveConnectionsForInput(AVCaptureSession *session, AVCaptureInput *input) { + NSMutableArray *connections = SimCamSessionTrackedConnections(session); + for (AVCaptureConnection *connection in [connections copy]) { + if (SimCamConnectionUsesInput(connection, input)) [connections removeObject:connection]; + } +} +static void SimCamAddAutomaticConnections(AVCaptureSession *session) { + for (AVCaptureOutput *output in SimCamSessionTrackedOutputs(session)) { + if (!SimCamOutputAttachedToFakeSession(output)) continue; + if (SimCamConnectionsForOutput(output).count > 0) continue; + SimCamRecordConnection(session, SimCamFakeConnectionForOutput(output)); + } +} + +@interface AVCaptureSession (SimCam) +@end +@implementation AVCaptureSession (SimCam) +- (void)simcam_addInput:(AVCaptureInput *)input { + if (SimCamIsFakeInput(input)) { + AVCaptureDevicePosition p = SimCamPositionOf(input); + SimCamSetPosition(self, p); + SimCamMarkCameraInUse(); + SimCamMarkSessionUsingFakeCamera(self, YES); + NSMutableArray *tracked = SimCamSessionTrackedInputs(self); + if (![tracked containsObject:input]) [tracked addObject:input]; + SimCamRefreshAttachedOutputInputsForSession(self); + SimCamAddAutomaticConnections(self); + simcam_log(@"addInput: fake input (%@) — tracked (count=%lu), skipping native add", + p == AVCaptureDevicePositionBack ? @"back" : @"front", + (unsigned long)tracked.count); + return; + } + [self simcam_addInput:input]; +} +- (BOOL)simcam_canAddInput:(AVCaptureInput *)input { + if (SimCamIsFakeInput(input)) return YES; + return [self simcam_canAddInput:input]; +} +- (void)simcam_addInputWithNoConnections:(AVCaptureInput *)input { + if (SimCamIsFakeInput(input)) { + AVCaptureDevicePosition p = SimCamPositionOf(input); + SimCamSetPosition(self, p); + SimCamMarkCameraInUse(); + SimCamMarkSessionUsingFakeCamera(self, YES); + NSMutableArray *tracked = SimCamSessionTrackedInputs(self); + if (![tracked containsObject:input]) [tracked addObject:input]; + SimCamRefreshAttachedOutputInputsForSession(self); + simcam_log(@"addInputWithNoConnections: fake input (%@) — tracked (count=%lu), skipping native add", + p == AVCaptureDevicePositionBack ? @"back" : @"front", + (unsigned long)tracked.count); + return; + } + [self simcam_addInputWithNoConnections:input]; +} +- (void)simcam_removeInput:(AVCaptureInput *)input { + if (SimCamIsFakeInput(input)) { + NSMutableArray *tracked = SimCamSessionTrackedInputs(self); + SimCamRemoveConnectionsForInput(self, input); + [tracked removeObject:input]; + SimCamRefreshAttachedOutputInputsForSession(self); + if (tracked.count == 0) SimCamMarkSessionUsingFakeCamera(self, NO); + simcam_log(@"removeInput: fake input — untracked (count=%lu)", + (unsigned long)tracked.count); + return; + } + [self simcam_removeInput:input]; +} +- (void)simcam_addOutput:(AVCaptureOutput *)output { + SimCamSetPosition(output, SimCamPositionOf(self)); + SimCamMarkCameraInUse(); + NSMutableArray *tracked = SimCamSessionTrackedOutputs(self); + if (![tracked containsObject:output]) [tracked addObject:output]; + SimCamMarkOutputAttachedToFakeSession(self, output); + if (SimCamOutputAttachedToFakeSession(output)) { + SimCamRecordConnection(self, SimCamFakeConnectionForOutput(output)); + } + simcam_log(@"addOutput: %@ (intercepted, tracked count=%lu, pos=%d)", + NSStringFromClass([output class]), + (unsigned long)tracked.count, + (int)SimCamPositionOf(self)); +} +- (BOOL)simcam_canAddOutput:(AVCaptureOutput *)output { return YES; } +- (void)simcam_addOutputWithNoConnections:(AVCaptureOutput *)output { + SimCamSetPosition(output, SimCamPositionOf(self)); + SimCamMarkCameraInUse(); + NSMutableArray *tracked = SimCamSessionTrackedOutputs(self); + if (![tracked containsObject:output]) [tracked addObject:output]; + SimCamMarkOutputAttachedToFakeSession(self, output); + simcam_log(@"addOutputWithNoConnections: %@ (intercepted, tracked count=%lu, pos=%d)", + NSStringFromClass([output class]), + (unsigned long)tracked.count, + (int)SimCamPositionOf(self)); +} +- (void)simcam_removeOutput:(AVCaptureOutput *)output { + SimCamRemoveConnectionsForOutput(self, output); + SimCamUnmarkOutputAttachedToFakeSession(output); + NSMutableArray *tracked = SimCamSessionTrackedOutputs(self); + [tracked removeObject:output]; + simcam_log(@"removeOutput: %@ — untracked (count=%lu)", + NSStringFromClass([output class]), (unsigned long)tracked.count); +} +- (void)simcam_beginConfiguration { + simcam_log(@"beginConfiguration intercepted (session=%p)", self); +} +- (void)simcam_commitConfiguration { + NSUInteger inCount = + ((NSArray *)objc_getAssociatedObject(self, &kSimCamSessionInputsKey)).count; + NSUInteger outCount = + ((NSArray *)objc_getAssociatedObject(self, &kSimCamSessionOutputsKey)).count; + simcam_log(@"commitConfiguration intercepted (session=%p, fakeInputs=%lu, fakeOutputs=%lu)", + self, (unsigned long)inCount, (unsigned long)outCount); +} +- (BOOL)simcam_canAddConnection:(AVCaptureConnection *)connection { + if (SimCamConnectionUsesFakeInput(connection)) { + return SimCamConnectionBelongsToSession(connection, self) && + ![SimCamSessionTrackedConnections(self) containsObject:connection]; + } + return [self simcam_canAddConnection:connection]; +} +- (void)simcam_addConnection:(AVCaptureConnection *)c { + if (!SimCamConnectionUsesFakeInput(c)) { + [self simcam_addConnection:c]; + return; + } + if (!SimCamConnectionBelongsToSession(c, self)) { + simcam_log(@"addConnection: rejected unowned fake connection (session=%p, conn=%p)", self, c); + return; + } + AVCaptureOutput *output = c.output; + SimCamRecordConnection(self, c); + simcam_log(@"addConnection: fake input (session=%p, conn=%p, output=%p)", self, c, output); +} +- (void)simcam_removeConnection:(AVCaptureConnection *)c { + if (!SimCamConnectionUsesFakeInput(c)) { + [self simcam_removeConnection:c]; + return; + } + [SimCamSessionTrackedConnections(self) removeObject:c]; + simcam_log(@"removeConnection: fake input (session=%p, conn=%p, output=%p)", self, c, c.output); +} +- (NSArray *)simcam_inputs { + NSMutableArray *tracked = objc_getAssociatedObject(self, &kSimCamSessionInputsKey); + NSArray *native = [self simcam_inputs]; + if (tracked.count == 0) return native ?: @[]; + if (native.count == 0) return [tracked copy]; + NSMutableArray *merged = [tracked mutableCopy]; + for (AVCaptureInput *n in native) { + if (![merged containsObject:n]) [merged addObject:n]; + } + return [merged copy]; +} +- (NSArray *)simcam_outputs { + NSMutableArray *tracked = objc_getAssociatedObject(self, &kSimCamSessionOutputsKey); + NSArray *native = [self simcam_outputs]; + if (tracked.count == 0) return native ?: @[]; + if (native.count == 0) return [tracked copy]; + NSMutableArray *merged = [tracked mutableCopy]; + for (AVCaptureOutput *n in native) { + if (![merged containsObject:n]) [merged addObject:n]; + } + return [merged copy]; +} +- (NSArray *)simcam_connections { + NSMutableArray *tracked = objc_getAssociatedObject(self, &kSimCamSessionConnectionsKey); + NSArray *native = [self simcam_connections]; + if (tracked.count == 0) return native ?: @[]; + NSMutableArray *merged = [tracked mutableCopy]; + for (AVCaptureConnection *n in native) { + if (![merged containsObject:n]) [merged addObject:n]; + } + return [merged copy]; +} +- (void)simcam_startRunning { + objc_setAssociatedObject(self, &kSimCamSessionRunningKey, @YES, OBJC_ASSOCIATION_RETAIN); + SimCamMarkCameraInUse(); + NSUInteger inCount = + ((NSArray *)objc_getAssociatedObject(self, &kSimCamSessionInputsKey)).count; + NSUInteger outCount = + ((NSArray *)objc_getAssociatedObject(self, &kSimCamSessionOutputsKey)).count; + simcam_log(@"startRunning intercepted (fake inputs=%lu outputs=%lu)", + (unsigned long)inCount, (unsigned long)outCount); + [[SimCamRegistry shared] startPumpingIfNeeded]; + [self willChangeValueForKey:@"running"]; + [self didChangeValueForKey:@"running"]; + AVCaptureSession *strong = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] + postNotificationName:AVCaptureSessionDidStartRunningNotification + object:strong]; + }); +} +- (void)simcam_stopRunning { + objc_setAssociatedObject(self, &kSimCamSessionRunningKey, @NO, OBJC_ASSOCIATION_RETAIN); + simcam_log(@"stopRunning intercepted"); + [self willChangeValueForKey:@"running"]; + [self didChangeValueForKey:@"running"]; + AVCaptureSession *strong = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] + postNotificationName:AVCaptureSessionDidStopRunningNotification + object:strong]; + }); +} +- (BOOL)simcam_isRunning { + NSNumber *v = objc_getAssociatedObject(self, &kSimCamSessionRunningKey); + return v.boolValue; +} +@end + +#pragma mark - AVCaptureVideoDataOutput swizzle + +@interface AVCaptureVideoDataOutput (SimCam) +@end +@implementation AVCaptureVideoDataOutput (SimCam) +- (void)simcam_setSampleBufferDelegate:(id)delegate + queue:(dispatch_queue_t)queue { + [self simcam_setSampleBufferDelegate:delegate queue:queue]; + SimCamMarkCameraInUse(); + if (delegate) { + [[SimCamRegistry shared] addOutput:self delegate:delegate queue:queue]; + } else { + [[SimCamRegistry shared] removeOutput:self]; + simcam_log(@"setSampleBufferDelegate nil — removed output %p", self); + } +} +@end + +#pragma mark - AVCaptureVideoPreviewLayer swizzle + +@interface AVCaptureVideoPreviewLayer (SimCam) +@end +@implementation AVCaptureVideoPreviewLayer (SimCam) +- (void)simcam_setSession:(AVCaptureSession *)session { + [self simcam_setSession:session]; + AVCaptureDevicePosition p = SimCamPositionOf(session); + SimCamSetPosition(self, p); + SimCamMarkCameraInUse(); + [[SimCamRegistry shared] addPreviewLayer:self]; +} +@end + +#pragma mark - AVCaptureDeviceFormat private-accessor swizzle + +@interface AVCaptureDeviceFormat (SimCamPrivate) +- (id)figCaptureSourceVideoFormat; +@end + +@interface AVCaptureDeviceFormat (SimCam) +@end +@implementation AVCaptureDeviceFormat (SimCam) +- (id)simcam_figCaptureSourceVideoFormat { + if ([self isKindOfClass:[SimCamFakeFormat class]]) return nil; + return [self simcam_figCaptureSourceVideoFormat]; +} +@end + +#pragma mark - AVCaptureOutput connection swizzles + +@interface AVCaptureOutput (SimCamConn) +@end +@implementation AVCaptureOutput (SimCamConn) +- (AVCaptureConnection *)simcam_connectionWithMediaType:(AVMediaType)mediaType { + AVCaptureConnection *real = [self simcam_connectionWithMediaType:mediaType]; + if (real) return real; + if (!SimCamOutputAttachedToFakeSession(self)) return nil; + if (![mediaType isEqualToString:AVMediaTypeVideo]) return nil; + AVCaptureConnection *connection = SimCamConnectionsForOutput(self).firstObject; + simcam_log(@"connectionWithMediaType:%@ → tracked %p for %@ %p", + mediaType, connection, NSStringFromClass([self class]), self); + return connection; +} +- (NSArray *)simcam_connections { + NSArray *real = [self simcam_connections]; + if (!SimCamOutputAttachedToFakeSession(self)) return real ?: @[]; + NSArray *tracked = SimCamConnectionsForOutput(self); + if (tracked.count == 0) return real ?: @[]; + if (real.count == 0) return tracked; + NSMutableArray *merged = [tracked mutableCopy]; + for (AVCaptureConnection *connection in real) { + if (![merged containsObject:connection]) [merged addObject:connection]; + } + return [merged copy]; +} +@end + +#pragma mark - AVCaptureOutput codec enumeration swizzle + +@interface AVCaptureOutput (SimCamPrivate) ++ (NSArray *)availableVideoCodecTypesForSourceDevice:(AVCaptureDevice *)device + sourceFormat:(AVCaptureDeviceFormat *)format + outputDimensions:(CMVideoDimensions)dims + fileType:(AVFileType)fileType + videoCodecTypesAllowList:(NSArray *)allow; +@end + +@interface AVCaptureOutput (SimCam) +@end +@implementation AVCaptureOutput (SimCam) ++ (NSArray *)simcam_availableVideoCodecTypesForSourceDevice:(AVCaptureDevice *)device + sourceFormat:(AVCaptureDeviceFormat *)format + outputDimensions:(CMVideoDimensions)dims + fileType:(AVFileType)fileType + videoCodecTypesAllowList:(NSArray *)allow { + BOOL fakeDevice = [device isKindOfClass:[SimCamFakeDevice class]]; + BOOL fakeFormat = [format isKindOfClass:[SimCamFakeFormat class]]; + BOOL nilArgs = (device == nil) && (format == nil); + if (fakeDevice || fakeFormat || nilArgs) { + simcam_log(@"availableVideoCodecTypes intercepted (device=%@ format=%@ allow=%lu)", + device ? NSStringFromClass([device class]) : @"", + format ? NSStringFromClass([format class]) : @"", + (unsigned long)allow.count); + NSArray *defaults = @[ AVVideoCodecTypeJPEG, AVVideoCodecTypeHEVC ]; + if (allow.count == 0) return defaults; + NSMutableArray *filtered = [NSMutableArray new]; + for (AVVideoCodecType t in defaults) if ([allow containsObject:t]) [filtered addObject:t]; + return filtered.count > 0 ? [filtered copy] : defaults; + } + return [self simcam_availableVideoCodecTypesForSourceDevice:device + sourceFormat:format + outputDimensions:dims + fileType:fileType + videoCodecTypesAllowList:allow]; +} +@end + +#pragma mark - AVCapturePhotoOutput swizzle + +@interface AVCapturePhotoOutput (SimCam) +@end +@implementation AVCapturePhotoOutput (SimCam) +- (CMVideoDimensions)simcam_maxPhotoDimensions { + if (SimCamOutputAttachedToFakeSession(self)) { + NSValue *value = objc_getAssociatedObject(self, &kSimCamMaxPhotoDimensionsKey); + if (value) { + CMVideoDimensions dimensions = { 0, 0 }; + [value getValue:&dimensions size:sizeof(dimensions)]; + return dimensions; + } + } + return [self simcam_maxPhotoDimensions]; +} +- (void)simcam_setMaxPhotoDimensions:(CMVideoDimensions)dimensions { + if (!SimCamOutputAttachedToFakeSession(self)) { + [self simcam_setMaxPhotoDimensions:dimensions]; + return; + } + // AVFoundation validates this against private AVCaptureDeviceFormat state. + // Synthetic formats only have public state, so retain the value per output. + NSValue *value = [NSValue valueWithBytes:&dimensions objCType:@encode(CMVideoDimensions)]; + objc_setAssociatedObject(self, &kSimCamMaxPhotoDimensionsKey, value, OBJC_ASSOCIATION_RETAIN); +} +- (void)simcam_capturePhotoWithSettings:(AVCapturePhotoSettings *)settings + delegate:(id)delegate { + if (!SimCamOutputAttachedToFakeSession(self)) { + [self simcam_capturePhotoWithSettings:settings delegate:delegate]; + return; + } + if (!delegate) return; + SimCamRegistry *reg = [SimCamRegistry shared]; + CVPixelBufferRef pb = [reg currentPixelBuffer]; + AVCaptureDevicePosition p = SimCamPositionOf(self); + if (p == 0) p = AVCaptureDevicePositionFront; + BOOL mirror = SimCamShouldMirror(p); + if (SimCamGetMirrorMode() == SimCamMirrorAuto) { + AVCaptureConnection *conn = [self connectionWithMediaType:AVMediaTypeVideo]; + if (conn && conn.isVideoMirroringSupported && !conn.automaticallyAdjustsVideoMirroring) { + mirror = conn.isVideoMirrored; + } + } + CGImageRef cg = NULL; + if (pb) { + CIImage *ci = [CIImage imageWithCVPixelBuffer:pb]; + if (mirror) ci = [ci imageByApplyingOrientation:kCGImagePropertyOrientationUpMirrored]; + static CIContext *ctx = nil; static dispatch_once_t once; + dispatch_once(&once, ^{ ctx = [CIContext contextWithOptions:nil]; }); + cg = [ctx createCGImage:ci fromRect:ci.extent]; + CVPixelBufferRelease(pb); + } + if (!cg) { + simcam_log(@"capturePhoto: no source frame, synthesizing 1x1 black"); + size_t bpr = 4; + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef bmp = CGBitmapContextCreate(NULL, 1, 1, 8, bpr, cs, + kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little); + CGContextSetFillColorWithColor(bmp, [UIColor blackColor].CGColor); + CGContextFillRect(bmp, CGRectMake(0, 0, 1, 1)); + cg = CGBitmapContextCreateImage(bmp); + CGContextRelease(bmp); + CGColorSpaceRelease(cs); + } + SimCamFakePhoto *photo = [SimCamFakePhoto photoFromImage:cg + jpegQuality:0.92 + mirrored:mirror]; + if (cg) CGImageRelease(cg); + AVCaptureResolvedPhotoSettings *resolved = photo.resolvedSettings; + simcam_log(@"capturePhoto intercepted (pos=%d, mirror=%d, jpeg=%lu bytes, dims=%dx%d)", + (int)p, (int)mirror, (unsigned long)photo.fileDataRepresentation.length, + resolved.photoDimensions.width, resolved.photoDimensions.height); + AVCapturePhotoOutput *output = self; + SEL selWillBegin = @selector(captureOutput:willBeginCaptureForResolvedSettings:); + SEL selWillCapture = @selector(captureOutput:willCapturePhotoForResolvedSettings:); + SEL selDidProcess = @selector(captureOutput:didFinishProcessingPhoto:error:); + SEL selDidCapture = @selector(captureOutput:didCapturePhotoForResolvedSettings:); + SEL selDidFinish = @selector(captureOutput:didFinishCaptureForResolvedSettings:error:); + dispatch_async(dispatch_get_main_queue(), ^{ + if ([delegate respondsToSelector:selWillBegin]) { + ((void (*)(id, SEL, AVCapturePhotoOutput *, AVCaptureResolvedPhotoSettings *)) + objc_msgSend)(delegate, selWillBegin, output, resolved); + } + if ([delegate respondsToSelector:selWillCapture]) { + ((void (*)(id, SEL, AVCapturePhotoOutput *, AVCaptureResolvedPhotoSettings *)) + objc_msgSend)(delegate, selWillCapture, output, resolved); + } + if ([delegate respondsToSelector:selDidCapture]) { + ((void (*)(id, SEL, AVCapturePhotoOutput *, AVCaptureResolvedPhotoSettings *)) + objc_msgSend)(delegate, selDidCapture, output, resolved); + } + BOOL delivered = NO; + if ([delegate respondsToSelector:selDidProcess]) { + ((void (*)(id, SEL, AVCapturePhotoOutput *, AVCapturePhoto *, NSError *)) + objc_msgSend)(delegate, selDidProcess, output, photo, (NSError *)nil); + delivered = YES; + } + if ([delegate respondsToSelector:selDidFinish]) { + ((void (*)(id, SEL, AVCapturePhotoOutput *, AVCaptureResolvedPhotoSettings *, NSError *)) + objc_msgSend)(delegate, selDidFinish, output, resolved, (NSError *)nil); + } + simcam_log(@"capturePhoto lifecycle complete (delivered photo=%d)", (int)delivered); + }); +} +@end + +#pragma mark - NSData write redirect (expo-camera placeholder substitution) + +static BOOL SimCamLooksLikeCameraDropPath(NSString *path) { + if (!path.length) return NO; + if (![path containsString:@"/Camera/"]) return NO; + NSString *lower = path.lowercaseString; + return [lower hasSuffix:@".jpg"] || [lower hasSuffix:@".jpeg"]; +} + +@interface NSData (SimCam) +@end +@implementation NSData (SimCam) + +- (BOOL)simcam_writeToURL:(NSURL *)url + options:(NSDataWritingOptions)opts + error:(NSError **)err { + NSString *path = url.isFileURL ? url.path : nil; + if (SimCamLooksLikeCameraDropPath(path)) { + NSData *snap = [[SimCamRegistry shared] currentSnapshotJPEGAtQuality:0.92]; + if (snap.length > 0) { + simcam_log(@"NSData writeToURL → substituted %lu→%lu bytes (%@)", + (unsigned long)self.length, (unsigned long)snap.length, path.lastPathComponent); + return [snap simcam_writeToURL:url options:opts error:err]; + } + } + return [self simcam_writeToURL:url options:opts error:err]; +} + +- (BOOL)simcam_writeToFile:(NSString *)path + options:(NSDataWritingOptions)opts + error:(NSError **)err { + if (SimCamLooksLikeCameraDropPath(path)) { + NSData *snap = [[SimCamRegistry shared] currentSnapshotJPEGAtQuality:0.92]; + if (snap.length > 0) { + simcam_log(@"NSData writeToFile → substituted %lu→%lu bytes (%@)", + (unsigned long)self.length, (unsigned long)snap.length, path.lastPathComponent); + return [snap simcam_writeToFile:path options:opts error:err]; + } + } + return [self simcam_writeToFile:path options:opts error:err]; +} + +@end + +#pragma mark - UIGraphicsImageRenderer redirect (camera-placeholder generators) + +static BOOL SimCamCallerLooksLikeCameraPlaceholder(void) { + if (!SimCamCameraIsInUse()) return NO; + + void *frames[12]; + int n = backtrace(frames, 12); + if (n < 4) return NO; + + static _Atomic uintptr_t cachedFrame = 0; + static _Atomic int cachedAnswer = -1; + uintptr_t topFrame = (uintptr_t)frames[3]; + if (atomic_load_explicit(&cachedFrame, memory_order_relaxed) == topFrame) { + int v = atomic_load_explicit(&cachedAnswer, memory_order_relaxed); + if (v >= 0) return (BOOL)v; + } + + BOOL match = NO; + int end = (n < 12) ? n : 12; + for (int i = 3; i < end; i++) { + Dl_info info; + if (dladdr(frames[i], &info) == 0 || !info.dli_sname) continue; + const char *name = info.dli_sname; + if (strstr(name, "generatePhoto") || + strstr(name, "generatePicture") || + strstr(name, "generateImage") || + strstr(name, "placeholderPhoto") || + strstr(name, "placeholderImage") || + strstr(name, "simulatorPhoto") || + strstr(name, "PictureForSimulator") || + strstr(name, "PhotoForSimulator") || + strstr(name, "ImageForSimulator") || + strstr(name, "mockPhoto") || + strstr(name, "fakePhoto")) { match = YES; break; } + if (strstr(name, "Camera") || strstr(name, "camera")) { + if (strstr(name, "Simulator") || + strstr(name, "simulator") || + strstr(name, "Placeholder") || + strstr(name, "placeholder") || + strstr(name, "generate")) { match = YES; break; } + } + } + atomic_store_explicit(&cachedFrame, topFrame, memory_order_relaxed); + atomic_store_explicit(&cachedAnswer, (int)match, memory_order_relaxed); + return match; +} + +@interface UIGraphicsImageRenderer (SimCam) +@end +@implementation UIGraphicsImageRenderer (SimCam) +- (UIImage *)simcam_imageWithActions:(void (NS_NOESCAPE ^)(UIGraphicsImageRendererContext *))actions { + if (SimCamCallerLooksLikeCameraPlaceholder()) { + NSData *jpeg = [[SimCamRegistry shared] currentSnapshotJPEGAtQuality:0.92]; + if (jpeg.length > 0) { + UIImage *snap = [UIImage imageWithData:jpeg]; + if (snap) { + simcam_log(@"UIGraphicsImageRenderer image: → live frame (jpeg %lu bytes)", + (unsigned long)jpeg.length); + return snap; + } + } + } + return [self simcam_imageWithActions:actions]; +} +@end + +#pragma mark - CoreMotion stubs + +static char kSimCamAccelTimerKey; +static char kSimCamGyroTimerKey; +static char kSimCamMagTimerKey; +static char kSimCamDeviceMotionTimerKey; + +static void SimCamStartTimer(id manager, char *key, NSTimeInterval interval, + dispatch_block_t tick) { + if (interval <= 0) interval = 0.1; + dispatch_source_t existing = objc_getAssociatedObject(manager, key); + if (existing) dispatch_source_cancel(existing); + dispatch_queue_t q = dispatch_queue_create("dev.servesim.simcam.motion", + DISPATCH_QUEUE_SERIAL); + dispatch_source_t t = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, q); + uint64_t ns = (uint64_t)(interval * NSEC_PER_SEC); + dispatch_source_set_timer(t, DISPATCH_TIME_NOW, ns, ns / 10); + dispatch_source_set_event_handler(t, tick); + dispatch_resume(t); + objc_setAssociatedObject(manager, key, t, OBJC_ASSOCIATION_RETAIN); +} + +static void SimCamStopTimer(id manager, char *key) { + dispatch_source_t t = objc_getAssociatedObject(manager, key); + if (t) { + dispatch_source_cancel(t); + objc_setAssociatedObject(manager, key, nil, OBJC_ASSOCIATION_RETAIN); + } +} + +@interface CMMotionManager (SimCam) +@end +@implementation CMMotionManager (SimCam) + +- (BOOL)simcam_isAccelerometerAvailable { return YES; } +- (BOOL)simcam_isGyroAvailable { return YES; } +- (BOOL)simcam_isMagnetometerAvailable { return YES; } +- (BOOL)simcam_isDeviceMotionAvailable { return YES; } + +- (BOOL)simcam_isAccelerometerActive { + return objc_getAssociatedObject(self, &kSimCamAccelTimerKey) != nil; +} +- (BOOL)simcam_isGyroActive { + return objc_getAssociatedObject(self, &kSimCamGyroTimerKey) != nil; +} +- (BOOL)simcam_isMagnetometerActive { + return objc_getAssociatedObject(self, &kSimCamMagTimerKey) != nil; +} +- (BOOL)simcam_isDeviceMotionActive { + return objc_getAssociatedObject(self, &kSimCamDeviceMotionTimerKey) != nil; +} + +- (CMAccelerometerData *)simcam_accelerometerData { return SimCamSharedAccelerometerData(); } +- (CMGyroData *)simcam_gyroData { return SimCamSharedGyroData(); } +- (CMMagnetometerData *)simcam_magnetometerData { return SimCamSharedMagnetometerData(); } +- (CMDeviceMotion *)simcam_deviceMotion { return SimCamSharedDeviceMotion(); } + +- (void)simcam_startAccelerometerUpdates { + if ([self simcam_isAccelerometerActive]) return; + SimCamStartTimer(self, &kSimCamAccelTimerKey, + self.accelerometerUpdateInterval, ^{}); +} +- (void)simcam_startAccelerometerUpdatesToQueue:(NSOperationQueue *)queue + withHandler:(CMAccelerometerHandler)handler { + if (!handler) { [self simcam_startAccelerometerUpdates]; return; } + CMAccelerometerHandler block = [handler copy]; + SimCamStartTimer(self, &kSimCamAccelTimerKey, + self.accelerometerUpdateInterval, ^{ + CMAccelerometerData *data = SimCamSharedAccelerometerData(); + if (queue) [queue addOperationWithBlock:^{ block(data, nil); }]; + else block(data, nil); + }); +} +- (void)simcam_stopAccelerometerUpdates { + SimCamStopTimer(self, &kSimCamAccelTimerKey); +} + +- (void)simcam_startGyroUpdates { + if ([self simcam_isGyroActive]) return; + SimCamStartTimer(self, &kSimCamGyroTimerKey, + self.gyroUpdateInterval, ^{}); +} +- (void)simcam_startGyroUpdatesToQueue:(NSOperationQueue *)queue + withHandler:(CMGyroHandler)handler { + if (!handler) { [self simcam_startGyroUpdates]; return; } + CMGyroHandler block = [handler copy]; + SimCamStartTimer(self, &kSimCamGyroTimerKey, self.gyroUpdateInterval, ^{ + CMGyroData *data = SimCamSharedGyroData(); + if (queue) [queue addOperationWithBlock:^{ block(data, nil); }]; + else block(data, nil); + }); +} +- (void)simcam_stopGyroUpdates { + SimCamStopTimer(self, &kSimCamGyroTimerKey); +} + +- (void)simcam_startMagnetometerUpdates { + if ([self simcam_isMagnetometerActive]) return; + SimCamStartTimer(self, &kSimCamMagTimerKey, + self.magnetometerUpdateInterval, ^{}); +} +- (void)simcam_startMagnetometerUpdatesToQueue:(NSOperationQueue *)queue + withHandler:(CMMagnetometerHandler)handler { + if (!handler) { [self simcam_startMagnetometerUpdates]; return; } + CMMagnetometerHandler block = [handler copy]; + SimCamStartTimer(self, &kSimCamMagTimerKey, self.magnetometerUpdateInterval, ^{ + CMMagnetometerData *data = SimCamSharedMagnetometerData(); + if (queue) [queue addOperationWithBlock:^{ block(data, nil); }]; + else block(data, nil); + }); +} +- (void)simcam_stopMagnetometerUpdates { + SimCamStopTimer(self, &kSimCamMagTimerKey); +} + +- (void)simcam_startDeviceMotionUpdates { + if ([self simcam_isDeviceMotionActive]) return; + SimCamStartTimer(self, &kSimCamDeviceMotionTimerKey, + self.deviceMotionUpdateInterval, ^{}); +} +- (void)simcam_startDeviceMotionUpdatesUsingReferenceFrame:(CMAttitudeReferenceFrame)frame { + (void)frame; + [self simcam_startDeviceMotionUpdates]; +} +- (void)simcam_startDeviceMotionUpdatesToQueue:(NSOperationQueue *)queue + withHandler:(CMDeviceMotionHandler)handler { + if (!handler) { [self simcam_startDeviceMotionUpdates]; return; } + CMDeviceMotionHandler block = [handler copy]; + SimCamStartTimer(self, &kSimCamDeviceMotionTimerKey, + self.deviceMotionUpdateInterval, ^{ + CMDeviceMotion *data = SimCamSharedDeviceMotion(); + if (queue) [queue addOperationWithBlock:^{ block(data, nil); }]; + else block(data, nil); + }); +} +- (void)simcam_startDeviceMotionUpdatesUsingReferenceFrame:(CMAttitudeReferenceFrame)frame + toQueue:(NSOperationQueue *)queue + withHandler:(CMDeviceMotionHandler)handler { + (void)frame; + [self simcam_startDeviceMotionUpdatesToQueue:queue withHandler:handler]; +} +- (void)simcam_stopDeviceMotionUpdates { + SimCamStopTimer(self, &kSimCamDeviceMotionTimerKey); +} + +@end + +static void InstallCoreMotionSwizzles(void) { + Class mm = [CMMotionManager class]; + if (!mm) return; + SwizzleInstanceMethod(mm, @selector(isAccelerometerAvailable), + @selector(simcam_isAccelerometerAvailable)); + SwizzleInstanceMethod(mm, @selector(isGyroAvailable), + @selector(simcam_isGyroAvailable)); + SwizzleInstanceMethod(mm, @selector(isMagnetometerAvailable), + @selector(simcam_isMagnetometerAvailable)); + SwizzleInstanceMethod(mm, @selector(isDeviceMotionAvailable), + @selector(simcam_isDeviceMotionAvailable)); + SwizzleInstanceMethod(mm, @selector(isAccelerometerActive), + @selector(simcam_isAccelerometerActive)); + SwizzleInstanceMethod(mm, @selector(isGyroActive), + @selector(simcam_isGyroActive)); + SwizzleInstanceMethod(mm, @selector(isMagnetometerActive), + @selector(simcam_isMagnetometerActive)); + SwizzleInstanceMethod(mm, @selector(isDeviceMotionActive), + @selector(simcam_isDeviceMotionActive)); + SwizzleInstanceMethod(mm, @selector(accelerometerData), + @selector(simcam_accelerometerData)); + SwizzleInstanceMethod(mm, @selector(gyroData), + @selector(simcam_gyroData)); + SwizzleInstanceMethod(mm, @selector(magnetometerData), + @selector(simcam_magnetometerData)); + SwizzleInstanceMethod(mm, @selector(deviceMotion), + @selector(simcam_deviceMotion)); + SwizzleInstanceMethod(mm, @selector(startAccelerometerUpdates), + @selector(simcam_startAccelerometerUpdates)); + SwizzleInstanceMethod(mm, @selector(startAccelerometerUpdatesToQueue:withHandler:), + @selector(simcam_startAccelerometerUpdatesToQueue:withHandler:)); + SwizzleInstanceMethod(mm, @selector(stopAccelerometerUpdates), + @selector(simcam_stopAccelerometerUpdates)); + SwizzleInstanceMethod(mm, @selector(startGyroUpdates), + @selector(simcam_startGyroUpdates)); + SwizzleInstanceMethod(mm, @selector(startGyroUpdatesToQueue:withHandler:), + @selector(simcam_startGyroUpdatesToQueue:withHandler:)); + SwizzleInstanceMethod(mm, @selector(stopGyroUpdates), + @selector(simcam_stopGyroUpdates)); + SwizzleInstanceMethod(mm, @selector(startMagnetometerUpdates), + @selector(simcam_startMagnetometerUpdates)); + SwizzleInstanceMethod(mm, @selector(startMagnetometerUpdatesToQueue:withHandler:), + @selector(simcam_startMagnetometerUpdatesToQueue:withHandler:)); + SwizzleInstanceMethod(mm, @selector(stopMagnetometerUpdates), + @selector(simcam_stopMagnetometerUpdates)); + SwizzleInstanceMethod(mm, @selector(startDeviceMotionUpdates), + @selector(simcam_startDeviceMotionUpdates)); + SwizzleInstanceMethod(mm, @selector(startDeviceMotionUpdatesUsingReferenceFrame:), + @selector(simcam_startDeviceMotionUpdatesUsingReferenceFrame:)); + SwizzleInstanceMethod(mm, @selector(startDeviceMotionUpdatesToQueue:withHandler:), + @selector(simcam_startDeviceMotionUpdatesToQueue:withHandler:)); + SwizzleInstanceMethod(mm, + @selector(startDeviceMotionUpdatesUsingReferenceFrame:toQueue:withHandler:), + @selector(simcam_startDeviceMotionUpdatesUsingReferenceFrame:toQueue:withHandler:)); + SwizzleInstanceMethod(mm, @selector(stopDeviceMotionUpdates), + @selector(simcam_stopDeviceMotionUpdates)); + simcam_log(@"CoreMotion stubs installed (portrait, face-up)"); +} + +#pragma mark - Install + +static void SimCamInstallPickerSwizzles(void); // defined below + +void SimCamInstallSwizzles(void) { + Class dev = [AVCaptureDevice class]; + SwizzleClassMethod(dev, + @selector(defaultDeviceWithDeviceType:mediaType:position:), + @selector(simcam_defaultDeviceWithDeviceType:mediaType:position:)); + SwizzleClassMethod(dev, + @selector(devicesWithMediaType:), + @selector(simcam_devicesWithMediaType:)); + SwizzleClassMethod(dev, @selector(devices), @selector(simcam_devices)); + + Class disc = [AVCaptureDeviceDiscoverySession class]; + SwizzleClassMethod(disc, + @selector(discoverySessionWithDeviceTypes:mediaType:position:), + @selector(simcam_discoverySessionWithDeviceTypes:mediaType:position:)); + + Class input = [AVCaptureDeviceInput class]; + SwizzleInstanceMethod(input, + @selector(initWithDevice:error:), + @selector(simcam_initWithDevice:error:)); + SwizzleInstanceMethod(input, @selector(device), @selector(simcam_device)); + SwizzleInstanceMethod(input, @selector(ports), @selector(simcam_ports)); + + Class sess = [AVCaptureSession class]; + SwizzleInstanceMethod(sess, @selector(addInput:), @selector(simcam_addInput:)); + SwizzleInstanceMethod(sess, @selector(canAddInput:), @selector(simcam_canAddInput:)); + SwizzleInstanceMethod(sess, @selector(addOutput:), @selector(simcam_addOutput:)); + SwizzleInstanceMethod(sess, @selector(canAddOutput:), @selector(simcam_canAddOutput:)); + SwizzleInstanceMethod(sess, @selector(startRunning), @selector(simcam_startRunning)); + SwizzleInstanceMethod(sess, @selector(stopRunning), @selector(simcam_stopRunning)); + SwizzleInstanceMethod(sess, @selector(isRunning), @selector(simcam_isRunning)); + SwizzleInstanceMethod(sess, @selector(inputs), @selector(simcam_inputs)); + SwizzleInstanceMethod(sess, @selector(outputs), @selector(simcam_outputs)); + SwizzleInstanceMethod(sess, @selector(connections), @selector(simcam_connections)); + SwizzleInstanceMethod(sess, + @selector(addInputWithNoConnections:), + @selector(simcam_addInputWithNoConnections:)); + SwizzleInstanceMethod(sess, + @selector(addOutputWithNoConnections:), + @selector(simcam_addOutputWithNoConnections:)); + SwizzleInstanceMethod(sess, @selector(removeInput:), @selector(simcam_removeInput:)); + SwizzleInstanceMethod(sess, @selector(removeOutput:), @selector(simcam_removeOutput:)); + SwizzleInstanceMethod(sess, + @selector(beginConfiguration), + @selector(simcam_beginConfiguration)); + SwizzleInstanceMethod(sess, + @selector(commitConfiguration), + @selector(simcam_commitConfiguration)); + SwizzleInstanceMethod(sess, + @selector(addConnection:), + @selector(simcam_addConnection:)); + SwizzleInstanceMethod(sess, + @selector(removeConnection:), + @selector(simcam_removeConnection:)); + SwizzleInstanceMethod(sess, + @selector(canAddConnection:), + @selector(simcam_canAddConnection:)); + + Class nc = [NSNotificationCenter class]; + SwizzleInstanceMethod(nc, + @selector(postNotificationName:object:userInfo:), + @selector(simcam_postNotificationName:object:userInfo:)); + SwizzleInstanceMethod(nc, + @selector(postNotificationName:object:), + @selector(simcam_postNotificationName:object:)); + SwizzleInstanceMethod(nc, + @selector(postNotification:), + @selector(simcam_postNotification:)); + simcam_log(@"NSNotificationCenter swizzles installed (AVCaptureSessionRuntimeErrorNotification gated)"); + + [[NSNotificationCenter defaultCenter] + addObserverForName:AVCaptureSessionRuntimeErrorNotification + object:nil + queue:nil + usingBlock:^(NSNotification *note) { + NSError *err = note.userInfo[AVCaptureSessionErrorKey]; + simcam_log(@"DIAG runtime-error delivered (post swizzle MISSED) object=%@ code=%ld desc=%@", + NSStringFromClass([note.object class]), + (long)err.code, + err.localizedDescription ?: @""); + }]; + + Class out = [AVCaptureVideoDataOutput class]; + SwizzleInstanceMethod(out, + @selector(setSampleBufferDelegate:queue:), + @selector(simcam_setSampleBufferDelegate:queue:)); + + Class outBase = [AVCaptureOutput class]; + SwizzleInstanceMethod(outBase, + @selector(connectionWithMediaType:), + @selector(simcam_connectionWithMediaType:)); + SwizzleInstanceMethod(outBase, + @selector(connections), + @selector(simcam_connections)); + + Class pl = [AVCaptureVideoPreviewLayer class]; + SwizzleInstanceMethod(pl, @selector(setSession:), @selector(simcam_setSession:)); + + Class fmtClass = [AVCaptureDeviceFormat class]; + SEL figFmtSel = NSSelectorFromString(@"figCaptureSourceVideoFormat"); + SEL figFmtSwizSel = @selector(simcam_figCaptureSourceVideoFormat); + BOOL figOk = SwizzleInstanceMethod(fmtClass, figFmtSel, figFmtSwizSel); + simcam_log(@"swizzle -[AVCaptureDeviceFormat figCaptureSourceVideoFormat] → %@", + figOk ? @"installed" : @"FAILED"); + + Class outClass = [AVCaptureOutput class]; + SEL availCodecsSel = NSSelectorFromString( + @"availableVideoCodecTypesForSourceDevice:sourceFormat:outputDimensions:fileType:videoCodecTypesAllowList:"); + SEL availCodecsSwizSel = @selector(simcam_availableVideoCodecTypesForSourceDevice:sourceFormat:outputDimensions:fileType:videoCodecTypesAllowList:); + Method origAvailCodecs = class_getClassMethod(outClass, availCodecsSel); + Method swizAvailCodecs = class_getClassMethod(outClass, availCodecsSwizSel); + if (origAvailCodecs && swizAvailCodecs) { + method_exchangeImplementations(origAvailCodecs, swizAvailCodecs); + simcam_log(@"swizzle +[AVCaptureOutput availableVideoCodecTypes…] → installed"); + } else { + simcam_log(@"swizzle +[AVCaptureOutput availableVideoCodecTypes…] FAILED (orig=%p swiz=%p)", + origAvailCodecs, swizAvailCodecs); + } + + Class photoOut = [AVCapturePhotoOutput class]; + if (@available(iOS 16.0, *)) { + SwizzleInstanceMethod(photoOut, + @selector(maxPhotoDimensions), + @selector(simcam_maxPhotoDimensions)); + SwizzleInstanceMethod(photoOut, + @selector(setMaxPhotoDimensions:), + @selector(simcam_setMaxPhotoDimensions:)); + } + SwizzleInstanceMethod(photoOut, + @selector(capturePhotoWithSettings:delegate:), + @selector(simcam_capturePhotoWithSettings:delegate:)); + + Class data = [NSData class]; + SwizzleInstanceMethod(data, + @selector(writeToURL:options:error:), + @selector(simcam_writeToURL:options:error:)); + SwizzleInstanceMethod(data, + @selector(writeToFile:options:error:), + @selector(simcam_writeToFile:options:error:)); + + Class renderer = [UIGraphicsImageRenderer class]; + SwizzleInstanceMethod(renderer, + @selector(imageWithActions:), + @selector(simcam_imageWithActions:)); + + InstallCoreMotionSwizzles(); + SimCamInstallPickerSwizzles(); +} + +#pragma mark - UIImagePickerController native-UI bridge + +// UIImagePickerController with sourceType=.camera renders Apple's own UI +// in the simulator (CAMPreviewView, CAMDynamicShutterControl, flash & switch +// buttons, etc.) but its viewfinder shows a gray "no camera" placeholder +// (CAMSnapshotView) and the shutter is permanently disabled because there's +// no real camera. We make the picker work by, on viewDidAppear: +// +// 1. Hiding CAMSnapshotView so our frames (already being pushed into the +// picker's AVCaptureVideoPreviewLayer via the existing setSession: +// swizzle) show through. +// 2. Capturing CAMPreviewView's aspect so the captured photo can be +// center-cropped to match the live framing. +// 3. Wrapping CAMDynamicShutterControl.delegate to catch +// shutterControlTouchAttemptedWhileDisabled: (fired even on a disabled +// shutter) and deliver our current frame to picker.delegate. +// +// All private class names are looked up by string with try/catch — if Apple +// renames them in a future iOS, the picker degrades to "back to gray + no +// shutter" rather than crashing. +// +// Credit: this is the approach pioneered by baguette's SimCamInject. + +static NSString *const SimCamPickerUTImage = @"public.image"; + +// Set during the view-tree walk so SimCamShutterDelegateWrapper can reach +// the host picker without changing its delegate-protocol signature. +static __weak UIImagePickerController *gSimCamCurrentPicker = nil; +static const void *kSimCamShutterWrappedDelegateKey = &kSimCamShutterWrappedDelegateKey; + +static UIImage *SimCamPickerSnapshotImageMirrored(BOOL mirror) { + CVPixelBufferRef pb = [[SimCamRegistry shared] currentPixelBuffer]; + if (!pb) return nil; + CIImage *ci = [CIImage imageWithCVPixelBuffer:pb]; + if (mirror) ci = [ci imageByApplyingOrientation:kCGImagePropertyOrientationUpMirrored]; + static CIContext *ctx = nil; static dispatch_once_t once; + dispatch_once(&once, ^{ ctx = [CIContext contextWithOptions:nil]; }); + CGImageRef cg = [ctx createCGImage:ci fromRect:ci.extent]; + CVPixelBufferRelease(pb); + if (!cg) return nil; + UIImage *img = [UIImage imageWithCGImage:cg]; + CGImageRelease(cg); + return img; +} + +static void SimCamDeliverFrameToPicker(UIImagePickerController *picker) { + if (!picker) return; + AVCaptureDevicePosition pos = + (picker.cameraDevice == UIImagePickerControllerCameraDeviceFront) + ? AVCaptureDevicePositionFront + : AVCaptureDevicePositionBack; + BOOL mirror = SimCamShouldMirror(pos); + UIImage *image = SimCamPickerSnapshotImageMirrored(mirror); + if (!image) { + simcam_log(@"picker shutter: no frame available — skipping delivery"); + return; + } + id delegate = + (id)picker.delegate; + if (![delegate respondsToSelector:@selector(imagePickerController:didFinishPickingMediaWithInfo:)]) { + simcam_log(@"picker shutter: delegate %@ doesn't implement didFinishPickingMediaWithInfo:", + NSStringFromClass([(id)delegate class])); + return; + } + NSMutableDictionary *info = [NSMutableDictionary dictionaryWithDictionary:@{ + UIImagePickerControllerOriginalImage: image, + UIImagePickerControllerMediaType: SimCamPickerUTImage, + }]; + // With allowsEditing, Apple shows an edit screen before delivery and + // populates editedImage + cropRect. We skip the edit UI, but pass the + // image through both keys with a full-image crop so apps that read + // editedImage don't get nil. + if (picker.allowsEditing) { + info[UIImagePickerControllerEditedImage] = image; + info[UIImagePickerControllerCropRect] = + [NSValue valueWithCGRect:CGRectMake(0, 0, image.size.width, image.size.height)]; + } + simcam_log(@"picker shutter → delivering %.0fx%.0f (edit=%d) to %@", + image.size.width, image.size.height, (int)picker.allowsEditing, + NSStringFromClass([(id)delegate class])); + [delegate imagePickerController:picker didFinishPickingMediaWithInfo:info]; +} + +// Wraps CAMDynamicShutterControl's delegate. In the simulator the shutter +// is always disabled (no real camera), so taps come through as +// shutterControlTouchAttemptedWhileDisabled: rather than the usual +// short-press selector. Catch both and deliver a frame; forward everything +// else to the original delegate via message forwarding so Apple's chrome +// keeps working. +@interface SimCamShutterDelegateWrapper : NSObject +@property (nonatomic, weak) id originalDelegate; +@property (nonatomic, weak) UIImagePickerController *picker; +@property (nonatomic, assign) BOOL hasDelivered; +@end + +@implementation SimCamShutterDelegateWrapper +- (void)shutterControlTouchAttemptedWhileDisabled:(id)control { + if (self.hasDelivered) return; + self.hasDelivered = YES; + simcam_log(@"intercepted shutterControlTouchAttemptedWhileDisabled"); + SimCamDeliverFrameToPicker(self.picker); +} +- (void)dynamicShutterControlDidShortPress:(id)control { + if (self.hasDelivered) return; + self.hasDelivered = YES; + simcam_log(@"intercepted dynamicShutterControlDidShortPress"); + SimCamDeliverFrameToPicker(self.picker); +} +- (BOOL)respondsToSelector:(SEL)sel { + return [super respondsToSelector:sel] || [self.originalDelegate respondsToSelector:sel]; +} +- (id)forwardingTargetForSelector:(SEL)sel { + if ([self.originalDelegate respondsToSelector:sel]) return self.originalDelegate; + return nil; +} +- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel { + NSMethodSignature *sig = [super methodSignatureForSelector:sel]; + if (sig) return sig; + return [(NSObject *)self.originalDelegate methodSignatureForSelector:sel]; +} +@end + +static void SimCamWalkPickerTree(UIView *view) { + NSString *cls = NSStringFromClass([view class]); + + // CAMPreviewView is where Apple shows the live viewfinder. On iOS 26 + // simulator it's force-hidden (no camera), and its inner CALayers have + // nil contents (the AVCaptureVideoPreviewLayer was created but never + // attached because the system errored). Unhide the view, hide the + // "Live Preview" UILabel placeholder, and register the empty content + // CALayer with the pump so our frames stream into it. + if ([cls isEqualToString:@"CAMPreviewView"]) { + if (view.hidden) { + view.hidden = NO; + simcam_log(@"un-hid CAMPreviewView (%@)", NSStringFromCGRect(view.frame)); + } + // Hide the "Live Preview" placeholder label that Apple ships in the + // sim build. Real device doesn't have it; simulator does. + for (UIView *sub in view.subviews) { + if ([sub isKindOfClass:[UILabel class]] && !sub.hidden) { + sub.hidden = YES; + simcam_log(@"hid CAMPreviewView UILabel placeholder"); + } + } + // Register the full-size content layer with the pump so it gets + // frames pushed into it via setContents:. Tag with the picker's + // current cameraDevice so SimCamShouldMirror picks the right axis + // (front → mirrored, back → not). + for (CALayer *sub in view.layer.sublayers) { + if (CGRectEqualToRect(sub.frame, view.bounds) || + (sub.frame.size.width >= view.bounds.size.width * 0.95 && + sub.frame.size.height >= view.bounds.size.height * 0.95)) { + AVCaptureDevicePosition pos = AVCaptureDevicePositionBack; + if (gSimCamCurrentPicker.cameraDevice == + UIImagePickerControllerCameraDeviceFront) { + pos = AVCaptureDevicePositionFront; + } + SimCamSetPosition(sub, pos); + [[SimCamRegistry shared] addPreviewLayer:(AVCaptureVideoPreviewLayer *)sub]; + simcam_log(@"registered CAMPreviewView content layer %@ pos=%d", + NSStringFromClass([sub class]), (int)pos); + break; + } + } + } + + // CAMSnapshotView is a full-screen sibling that covers everything with + // a gray "viewfinder closed" image. Hide it so the preview shows. + if ([cls isEqualToString:@"CAMSnapshotView"] && !view.hidden) { + view.hidden = YES; + simcam_log(@"hid CAMSnapshotView to clear gray cover"); + } + + // CAMDynamicShutterControl — wrap its delegate so taps on the + // (disabled) shutter still deliver a frame. Apple reuses the same + // control instance across picker presentations, so re-seat picker and + // reset hasDelivered on every walk; otherwise the second shot would be + // silently dropped. + if ([cls isEqualToString:@"CAMDynamicShutterControl"] && gSimCamCurrentPicker) { + @try { + SimCamShutterDelegateWrapper *existing = + objc_getAssociatedObject(view, kSimCamShutterWrappedDelegateKey); + id currentDelegate = [view valueForKey:@"delegate"]; + if (existing) { + existing.picker = gSimCamCurrentPicker; + existing.hasDelivered = NO; + if (currentDelegate != existing) { + existing.originalDelegate = currentDelegate; + [view setValue:existing forKey:@"delegate"]; + simcam_log(@"re-seated shutter wrapper (orig: %@)", + NSStringFromClass([currentDelegate class])); + } + } else { + SimCamShutterDelegateWrapper *wrapper = [SimCamShutterDelegateWrapper new]; + wrapper.originalDelegate = currentDelegate; + wrapper.picker = gSimCamCurrentPicker; + objc_setAssociatedObject(view, kSimCamShutterWrappedDelegateKey, wrapper, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [view setValue:wrapper forKey:@"delegate"]; + simcam_log(@"hijacked %@.delegate (orig: %@)", + cls, NSStringFromClass([currentDelegate class])); + } + } @catch (NSException *e) { + simcam_log(@"failed to hijack shutter delegate: %@", e); + } + } + + for (UIView *child in view.subviews) SimCamWalkPickerTree(child); +} + +@interface UIImagePickerController (SimCam) +@end +@implementation UIImagePickerController (SimCam) + ++ (BOOL)simcam_isSourceTypeAvailable:(UIImagePickerControllerSourceType)t { + if (t == UIImagePickerControllerSourceTypeCamera) return YES; + return [self simcam_isSourceTypeAvailable:t]; +} ++ (NSArray *)simcam_availableMediaTypesForSourceType:(UIImagePickerControllerSourceType)t { + if (t == UIImagePickerControllerSourceTypeCamera) return @[SimCamPickerUTImage]; + return [self simcam_availableMediaTypesForSourceType:t]; +} ++ (NSArray *)simcam_availableCaptureModesForCameraDevice:(UIImagePickerControllerCameraDevice)d { + (void)d; return @[ @(UIImagePickerControllerCameraCaptureModePhoto) ]; +} ++ (BOOL)simcam_isCameraDeviceAvailable:(UIImagePickerControllerCameraDevice)d { (void)d; return YES; } ++ (BOOL)simcam_isFlashAvailableForCameraDevice:(UIImagePickerControllerCameraDevice)d { (void)d; return NO; } + +- (void)simcam_viewDidAppear:(BOOL)animated { + [self simcam_viewDidAppear:animated]; + if (self.sourceType != UIImagePickerControllerSourceTypeCamera) return; + gSimCamCurrentPicker = self; + SimCamWalkPickerTree(self.view); + gSimCamCurrentPicker = nil; +} + +@end + +static void SimCamInstallPickerSwizzles(void) { + // method_exchangeImplementations is its own inverse — a second call + // would un-install. Guard with dispatch_once. + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class picker = [UIImagePickerController class]; + SwizzleClassMethod(picker, + @selector(isSourceTypeAvailable:), + @selector(simcam_isSourceTypeAvailable:)); + SwizzleClassMethod(picker, + @selector(availableMediaTypesForSourceType:), + @selector(simcam_availableMediaTypesForSourceType:)); + SwizzleClassMethod(picker, + @selector(availableCaptureModesForCameraDevice:), + @selector(simcam_availableCaptureModesForCameraDevice:)); + SwizzleClassMethod(picker, + @selector(isCameraDeviceAvailable:), + @selector(simcam_isCameraDeviceAvailable:)); + SwizzleClassMethod(picker, + @selector(isFlashAvailableForCameraDevice:), + @selector(simcam_isFlashAvailableForCameraDevice:)); + SwizzleInstanceMethod(picker, + @selector(viewDidAppear:), + @selector(simcam_viewDidAppear:)); + simcam_log(@"UIImagePickerController swizzles installed"); + }); +} diff --git a/app/macos/SimCameraInjector/SimCameraInjector.m b/app/macos/SimCameraInjector/SimCameraInjector.m new file mode 100644 index 0000000..e785a8f --- /dev/null +++ b/app/macos/SimCameraInjector/SimCameraInjector.m @@ -0,0 +1,19 @@ +#import +#include + +#import "SimCamFakes.h" +#import "SimCamFrameSource.h" +#import "SimCamLog.h" +#import "SimCamSwizzles.h" + +__attribute__((constructor)) +static void SimCamInit(void) { + @autoreleasepool { + simcam_log(@"loaded into pid %d", getpid()); + SimCamReadMirrorModeFromEnv(); + SimCamFrameSourceOpenShmIfRequested(); + if (!SimCamFrameSourceIsShmAttached()) SimCamFrameSourceLoadImage(); + SimCamInstallSwizzles(); + simcam_log(@"swizzles installed"); + } +} diff --git a/app/macos/SimCameraInjector/build.sh b/app/macos/SimCameraInjector/build.sh new file mode 100755 index 0000000..41c21bb --- /dev/null +++ b/app/macos/SimCameraInjector/build.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Builds libSimCameraInjector.dylib for the iOS Simulator (arm64 + x86_64) +# into $1. Adapted from serve-sim's Sources/SimCameraInjector/build.sh. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +OUT_DIR="$1" +DYLIB="$OUT_DIR/libSimCameraInjector.dylib" +mkdir -p "$OUT_DIR" + +# Xcode's macOS build environment (SDKROOT, deployment target, ...) must not +# leak into an iphonesimulator compile. +unset SDKROOT MACOSX_DEPLOYMENT_TARGET IPHONEOS_DEPLOYMENT_TARGET + +newest_source=$(ls -t "$HERE"/*.m "$HERE"/*.h "$HERE"/include/*.h "$0" | head -1) +if [[ -f "$DYLIB" && "$DYLIB" -nt "$newest_source" ]]; then + exit 0 +fi + +SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun --sdk iphonesimulator clang \ + -arch arm64 -arch x86_64 \ + -mios-simulator-version-min=15.0 \ + -isysroot "$SDK" \ + -dynamiclib \ + -fobjc-arc \ + -fmodules \ + -fobjc-weak \ + -framework Foundation \ + -framework UIKit \ + -framework AVFoundation \ + -framework CoreImage \ + -framework CoreMedia \ + -framework CoreMotion \ + -framework CoreVideo \ + -framework CoreGraphics \ + -framework IOSurface \ + -framework QuartzCore \ + -install_name "@rpath/libSimCameraInjector.dylib" \ + -o "$DYLIB" \ + "$HERE/SimCameraInjector.m" \ + "$HERE/SimCamLog.m" \ + "$HERE/SimCamFakes.m" \ + "$HERE/SimCamFrameSource.m" \ + "$HERE/SimCamSwizzles.m" diff --git a/app/macos/SimCameraInjector/include/SimCamShared.h b/app/macos/SimCameraInjector/include/SimCamShared.h new file mode 100644 index 0000000..86847ca --- /dev/null +++ b/app/macos/SimCameraInjector/include/SimCamShared.h @@ -0,0 +1,79 @@ +// Wire format for serve-sim's simulator camera feed. +// +// Frames travel over a small ring of IOSurfaces shared by global surface ID. +// The host helper (running on macOS) renders BGRA frames straight into the +// surfaces; the injected dylib inside the simulator app looks the surfaces up +// by ID and wraps the latest one as a CVPixelBuffer — no per-frame pixel copy. +// +// A tiny POSIX shared-memory region acts as the control channel: it carries +// the surface IDs, which surface holds the newest frame, the frame sequence +// number, and the mirror mode. The pixels themselves live in the IOSurfaces, +// not in this region. +// +// Surfaces are BGRA. Row stride comes from IOSurfaceGetBytesPerRow (it may be +// padded beyond width*4); `bytesPerRow` below mirrors that actual stride. +// +// Synchronization is lock-free and lossy. The writer renders into a surface +// the reader is not holding, points `latestIndex` at it, then bumps `frameSeq` +// last. The reader samples `frameSeq` before and after wrapping the surface and +// retries on the next tick if they disagree. A single dropped frame is fine for +// a 30 fps camera. + +#ifndef SIM_CAM_SHARED_H +#define SIM_CAM_SHARED_H + +#include +#include +#include + +#define SIMCAM_SHM_MAGIC 0x53434D31u // 'SCM1' +#define SIMCAM_PIXEL_BGRA 0u +#define SIMCAM_DEFAULT_WIDTH 1280u +#define SIMCAM_DEFAULT_HEIGHT 720u + +// Number of IOSurfaces in the ring. The writer keeps off whichever surface the +// reader most recently published, so a few buffers absorb a reader that holds +// a frame for a tick or two without tearing. +#define SIMCAM_SURFACE_RING 4u + +// Mirror mode codes for SimCamShmHeader.mirrorMode. +// "Unset" lets the dylib fall back to its env-var configuration (back-compat +// with hosts that don't write the byte). +#define SIMCAM_MIRROR_UNSET 0xFF +#define SIMCAM_MIRROR_AUTO 0 +#define SIMCAM_MIRROR_ON 1 +#define SIMCAM_MIRROR_OFF 2 + +// Control header is 64 bytes. The surface-ID table follows immediately after. +typedef struct { + uint32_t magic; // SIMCAM_SHM_MAGIC + uint32_t version; // bumps on layout change + uint32_t width; + uint32_t height; + uint32_t pixelFormat; // SIMCAM_PIXEL_BGRA + uint32_t bytesPerRow; // actual IOSurface row stride (may exceed width*4) + uint64_t pixelByteSize;// logical frame size: width*height*4 + _Atomic uint64_t frameSeq; // written LAST with release; readers acquire-load + uint64_t timestampNs; // mach_absolute_time-based, host monotonic + uint8_t mirrorMode; // SIMCAM_MIRROR_*; UNSET = ignore (use env) + uint8_t reserved[15]; +} SimCamShmHeader; + +_Static_assert(sizeof(SimCamShmHeader) == 64, "SimCamShmHeader must be 64 bytes"); +_Static_assert(offsetof(SimCamShmHeader, frameSeq) == 32, "frameSeq offset must stay stable"); +_Static_assert(offsetof(SimCamShmHeader, mirrorMode) == 48, "mirrorMode offset must stay stable"); + +// Ring of global IOSurface IDs, written once at startup. `latestIndex` is +// updated each frame (before frameSeq) to point at the freshest surface. +typedef struct __attribute__((packed)) { + uint32_t surfaceCount; // valid entries in ids[] + uint32_t latestIndex; // ids[] slot holding the newest frame + uint32_t ids[SIMCAM_SURFACE_RING]; // global IOSurface IDs +} SimCamSurfaceTable; + +// Total control-region size: header + surface table. +static inline uint64_t SimCamControlSize(void) { + return (uint64_t)sizeof(SimCamShmHeader) + (uint64_t)sizeof(SimCamSurfaceTable); +} + +#endif diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b1905d3..5b0180b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: equatable: ^2.0.7 get_it: ^9.3.0 simutil_adb: ^1.4.0 - simutil_apple: ^1.3.1 + simutil_apple: ^1.4.0 simutil_core: ^1.3.0 simutil_h264: ^0.1.0 simutil_shared: ^1.1.0 diff --git a/app/test/sim_camera_test.dart b/app/test/sim_camera_test.dart new file mode 100644 index 0000000..799fca3 --- /dev/null +++ b/app/test/sim_camera_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:simutil_app/src/stream/ios/sim_camera.dart'; +import 'package:simutil_core/simutil_core.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('parse reads webcam, image and video values', () { + expect(SimCamera.parse('webcam'), (source: 'webcam', arg: null)); + expect(SimCamera.parse('webcam:FaceTime'), ( + source: 'webcam', + arg: 'FaceTime', + )); + expect(SimCamera.parse('image:/a/b:c.png'), ( + source: 'image', + arg: '/a/b:c.png', + )); + expect(() => SimCamera.parse('video:'), throwsFormatException); + expect(() => SimCamera.parse('virtualscene'), throwsFormatException); + }); + + test('onDevices stops the feed of a simulator that shut down', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('simutil/sim_camera'), ( + call, + ) async { + calls.add(call); + return call.method == 'start' ? {'shm': '/s', 'dylib': '/d'} : null; + }); + Device sim(DeviceState state) => Device.ios( + id: 'UDID', + name: 'iPhone', + state: state, + type: DeviceType.simulator, + ); + await SimCamera.setSource('UDID', 'webcam'); + + SimCamera.onDevices([sim(DeviceState.booted)]); + expect(calls.map((c) => c.method), ['start']); + + SimCamera.onDevices([sim(DeviceState.shutdown)]); + SimCamera.onDevices([sim(DeviceState.shutdown)]); + expect(calls.map((c) => c.method), ['start', 'stop']); + expect(calls.last.arguments, {'udid': 'UDID'}); + }); +} diff --git a/packages/simutil_apple/CHANGELOG.md b/packages/simutil_apple/CHANGELOG.md index 9b44716..16454b4 100644 --- a/packages/simutil_apple/CHANGELOG.md +++ b/packages/simutil_apple/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.4.0 + +- `IosAutomation.launchWithCamera` grants a simulator app camera access and + relaunches it with a camera injector dylib inserted + (`SIMCTL_CHILD_DYLD_INSERT_LIBRARIES`) and its shared memory name + (`SIMCTL_CHILD_SIMCAM_SHM_NAME`), for the SimUtil desktop app's simulator + camera. +- `IosAutomation.installedApps` lists a simulator's user-installed apps + (`simctl listapps`). + ## 1.3.1 - `IOSDeviceService.getPhysicalDevices` passes diff --git a/packages/simutil_apple/lib/src/ios_automation.dart b/packages/simutil_apple/lib/src/ios_automation.dart index d18b65f..3eda5cf 100644 --- a/packages/simutil_apple/lib/src/ios_automation.dart +++ b/packages/simutil_apple/lib/src/ios_automation.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:simutil_apple/src/simulator_app_converter.dart'; @@ -15,6 +16,20 @@ abstract interface class IosAutomation implements DeviceAutomation { /// [device] after [convertForSimulator] on a temporary copy; [path] is /// left untouched. For a [DeviceOnlyBuildException] from [install]. Future installForSimulator(Device device, String path); + + /// User-installed apps on simulator [device] as (bundle id, name), sorted + /// by name. + Future> installedApps(Device device); + + /// Grants app [appId] camera access on simulator [device] and (re)launches + /// it with the camera injector [dylib] inserted (`DYLD_INSERT_LIBRARIES`), + /// reading frames from the POSIX shared memory region [shmName]. + Future launchWithCamera( + Device device, + String appId, { + required String dylib, + required String shmName, + }); } /// [IosAutomation.install] was given a device-only build for a simulator; @@ -211,6 +226,77 @@ class _IosAutomation implements IosAutomation { } } + @override + Future> installedApps(Device device) async { + // simctl prints an old-style plist; plutil turns it into JSON. + final result = await _exec.run( + 'sh', + arguments: [ + '-c', + r'xcrun simctl listapps "$0" | plutil -convert json -o - -', + device.id, + ], + timeout: const Duration(seconds: 15), + ); + if (!result.success) { + throw StateError( + 'Could not list apps on ${device.name}: ${result.stderr}'.trim(), + ); + } + final apps = [ + for (final MapEntry(:key, :value) + in (jsonDecode(result.stdout) as Map).entries) + if (value case {'ApplicationType': 'User'} && final Map info) + ( + id: key, + name: + (info['CFBundleDisplayName'] ?? info['CFBundleName'] ?? key) + as String, + ), + ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return apps; + } + + @override + Future launchWithCamera( + Device device, + String appId, { + required String dylib, + required String shmName, + }) async { + if (device.type.isPhysical) { + throw UnsupportedError('Camera injection needs a simulator'); + } + // ponytail: best effort; an app without a camera usage description + // still launches, and the dylib does not check authorization anyway. + await _exec.run( + 'xcrun', + arguments: ['simctl', 'privacy', device.id, 'grant', 'camera', appId], + timeout: const Duration(seconds: 15), + ); + // CommandExec has no environment; env(1) sets the SIMCTL_CHILD_ variables + // simctl forwards to the app. + final result = await _exec.run( + 'env', + arguments: [ + 'SIMCTL_CHILD_DYLD_INSERT_LIBRARIES=$dylib', + 'SIMCTL_CHILD_SIMCAM_SHM_NAME=$shmName', + 'xcrun', + 'simctl', + 'launch', + '--terminate-running-process', + device.id, + appId, + ], + timeout: const Duration(seconds: 30), + ); + if (!result.success) { + throw StateError( + 'Could not launch $appId on ${device.name}: ${result.stderr}'.trim(), + ); + } + } + /// Stops [appId] and empties its data container (Documents, Library, /// tmp). ponytail: app group containers and keychain items stay; erase the /// simulator when those matter. diff --git a/packages/simutil_apple/pubspec.yaml b/packages/simutil_apple/pubspec.yaml index 0ae1ed5..12703d0 100644 --- a/packages/simutil_apple/pubspec.yaml +++ b/packages/simutil_apple/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_apple description: Apple simulator and device APIs for SimUtil via simctl and devicectl. -version: 1.3.1 +version: 1.4.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_apple issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_apple/test/services/ios_automation_test.dart b/packages/simutil_apple/test/services/ios_automation_test.dart index 47fb703..f3af18b 100644 --- a/packages/simutil_apple/test/services/ios_automation_test.dart +++ b/packages/simutil_apple/test/services/ios_automation_test.dart @@ -105,6 +105,52 @@ void main() { ]); }); + test('installedApps lists user apps by name', () async { + final exec = FakeCommandExec( + (_, _) => FakeCommandExec.ok( + '{"com.apple.mobilesafari": {"ApplicationType": "System", ' + '"CFBundleDisplayName": "Safari"}, ' + '"com.acme.z": {"ApplicationType": "User", "CFBundleName": "Zed"}, ' + '"com.acme.a": {"ApplicationType": "User", ' + '"CFBundleDisplayName": "Acme"}}', + ), + ); + expect(await IosAutomation(exec).installedApps(_sim), [ + (id: 'com.acme.a', name: 'Acme'), + (id: 'com.acme.z', name: 'Zed'), + ]); + expect(exec.calls.single.arguments.last, 'UDID'); + }); + + test('launchWithCamera grants camera and inserts the dylib', () async { + final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); + await IosAutomation(exec).launchWithCamera( + _sim, + 'com.acme.app', + dylib: '/A/libSimCameraInjector.dylib', + shmName: '/simutil-cam-UDID', + ); + expect(exec.calls.first.arguments, [ + 'simctl', + 'privacy', + 'UDID', + 'grant', + 'camera', + 'com.acme.app', + ]); + expect(exec.calls.last.command, 'env'); + expect(exec.calls.last.arguments, [ + 'SIMCTL_CHILD_DYLD_INSERT_LIBRARIES=/A/libSimCameraInjector.dylib', + 'SIMCTL_CHILD_SIMCAM_SHM_NAME=/simutil-cam-UDID', + 'xcrun', + 'simctl', + 'launch', + '--terminate-running-process', + 'UDID', + 'com.acme.app', + ]); + }); + test('install surfaces the simctl error', () async { final exec = FakeCommandExec( (_, _) => FakeCommandExec.fail('not built for the simulator'),