Skip to content

Commit 0b81e80

Browse files
committed
Merge remote-tracking branch 'origin/main' into improve-apple-watch-chrome
2 parents bcc2f25 + 794d8f8 commit 0b81e80

41 files changed

Lines changed: 7594 additions & 288 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,51 @@ jobs:
232232

233233
- name: JS API simulator integration tests
234234
run: npm run test:integration:js-api
235+
236+
integration-android:
237+
name: Android emulator integration
238+
runs-on: ubuntu-latest
239+
timeout-minutes: 35
240+
needs:
241+
- client
242+
- packages
243+
244+
steps:
245+
- uses: actions/checkout@v4
246+
247+
- uses: actions/setup-node@v4
248+
with:
249+
node-version: 20
250+
cache: npm
251+
cache-dependency-path: package-lock.json
252+
253+
- uses: dtolnay/rust-toolchain@stable
254+
255+
- name: Enable KVM access
256+
run: |
257+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
258+
sudo udevadm control --reload-rules
259+
sudo udevadm trigger --name-match=kvm
260+
261+
- name: Install root dependencies
262+
run: npm ci --ignore-scripts --force
263+
264+
- name: Build Linux Android integration artifacts
265+
run: |
266+
npm run build:cli
267+
npm run build:simdeck-test
268+
269+
- name: Android emulator integration tests
270+
uses: reactivecircus/android-emulator-runner@v2
271+
with:
272+
api-level: 35
273+
target: google_apis
274+
arch: x86_64
275+
profile: pixel_6
276+
avd-name: SimDeck_Pixel_CI
277+
disable-animations: true
278+
script: npm run test:integration:android
279+
env:
280+
SIMDECK_INTEGRATION_ANDROID_AVD: SimDeck_Pixel_CI
281+
SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID: "1"
282+
SIMDECK_INTEGRATION_VERBOSE: "1"

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 video over browser-native WebRTC H.264 with H.264 WebSocket fallback
39+
- Android emulator frames are sourced from emulator gRPC; loopback browsers use raw RGBA over WebRTC, and non-loopback browsers use VideoToolbox-encoded H.264
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, Flutter, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
@@ -120,6 +121,7 @@ simdeck boot <udid>
120121
simdeck shutdown <udid>
121122
simdeck erase <udid>
122123
simdeck install <udid> /path/to/App.app
124+
simdeck install android:<avd-name> /path/to/app.apk
123125
simdeck uninstall <udid> com.example.App
124126
simdeck open-url <udid> https://example.com
125127
simdeck launch <udid> com.apple.Preferences
@@ -164,6 +166,14 @@ simdeck logs <udid> --seconds 30 --limit 200
164166
without launching Simulator.app, then falls back to `xcrun simctl` when private
165167
booting is unavailable.
166168

169+
Android emulators appear in `simdeck list` with IDs like
170+
`android:SimDeck_Pixel_8_API_36`. For Android IDs, lifecycle, install, launch,
171+
URL, screenshot, logs, UIAutomator `describe`, tap, swipe, text, key, home, app
172+
switcher, rotation, pasteboard, and browser live view route through the Android
173+
SDK tools (`emulator` and `adb`) plus the emulator gRPC screenshot stream for
174+
live video. `simdeck stream` remains iOS-only because it writes the iOS H.264
175+
transport stream.
176+
167177
`stream` writes an Annex B H.264 elementary stream to stdout for diagnostics or
168178
external tools such as `ffplay`.
169179

cli/native/XCWNativeBridge.h

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

94+
void * _Nullable xcw_native_h264_encoder_create(xcw_native_frame_callback _Nullable callback, void * _Nullable user_data, char * _Nullable * _Nullable error_message);
95+
void xcw_native_h264_encoder_destroy(void * _Nullable handle);
96+
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);
97+
void xcw_native_h264_encoder_request_keyframe(void * _Nonnull handle);
98+
9499
void xcw_native_free_string(char * _Nullable value);
95100
void xcw_native_free_bytes(xcw_native_owned_bytes bytes);
96101
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;
@@ -916,6 +1098,58 @@ void xcw_native_session_set_frame_callback(void *handle, xcw_native_frame_callba
9161098
}
9171099
}
9181100

1101+
void *xcw_native_h264_encoder_create(xcw_native_frame_callback callback, void *user_data, char **error_message) {
1102+
@autoreleasepool {
1103+
XCWNativeH264Encoder *encoder = [[XCWNativeH264Encoder alloc] initWithFrameCallback:callback
1104+
userData:user_data];
1105+
if (encoder == nil) {
1106+
if (error_message != NULL) {
1107+
*error_message = XCWCopyCString(@"Unable to create the native H.264 encoder.");
1108+
}
1109+
return NULL;
1110+
}
1111+
return (__bridge_retained void *)encoder;
1112+
}
1113+
}
1114+
1115+
void xcw_native_h264_encoder_destroy(void *handle) {
1116+
if (handle == NULL) {
1117+
return;
1118+
}
1119+
@autoreleasepool {
1120+
XCWNativeH264Encoder *encoder = CFBridgingRelease(handle);
1121+
[encoder invalidate];
1122+
}
1123+
}
1124+
1125+
bool xcw_native_h264_encoder_encode_rgba(void *handle,
1126+
const uint8_t *rgba,
1127+
size_t length,
1128+
uint32_t width,
1129+
uint32_t height,
1130+
uint64_t timestamp_us,
1131+
char **error_message) {
1132+
(void)timestamp_us;
1133+
@autoreleasepool {
1134+
NSError *error = nil;
1135+
BOOL ok = [XCWNativeH264EncoderFromHandle(handle) encodeRGBA:rgba
1136+
length:length
1137+
width:width
1138+
height:height
1139+
error:&error];
1140+
if (!ok) {
1141+
XCWSetErrorMessage(error_message, error);
1142+
}
1143+
return ok;
1144+
}
1145+
}
1146+
1147+
void xcw_native_h264_encoder_request_keyframe(void *handle) {
1148+
@autoreleasepool {
1149+
[XCWNativeH264EncoderFromHandle(handle) requestKeyFrame];
1150+
}
1151+
}
1152+
9191153
void xcw_native_free_string(char *value) {
9201154
if (value != NULL) {
9211155
free(value);

0 commit comments

Comments
 (0)