Skip to content

Commit e0914b7

Browse files
committed
Tighten Android local stream behavior
1 parent 07d3f70 commit e0914b7

8 files changed

Lines changed: 198 additions & 44 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ view inside the editor.
3535

3636
## Features
3737

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
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
4040
- Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI
4141
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
4242
- CoreSimulator chrome asset rendering for device bezels

client/src/features/stream/streamWorkerClient.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildStreamTarget,
55
initialStreamBackend,
66
preferredStreamBackend,
7+
shouldUseLocalAndroidRgbaWebRtc,
78
} from "./streamWorkerClient";
89

910
describe("streamWorkerClient", () => {
@@ -61,4 +62,63 @@ describe("streamWorkerClient", () => {
6162
).RTCPeerConnection = previousPeerConnection;
6263
}
6364
});
65+
66+
it("uses RGBA WebRTC transport for local loopback Android streams", () => {
67+
const previousWindow = globalThis.window;
68+
Object.defineProperty(globalThis, "window", {
69+
configurable: true,
70+
value: { location: { hostname: "127.0.0.1", search: "" } },
71+
});
72+
73+
try {
74+
expect(
75+
shouldUseLocalAndroidRgbaWebRtc(
76+
buildStreamTarget("android:Pixel_8", {
77+
platform: "android-emulator",
78+
transport: "auto",
79+
}),
80+
),
81+
).toBe(true);
82+
} finally {
83+
Object.defineProperty(globalThis, "window", {
84+
configurable: true,
85+
value: previousWindow,
86+
});
87+
}
88+
});
89+
90+
it("keeps Android RGBA disabled for h264 or remote streams", () => {
91+
const previousWindow = globalThis.window;
92+
const location = { hostname: "127.0.0.1", search: "?stream=h264" };
93+
Object.defineProperty(globalThis, "window", {
94+
configurable: true,
95+
value: { location },
96+
});
97+
98+
try {
99+
expect(
100+
shouldUseLocalAndroidRgbaWebRtc(
101+
buildStreamTarget("android:Pixel_8", {
102+
platform: "android-emulator",
103+
transport: "auto",
104+
}),
105+
),
106+
).toBe(false);
107+
location.search = "";
108+
expect(
109+
shouldUseLocalAndroidRgbaWebRtc(
110+
buildStreamTarget("android:Pixel_8", {
111+
platform: "android-emulator",
112+
remote: true,
113+
transport: "auto",
114+
}),
115+
),
116+
).toBe(false);
117+
} finally {
118+
Object.defineProperty(globalThis, "window", {
119+
configurable: true,
120+
value: previousWindow,
121+
});
122+
}
123+
});
64124
});

client/src/features/stream/streamWorkerClient.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2684,12 +2684,20 @@ function shouldUseRemoteH264AutoProfile(
26842684
return Boolean(target?.remote) || !isLoopbackHost(window.location.hostname);
26852685
}
26862686

2687-
function shouldUseLocalAndroidRgbaWebRtc(target: StreamConnectTarget): boolean {
2687+
export function shouldUseLocalAndroidRgbaWebRtc(
2688+
target: StreamConnectTarget,
2689+
): boolean {
26882690
const stream = new URLSearchParams(window.location.search).get("stream");
2691+
if (
2692+
target.transport === "h264" ||
2693+
stream === "h264" ||
2694+
stream === "h264-ws"
2695+
) {
2696+
return false;
2697+
}
26892698
return (
26902699
!target.remote &&
26912700
isLoopbackHost(window.location.hostname) &&
2692-
(stream === "rgba" || stream === "webrtc-rgba") &&
26932701
(target.platform === "android-emulator" ||
26942702
target.udid.startsWith("android:"))
26952703
);

docs/api/rest.md

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,16 +207,10 @@ and the server responds with an SDP answer for a receive-only H.264 video track:
207207
}
208208
```
209209

210-
The endpoint requires the selected device stream to produce H.264-compatible
211-
samples. For iOS, samples come from the native simulator display session. For
212-
Android, the server reads raw frames from the emulator gRPC `streamScreenshot`
213-
API, encodes them through VideoToolbox, and writes the resulting H.264 samples
214-
to the same WebRTC video track.
215-
216-
Loopback Android clients may pass `"transport": "rgba"` in the offer payload.
217-
That keeps the same WebRTC endpoint and control channels, but sends raw RGBA
218-
frames over the `simdeck-rgba` data channel instead of creating a media track.
219-
Non-loopback Android clients use encoded H.264.
210+
For iOS, samples come from the native simulator display session and are sent as
211+
an H.264 media track. Android loopback clients use the same endpoint and control
212+
channels, but receive raw RGBA frames over the `simdeck-rgba` data channel.
213+
Non-loopback Android clients receive VideoToolbox-encoded H.264.
220214

221215
The browser also opens `simdeck-control` and `simdeck-telemetry` data channels.
222216
In addition to input messages, clients can request a keyframe or tune the

docs/extensions/browser-client.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ client/
3939
└── styles/
4040
```
4141

42-
| Folder | Responsibility |
43-
| ------------------------- | ---------------------------------------------------------------------------- |
44-
| `api/` | Typed wrappers around the SimDeck REST API and shared TypeScript types. |
45-
| `features/simulators/` | Sidebar list of simulators plus boot/shutdown affordances. |
46-
| `features/viewport/` | Frame canvas, chrome compositing, hit testing. |
47-
| `features/stream/` | WebRTC H.264 client for iOS and Android, receiver stats, and frame plumbing. |
48-
| `features/input/` | Touch / keyboard / hardware-button affordances. |
49-
| `features/accessibility/` | Accessibility tree pane and source switcher. |
50-
| `features/toolbar/` | Top toolbar (rotate, home, app switcher, dark mode toggle, refresh). |
42+
| Folder | Responsibility |
43+
| ------------------------- | ------------------------------------------------------------------------------------ |
44+
| `api/` | Typed wrappers around the SimDeck REST API and shared TypeScript types. |
45+
| `features/simulators/` | Sidebar list of simulators plus boot/shutdown affordances. |
46+
| `features/viewport/` | Frame canvas, chrome compositing, hit testing. |
47+
| `features/stream/` | WebRTC media/RGBA stream client, H.264 fallback, receiver stats, and frame plumbing. |
48+
| `features/input/` | Touch / keyboard / hardware-button affordances. |
49+
| `features/accessibility/` | Accessibility tree pane and source switcher. |
50+
| `features/toolbar/` | Top toolbar (rotate, home, app switcher, dark mode toggle, refresh). |
5151

5252
## Bootstrap flow
5353

@@ -56,7 +56,7 @@ client/
5656
3. `AppShell` calls `GET /api/health` to learn the active encoder mode.
5757
4. The simulator sidebar fetches `GET /api/simulators` and renders the list.
5858
5. Selecting a device posts an SDP offer to `/api/simulators/<udid>/webrtc/offer`.
59-
6. The browser renders the H.264 video track through native WebRTC playback. Android emulator frames are sourced from emulator gRPC on the server and encoded through VideoToolbox before WebRTC delivery.
59+
6. The browser renders the WebRTC stream. iOS uses an H.264 media track; Android loopback clients use a raw RGBA data channel, while non-loopback Android clients use VideoToolbox-encoded H.264.
6060
7. Touch and key events round-trip through `POST /api/simulators/<udid>/touch` and `/key`.
6161

6262
## Dev workflow

server/src/api/routes.rs

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2963,7 +2963,9 @@ async fn accessibility_point(
29632963
android.accessibility_tree(&udid, None)
29642964
})
29652965
.await?;
2966-
return Ok(json(snapshot));
2966+
return Ok(json(accessibility_point_snapshot(
2967+
&snapshot, query.x, query.y,
2968+
)?));
29672969
}
29682970
let snapshot = accessibility_snapshot(state, udid, Some((query.x, query.y)), None).await?;
29692971
Ok(json(snapshot))
@@ -3683,6 +3685,77 @@ fn normalize_screen_point_from_snapshot(
36833685
Ok(((x / width).clamp(0.0, 1.0), (y / height).clamp(0.0, 1.0)))
36843686
}
36853687

3688+
fn accessibility_point_snapshot(snapshot: &Value, x: f64, y: f64) -> Result<Value, AppError> {
3689+
let roots = snapshot
3690+
.get("roots")
3691+
.and_then(Value::as_array)
3692+
.ok_or_else(|| AppError::not_found("Accessibility snapshot does not contain roots."))?;
3693+
let node = roots
3694+
.iter()
3695+
.rev()
3696+
.find_map(|root| deepest_node_at_point(root, x, y))
3697+
.ok_or_else(|| AppError::not_found("No accessibility element contains the point."))?;
3698+
let mut node = node.clone();
3699+
if let Some(object) = node.as_object_mut() {
3700+
object.remove("children");
3701+
}
3702+
3703+
let mut response = Map::new();
3704+
for key in [
3705+
"source",
3706+
"availableSources",
3707+
"requestedSource",
3708+
"fallbackReason",
3709+
"inspector",
3710+
"includeHidden",
3711+
] {
3712+
if let Some(value) = snapshot.get(key) {
3713+
response.insert(key.to_owned(), value.clone());
3714+
}
3715+
}
3716+
response.insert("roots".to_owned(), Value::Array(vec![node]));
3717+
Ok(Value::Object(response))
3718+
}
3719+
3720+
fn deepest_node_at_point(node: &Value, x: f64, y: f64) -> Option<&Value> {
3721+
let has_frame = node
3722+
.get("frame")
3723+
.or_else(|| node.get("frameInScreen"))
3724+
.is_some();
3725+
if has_frame && !node_frame_contains_point(node, x, y).unwrap_or(false) {
3726+
return None;
3727+
}
3728+
for child in node
3729+
.get("children")
3730+
.and_then(Value::as_array)
3731+
.into_iter()
3732+
.flatten()
3733+
.rev()
3734+
{
3735+
if let Some(found) = deepest_node_at_point(child, x, y) {
3736+
return Some(found);
3737+
}
3738+
}
3739+
has_frame.then_some(node)
3740+
}
3741+
3742+
fn node_frame_contains_point(node: &Value, x: f64, y: f64) -> Result<bool, AppError> {
3743+
let frame = node
3744+
.get("frame")
3745+
.or_else(|| node.get("frameInScreen"))
3746+
.ok_or_else(|| AppError::not_found("Accessibility node does not expose a frame."))?;
3747+
let frame_x = number_field(frame, "x")?;
3748+
let frame_y = number_field(frame, "y")?;
3749+
let width = number_field(frame, "width")?;
3750+
let height = number_field(frame, "height")?;
3751+
Ok(width > 0.0
3752+
&& height > 0.0
3753+
&& x >= frame_x
3754+
&& y >= frame_y
3755+
&& x < frame_x + width
3756+
&& y < frame_y + height)
3757+
}
3758+
36863759
fn number_field(value: &Value, field: &str) -> Result<f64, AppError> {
36873760
value
36883761
.get(field)
@@ -5328,10 +5401,11 @@ async fn simulator_payload(state: AppState, udid: String) -> Result<Json<Value>,
53285401
#[cfg(test)]
53295402
mod tests {
53305403
use super::{
5331-
attach_tree_metadata, available_sources_for_snapshot, best_inspector_session,
5332-
compact_accessibility_snapshot, element_matches_selector, first_matching_element,
5333-
inspector_available_sources, inspector_metadata, inspector_session_from_published,
5334-
inspector_session_score, is_inspector_agent_transport_path, normalize_inspector_node,
5404+
accessibility_point_snapshot, attach_tree_metadata, available_sources_for_snapshot,
5405+
best_inspector_session, compact_accessibility_snapshot, element_matches_selector,
5406+
first_matching_element, inspector_available_sources, inspector_metadata,
5407+
inspector_session_from_published, inspector_session_score,
5408+
is_inspector_agent_transport_path, normalize_inspector_node,
53355409
normalize_screen_point_from_snapshot, normalized_gesture_coordinates,
53365410
parse_lsof_tcp_listener, resolved_stream_quality_limits, split_filter_values,
53375411
stream_quality_profile, suppress_native_ax_translation_error, tap_point_from_snapshot,
@@ -5431,6 +5505,35 @@ mod tests {
54315505
assert_eq!(point, (1.0, 0.0));
54325506
}
54335507

5508+
#[test]
5509+
fn accessibility_point_snapshot_returns_deepest_node() {
5510+
let snapshot = json!({
5511+
"source": "android-uiautomator",
5512+
"availableSources": ["android-uiautomator"],
5513+
"roots": [{
5514+
"type": "FrameLayout",
5515+
"frame": { "x": 0.0, "y": 0.0, "width": 400.0, "height": 800.0 },
5516+
"children": [{
5517+
"type": "ViewGroup",
5518+
"AXIdentifier": "container",
5519+
"frame": { "x": 0.0, "y": 100.0, "width": 400.0, "height": 300.0 },
5520+
"children": [{
5521+
"type": "Button",
5522+
"AXIdentifier": "child-button",
5523+
"frame": { "x": 120.0, "y": 140.0, "width": 80.0, "height": 60.0 },
5524+
"children": []
5525+
}]
5526+
}]
5527+
}]
5528+
});
5529+
5530+
let point = accessibility_point_snapshot(&snapshot, 150.0, 160.0).unwrap();
5531+
5532+
assert_eq!(point["source"], "android-uiautomator");
5533+
assert_eq!(point["roots"][0]["AXIdentifier"], "child-button");
5534+
assert!(point["roots"][0].get("children").is_none());
5535+
}
5536+
54345537
#[test]
54355538
fn gesture_presets_clamp_delta_and_reject_unknown_names() {
54365539
assert_eq!(

server/src/transport/webrtc.rs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ pub async fn create_answer(
164164
state.android.clone(),
165165
state.metrics.clone(),
166166
udid.clone(),
167-
android_webrtc_max_edge(),
167+
None,
168168
true,
169169
)
170170
.await?,
@@ -1360,9 +1360,6 @@ impl AndroidWebRtcSource {
13601360
match frame {
13611361
Ok(Some(frame)) => {
13621362
let frame = Arc::new(frame);
1363-
if let Some(inner) = reader_inner.upgrade() {
1364-
let _ = inner.raw_sender.send(frame.clone());
1365-
}
13661363
*reader_latest_frame.lock().unwrap() = Some(frame);
13671364
}
13681365
Ok(None) => break,
@@ -1679,14 +1676,6 @@ unsafe fn take_native_error(raw: *mut i8) -> Option<AppError> {
16791676
Some(AppError::native(message))
16801677
}
16811678

1682-
fn android_webrtc_max_edge() -> Option<u32> {
1683-
std::env::var("SIMDECK_ANDROID_WEBRTC_MAX_EDGE")
1684-
.ok()
1685-
.and_then(|value| value.parse::<u32>().ok())
1686-
.filter(|value| *value > 0)
1687-
.map(|value| value.clamp(360, 4096))
1688-
}
1689-
16901679
fn android_webrtc_frame_interval() -> Duration {
16911680
Duration::from_micros(1_000_000 / ANDROID_WEBRTC_FPS)
16921681
}

skills/simdeck/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ Build apps with project tooling.
5959

6060
Android devices use IDs like `android:Pixel_8_API_36`. `simdeck list` discovers
6161
AVDs from the Android SDK, `boot` starts `emulator -avd ... -no-window`, and
62-
live browser viewing uses the same WebRTC H.264 endpoint as iOS. Android frames
63-
come from emulator gRPC and are encoded through VideoToolbox. `simdeck stream`
64-
is still iOS-only.
62+
live browser viewing uses WebRTC. Local loopback Android viewing sends raw RGBA
63+
frames over a WebRTC data channel; non-loopback Android viewing uses
64+
VideoToolbox-encoded H.264. `simdeck stream` is still iOS-only.
6565

6666
## Fast Agent Inspection
6767

0 commit comments

Comments
 (0)