Skip to content

Commit 80c6f30

Browse files
committed
Merge remote-tracking branch 'origin/main' into implement-private-boot-path
# Conflicts: # docs/cli/commands.md # docs/guide/architecture.md # docs/guide/installation.md # packages/simdeck-test/dist/index.d.ts # packages/simdeck-test/dist/index.js # packages/simdeck-test/src/index.ts
2 parents 6b3b885 + 794d8f8 commit 80c6f30

41 files changed

Lines changed: 7587 additions & 312 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 & 21 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
@@ -68,24 +69,6 @@ simdeck "iPhone 17 Pro Max"
6869
The served loopback browser UI receives the generated API access token automatically.
6970
LAN clients should pair with the printed code before receiving the API cookie.
7071

71-
SimDeck Studio providers run the daemon on loopback and use
72-
`scripts/studio-provider-bridge.mjs` for outbound control-plane communication
73-
with Studio. Studio hosts the browser UI and proxies SimDeck REST requests over
74-
that bridge while WebRTC media negotiates directly between the browser and
75-
runner through ICE. If WebRTC fails before rendering a frame, the browser can
76-
fall back to H.264 over WebSocket while keeping input on a separate WebSocket
77-
channel.
78-
79-
Expose a local simulator through SimDeck Studio with one command:
80-
81-
```sh
82-
simdeck studio expose "iPhone 17 Pro"
83-
```
84-
85-
The command starts or reuses the local daemon, creates an ephemeral Studio
86-
session, prints a unique `https://simdeck.djdev.me/simulator/...` URL, and keeps
87-
the outbound bridge alive until you press Ctrl-C.
88-
8972
CLI commands automatically use the same warm daemon:
9073

9174
```sh
@@ -138,6 +121,7 @@ simdeck boot <udid>
138121
simdeck shutdown <udid>
139122
simdeck erase <udid>
140123
simdeck install <udid> /path/to/App.app
124+
simdeck install android:<avd-name> /path/to/app.apk
141125
simdeck uninstall <udid> com.example.App
142126
simdeck open-url <udid> https://example.com
143127
simdeck launch <udid> com.apple.Preferences
@@ -180,6 +164,14 @@ without launching Simulator.app. If that private path is unavailable, the
180164
command returns the CoreSimulator error instead of falling back to
181165
`xcrun simctl boot`.
182166

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

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);

0 commit comments

Comments
 (0)