Skip to content

Commit e3ca3c2

Browse files
committed
Improve Android WebRTC input and encoding performance
1 parent c966672 commit e3ca3c2

22 files changed

Lines changed: 3568 additions & 129 deletions

File tree

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<p align="center">
77
SimDeck is a developer tool built for streamlining mobile app development for coding agents.
8-
Drive Simulator from the CLI using agents, browser, and automated tests on macOS.
8+
Drive iOS Simulators and Android emulators from the CLI using agents, browser, and automated tests on macOS.
99
</p>
1010
</p>
1111

@@ -35,8 +35,9 @@ view inside the editor.
3535

3636
## Features
3737

38-
- Local simulator video stream over browser-native WebRTC H.264 with H.264 WebSocket fallback
39-
- Full simulator control & inspection using private accessibility APIs - available using `simdeck` CLI
38+
- Local iOS Simulator and Android emulator video over browser-native WebRTC H.264 with H.264 WebSocket fallback
39+
- Android emulator frames are sourced from emulator gRPC and encoded through macOS VideoToolbox
40+
- Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI
4041
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
4142
- CoreSimulator chrome asset rendering for device bezels
4243
- NativeScript, React Native, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
@@ -138,6 +139,7 @@ simdeck boot <udid>
138139
simdeck shutdown <udid>
139140
simdeck erase <udid>
140141
simdeck install <udid> /path/to/App.app
142+
simdeck install android:<avd-name> /path/to/app.apk
141143
simdeck uninstall <udid> com.example.App
142144
simdeck open-url <udid> https://example.com
143145
simdeck launch <udid> com.apple.Preferences
@@ -179,6 +181,14 @@ simdeck logs <udid> --seconds 30 --limit 200
179181
without launching Simulator.app, then falls back to `xcrun simctl` when private
180182
booting is unavailable.
181183

184+
Android emulators appear in `simdeck list` with IDs like
185+
`android:SimDeck_Pixel_8_API_36`. For Android IDs, lifecycle, install, launch,
186+
URL, screenshot, logs, UIAutomator `describe`, tap, swipe, text, key, home, app
187+
switcher, rotation, pasteboard, and browser live view route through the Android
188+
SDK tools (`emulator` and `adb`) plus the emulator gRPC screenshot stream for
189+
live video. `simdeck stream` remains iOS-only because it writes the iOS H.264
190+
transport stream.
191+
182192
`stream` writes an Annex B H.264 elementary stream to stdout for diagnostics or
183193
external tools such as `ffplay`.
184194

cli/native/XCWNativeBridge.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ bool xcw_native_session_rotate_right(void * _Nonnull handle, char * _Nullable *
8989
bool xcw_native_session_rotate_left(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
9090
void xcw_native_session_set_frame_callback(void * _Nonnull handle, xcw_native_frame_callback _Nullable callback, void * _Nullable user_data);
9191

92+
void * _Nullable xcw_native_h264_encoder_create(xcw_native_frame_callback _Nullable callback, void * _Nullable user_data, char * _Nullable * _Nullable error_message);
93+
void xcw_native_h264_encoder_destroy(void * _Nullable handle);
94+
bool xcw_native_h264_encoder_encode_rgba(void * _Nonnull handle, const uint8_t * _Nonnull rgba, size_t length, uint32_t width, uint32_t height, uint64_t timestamp_us, char * _Nullable * _Nullable error_message);
95+
void xcw_native_h264_encoder_request_keyframe(void * _Nonnull handle);
96+
9297
void xcw_native_free_string(char * _Nullable value);
9398
void xcw_native_free_bytes(xcw_native_owned_bytes bytes);
9499
void xcw_native_release_shared_bytes(xcw_native_shared_bytes bytes);

cli/native/XCWNativeBridge.m

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
#import "DFPrivateSimulatorDisplayBridge.h"
44
#import "XCWAccessibilityBridge.h"
55
#import "XCWChromeRenderer.h"
6+
#import "XCWH264Encoder.h"
67
#import "XCWNativeSession.h"
78
#import "XCWSimctl.h"
89

910
#import <AppKit/AppKit.h>
1011
#import <CoreFoundation/CoreFoundation.h>
12+
#import <CoreVideo/CoreVideo.h>
1113
#include <stdlib.h>
1214
#include <string.h>
1315

@@ -63,10 +65,190 @@ static xcw_native_owned_bytes XCWOwnedBytesFromData(NSData *data) {
6365
return bytes;
6466
}
6567

68+
static xcw_native_shared_bytes XCWSharedBytesFromData(NSData *data) {
69+
if (data.length == 0) {
70+
return (xcw_native_shared_bytes){0};
71+
}
72+
73+
CFTypeRef owner = CFRetain((__bridge CFTypeRef)data);
74+
return (xcw_native_shared_bytes){
75+
.data = data.bytes,
76+
.length = data.length,
77+
.owner = (const void *)owner,
78+
};
79+
}
80+
6681
static XCWNativeSession *XCWNativeSessionFromHandle(void *handle) {
6782
return (__bridge XCWNativeSession *)handle;
6883
}
6984

85+
@interface XCWNativeH264Encoder : NSObject
86+
87+
- (instancetype)initWithFrameCallback:(xcw_native_frame_callback)callback
88+
userData:(void *)userData;
89+
- (BOOL)encodeRGBA:(const uint8_t *)rgba
90+
length:(size_t)length
91+
width:(uint32_t)width
92+
height:(uint32_t)height
93+
error:(NSError * _Nullable __autoreleasing *)error;
94+
- (void)requestKeyFrame;
95+
- (void)invalidate;
96+
97+
@end
98+
99+
@implementation XCWNativeH264Encoder {
100+
XCWH264Encoder *_encoder;
101+
xcw_native_frame_callback _callback;
102+
void *_callbackUserData;
103+
uint64_t _frameSequence;
104+
}
105+
106+
- (instancetype)initWithFrameCallback:(xcw_native_frame_callback)callback
107+
userData:(void *)userData {
108+
self = [super init];
109+
if (self == nil) {
110+
return nil;
111+
}
112+
113+
_callback = callback;
114+
_callbackUserData = userData;
115+
__weak typeof(self) weakSelf = self;
116+
@synchronized (XCWNativeH264Encoder.class) {
117+
const char *previousCodec = getenv("SIMDECK_VIDEO_CODEC");
118+
char *previousCodecCopy = previousCodec != NULL ? strdup(previousCodec) : NULL;
119+
const char *androidCodec = getenv("SIMDECK_ANDROID_VIDEO_CODEC");
120+
if (androidCodec == NULL || strlen(androidCodec) == 0) {
121+
androidCodec = "software";
122+
}
123+
setenv("SIMDECK_VIDEO_CODEC", androidCodec, 1);
124+
_encoder = [[XCWH264Encoder alloc] initWithOutputHandler:^(NSData *sampleData,
125+
uint64_t timestampUs,
126+
BOOL isKeyFrame,
127+
NSString * _Nullable codec,
128+
NSData * _Nullable decoderConfig,
129+
CGSize dimensions) {
130+
__strong typeof(weakSelf) strongSelf = weakSelf;
131+
if (strongSelf == nil || strongSelf->_callback == NULL || sampleData.length == 0) {
132+
return;
133+
}
134+
strongSelf->_frameSequence += 1;
135+
xcw_native_frame frame = {
136+
.frame_sequence = strongSelf->_frameSequence,
137+
.timestamp_us = timestampUs,
138+
.is_keyframe = isKeyFrame,
139+
.width = (uint32_t)llround(dimensions.width),
140+
.height = (uint32_t)llround(dimensions.height),
141+
.codec = codec.UTF8String,
142+
.description = XCWSharedBytesFromData(decoderConfig),
143+
.data = XCWSharedBytesFromData(sampleData),
144+
};
145+
strongSelf->_callback(&frame, strongSelf->_callbackUserData);
146+
}];
147+
if (previousCodecCopy != NULL) {
148+
setenv("SIMDECK_VIDEO_CODEC", previousCodecCopy, 1);
149+
free(previousCodecCopy);
150+
} else {
151+
unsetenv("SIMDECK_VIDEO_CODEC");
152+
}
153+
}
154+
return self;
155+
}
156+
157+
- (void)dealloc {
158+
[self invalidate];
159+
}
160+
161+
- (BOOL)encodeRGBA:(const uint8_t *)rgba
162+
length:(size_t)length
163+
width:(uint32_t)width
164+
height:(uint32_t)height
165+
error:(NSError * _Nullable __autoreleasing *)error {
166+
if (rgba == NULL || width == 0 || height == 0) {
167+
if (error != NULL) {
168+
*error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
169+
code:1
170+
userInfo:@{ NSLocalizedDescriptionKey: @"RGBA frame input was empty." }];
171+
}
172+
return NO;
173+
}
174+
size_t expectedLength = (size_t)width * (size_t)height * 4;
175+
if (length < expectedLength) {
176+
if (error != NULL) {
177+
*error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
178+
code:2
179+
userInfo:@{ NSLocalizedDescriptionKey: @"RGBA frame input was truncated." }];
180+
}
181+
return NO;
182+
}
183+
184+
NSDictionary *attributes = @{
185+
(__bridge NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
186+
(__bridge NSString *)kCVPixelBufferWidthKey: @(width),
187+
(__bridge NSString *)kCVPixelBufferHeightKey: @(height),
188+
(__bridge NSString *)kCVPixelBufferIOSurfacePropertiesKey: @{},
189+
};
190+
CVPixelBufferRef pixelBuffer = NULL;
191+
CVReturn createStatus = CVPixelBufferCreate(kCFAllocatorDefault,
192+
(size_t)width,
193+
(size_t)height,
194+
kCVPixelFormatType_32BGRA,
195+
(__bridge CFDictionaryRef)attributes,
196+
&pixelBuffer);
197+
if (createStatus != kCVReturnSuccess || pixelBuffer == NULL) {
198+
if (error != NULL) {
199+
*error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
200+
code:createStatus
201+
userInfo:@{ NSLocalizedDescriptionKey: @"Unable to allocate a VideoToolbox pixel buffer." }];
202+
}
203+
return NO;
204+
}
205+
206+
CVReturn lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, 0);
207+
if (lockStatus != kCVReturnSuccess) {
208+
CVPixelBufferRelease(pixelBuffer);
209+
if (error != NULL) {
210+
*error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
211+
code:lockStatus
212+
userInfo:@{ NSLocalizedDescriptionKey: @"Unable to lock a VideoToolbox pixel buffer." }];
213+
}
214+
return NO;
215+
}
216+
217+
uint8_t *dst = CVPixelBufferGetBaseAddress(pixelBuffer);
218+
size_t dstRowBytes = CVPixelBufferGetBytesPerRow(pixelBuffer);
219+
size_t srcRowBytes = (size_t)width * 4;
220+
for (uint32_t y = 0; y < height; y += 1) {
221+
const uint8_t *srcRow = rgba + ((size_t)y * srcRowBytes);
222+
uint8_t *dstRow = dst + ((size_t)y * dstRowBytes);
223+
for (uint32_t x = 0; x < width; x += 1) {
224+
const uint8_t *src = srcRow + ((size_t)x * 4);
225+
uint8_t *pixel = dstRow + ((size_t)x * 4);
226+
pixel[0] = src[2];
227+
pixel[1] = src[1];
228+
pixel[2] = src[0];
229+
pixel[3] = src[3];
230+
}
231+
}
232+
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
233+
[_encoder encodePixelBuffer:pixelBuffer];
234+
CVPixelBufferRelease(pixelBuffer);
235+
return YES;
236+
}
237+
238+
- (void)requestKeyFrame {
239+
[_encoder requestKeyFrame];
240+
}
241+
242+
- (void)invalidate {
243+
[_encoder invalidate];
244+
}
245+
246+
@end
247+
248+
static XCWNativeH264Encoder *XCWNativeH264EncoderFromHandle(void *handle) {
249+
return (__bridge XCWNativeH264Encoder *)handle;
250+
}
251+
70252
static BOOL XCWPerformSimctlAction(char **errorMessage, BOOL (^action)(XCWSimctl *simctl, NSError **error)) {
71253
XCWSimctl *simctl = [[XCWSimctl alloc] init];
72254
NSError *error = nil;
@@ -889,6 +1071,58 @@ void xcw_native_session_set_frame_callback(void *handle, xcw_native_frame_callba
8891071
}
8901072
}
8911073

1074+
void *xcw_native_h264_encoder_create(xcw_native_frame_callback callback, void *user_data, char **error_message) {
1075+
@autoreleasepool {
1076+
XCWNativeH264Encoder *encoder = [[XCWNativeH264Encoder alloc] initWithFrameCallback:callback
1077+
userData:user_data];
1078+
if (encoder == nil) {
1079+
if (error_message != NULL) {
1080+
*error_message = XCWCopyCString(@"Unable to create the native H.264 encoder.");
1081+
}
1082+
return NULL;
1083+
}
1084+
return (__bridge_retained void *)encoder;
1085+
}
1086+
}
1087+
1088+
void xcw_native_h264_encoder_destroy(void *handle) {
1089+
if (handle == NULL) {
1090+
return;
1091+
}
1092+
@autoreleasepool {
1093+
XCWNativeH264Encoder *encoder = CFBridgingRelease(handle);
1094+
[encoder invalidate];
1095+
}
1096+
}
1097+
1098+
bool xcw_native_h264_encoder_encode_rgba(void *handle,
1099+
const uint8_t *rgba,
1100+
size_t length,
1101+
uint32_t width,
1102+
uint32_t height,
1103+
uint64_t timestamp_us,
1104+
char **error_message) {
1105+
(void)timestamp_us;
1106+
@autoreleasepool {
1107+
NSError *error = nil;
1108+
BOOL ok = [XCWNativeH264EncoderFromHandle(handle) encodeRGBA:rgba
1109+
length:length
1110+
width:width
1111+
height:height
1112+
error:&error];
1113+
if (!ok) {
1114+
XCWSetErrorMessage(error_message, error);
1115+
}
1116+
return ok;
1117+
}
1118+
}
1119+
1120+
void xcw_native_h264_encoder_request_keyframe(void *handle) {
1121+
@autoreleasepool {
1122+
[XCWNativeH264EncoderFromHandle(handle) requestKeyFrame];
1123+
}
1124+
}
1125+
8921126
void xcw_native_free_string(char *value) {
8931127
if (value != NULL) {
8941128
free(value);

client/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,17 @@ export interface PrivateDisplayInfo {
2828
export interface SimulatorMetadata {
2929
udid: string;
3030
name: string;
31+
platform?: "ios-simulator" | "android-emulator" | string;
3132
runtimeName?: string;
3233
runtimeIdentifier?: string;
3334
deviceTypeName?: string;
3435
deviceTypeIdentifier?: string;
3536
isBooted: boolean;
37+
android?: {
38+
avdName?: string;
39+
grpcPort?: number;
40+
serial?: string;
41+
};
3642
privateDisplay?: PrivateDisplayInfo;
3743
}
3844

@@ -61,6 +67,7 @@ export interface ChromeProfile {
6167
screenWidth: number;
6268
screenHeight: number;
6369
cornerRadius: number;
70+
chromeStyle?: "asset" | "css-android" | string;
6471
hasScreenMask?: boolean;
6572
buttons?: ChromeButtonProfile[];
6673
}

client/src/app/AppShell.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,9 +737,12 @@ export function AppShell({
737737
: "",
738738
[selectedSimulator?.udid, streamStamp],
739739
);
740+
const chromeUsesAsset = Boolean(
741+
viewportChromeProfile && viewportChromeProfile.chromeStyle !== "css-android",
742+
);
740743
const chromeRequired = Boolean(
741744
(shouldRenderChrome && !chromeProfileReady) ||
742-
(viewportChromeProfile && chromeUrl),
745+
(chromeUsesAsset && chromeUrl),
743746
);
744747
const simulatorRotationQuarterTurns =
745748
normalizeSimulatorRotationQuarterTurns(selectedSimulator);
@@ -1935,6 +1938,7 @@ export function AppShell({
19351938
chromeProfile={viewportChromeProfile}
19361939
chromeRequired={chromeRequired}
19371940
chromeScreenStyle={viewportScreenStyle}
1941+
chromeStyle={viewportChromeProfile?.chromeStyle}
19381942
chromeUrl={chromeUrl}
19391943
chromeButtonUrl={chromeButtonUrl}
19401944
debugPanel={

client/src/features/simulators/simulatorDisplay.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export function simulatorRuntimeLabel(simulator: SimulatorMetadata): string {
1414
export function shouldRenderNativeChrome(
1515
simulator: SimulatorMetadata,
1616
): boolean {
17+
if (simulator.platform === "android-emulator") {
18+
return true;
19+
}
1720
const identifier = simulator.deviceTypeIdentifier ?? "";
1821
const name = simulator.name ?? "";
1922
const deviceTypeName = simulator.deviceTypeName ?? "";

0 commit comments

Comments
 (0)