Skip to content

Commit bf90be3

Browse files
committed
fix: disable unsafe native scroll packets
1 parent e640c8f commit bf90be3

7 files changed

Lines changed: 185 additions & 86 deletions

File tree

docs/api/rest.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,11 @@ For normal clients, copy the browser behavior instead of hand-coding a raw decod
176176

177177
The input/control WebSocket accepts JSON control messages with camelCase fields,
178178
including `touch`, `edgeTouch`, `multiTouch`, `key`, `button`, `crown`,
179-
`scroll`, `home`, `appSwitcher`, and rotation controls. Native iOS scroll wheel
180-
input uses `{ "type": "scroll", "deltaX": 0, "deltaY": 24, "x": 0.5, "y": 0.5 }`,
181-
where `x` and `y` are optional normalized screen coordinates from `0.0` to
182-
`1.0`. Touch-like messages use normalized screen coordinates too.
179+
`home`, `appSwitcher`, and rotation controls. Use short `touch` drag sequences
180+
for simulator scrolling. The legacy raw `scroll` control is parsed for
181+
compatibility but rejected because SimulatorKit scroll packets can destabilize
182+
iOS runtimes. Touch-like messages use normalized screen coordinates from `0.0`
183+
to `1.0`.
183184

184185
Minimal WebRTC request:
185186

packages/client/src/app/AppShell.tsx

Lines changed: 167 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,10 @@ const SAFARI_SCREEN_GESTURE_INITIAL_SPREAD = 0.28;
132132
const SAFARI_SCREEN_GESTURE_MIN_SCALE = 0.25;
133133
const SAFARI_SCREEN_GESTURE_MAX_SCALE = 4;
134134
const REAL_MULTITOUCH_GESTURE_GRACE_MS = 120;
135-
const NATIVE_SCROLL_DELTA_SCALE = 4;
136-
const NATIVE_SCROLL_MAX_DELTA = 180;
135+
const WHEEL_TOUCH_SCROLL_DELTA_SCALE = 0.0012;
136+
const WHEEL_TOUCH_SCROLL_MAX_STEP = 0.18;
137+
const WHEEL_TOUCH_SCROLL_EDGE_INSET = 0.08;
138+
const WHEEL_TOUCH_SCROLL_IDLE_MS = 90;
137139
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
138140
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
139141
const FLUTTER_INSPECTOR_MAX_DEPTH = 48;
@@ -176,6 +178,11 @@ interface StreamQualityResponse {
176178
videoCodec?: string;
177179
}
178180

181+
interface WheelTouchScrollState {
182+
udid: string;
183+
current: Point;
184+
}
185+
179186
function buildChromeUrl(
180187
udid: string,
181188
stamp: string,
@@ -613,6 +620,8 @@ export function AppShell({
613620
const scrollAccumulatorRef = useRef<Point>({ x: 0, y: 0 });
614621
const scrollFrameRef = useRef<number>(0);
615622
const scrollPointRef = useRef<Point>({ x: 0.5, y: 0.5 });
623+
const wheelTouchScrollRef = useRef<WheelTouchScrollState | null>(null);
624+
const wheelTouchScrollEndTimeoutRef = useRef<number>(0);
616625
const effectiveZoomRef = useRef(initialViewportState.zoom ?? 1);
617626
const panRef = useRef<Point>(initialViewportState.pan);
618627
const applyZoomAtClientPointRef = useRef<
@@ -661,6 +670,11 @@ export function AppShell({
661670
window.cancelAnimationFrame(scrollFrameRef.current);
662671
scrollFrameRef.current = 0;
663672
}
673+
if (wheelTouchScrollEndTimeoutRef.current !== 0) {
674+
window.clearTimeout(wheelTouchScrollEndTimeoutRef.current);
675+
wheelTouchScrollEndTimeoutRef.current = 0;
676+
}
677+
endWheelTouchScroll("cancelled");
664678
};
665679
}, []);
666680

@@ -2613,7 +2627,7 @@ export function AppShell({
26132627
);
26142628
setAccessibilitySelectedId("");
26152629
setAccessibilityHoveredId(null);
2616-
sendScrollWheel(deltaX, deltaY, screenPoint.x, screenPoint.y);
2630+
sendWheelTouchScroll(deltaX, deltaY, screenPoint.x, screenPoint.y);
26172631
return true;
26182632
}
26192633

@@ -3110,6 +3124,97 @@ export function AppShell({
31103124
}
31113125
}
31123126

3127+
function clampWheelTouchPoint(point: Point): Point {
3128+
return {
3129+
x: clampNumber(
3130+
point.x,
3131+
WHEEL_TOUCH_SCROLL_EDGE_INSET,
3132+
1 - WHEEL_TOUCH_SCROLL_EDGE_INSET,
3133+
),
3134+
y: clampNumber(
3135+
point.y,
3136+
WHEEL_TOUCH_SCROLL_EDGE_INSET,
3137+
1 - WHEEL_TOUCH_SCROLL_EDGE_INSET,
3138+
),
3139+
};
3140+
}
3141+
3142+
function wheelTouchStartPoint(anchor: Point, step: Point): Point {
3143+
const clampedAnchor = clampWheelTouchPoint(anchor);
3144+
const maxX = 1 - WHEEL_TOUCH_SCROLL_EDGE_INSET;
3145+
const maxY = 1 - WHEEL_TOUCH_SCROLL_EDGE_INSET;
3146+
let x = clampedAnchor.x;
3147+
let y = clampedAnchor.y;
3148+
3149+
if (step.x > 0) {
3150+
x = Math.min(x, maxX - Math.abs(step.x));
3151+
} else if (step.x < 0) {
3152+
x = Math.max(x, WHEEL_TOUCH_SCROLL_EDGE_INSET + Math.abs(step.x));
3153+
}
3154+
3155+
if (step.y > 0) {
3156+
y = Math.min(y, maxY - Math.abs(step.y));
3157+
} else if (step.y < 0) {
3158+
y = Math.max(y, WHEEL_TOUCH_SCROLL_EDGE_INSET + Math.abs(step.y));
3159+
}
3160+
3161+
return clampWheelTouchPoint({ x, y });
3162+
}
3163+
3164+
function wheelDeltaToTouchStep(deltaX: number, deltaY: number): Point {
3165+
return {
3166+
x: clampNumber(
3167+
-deltaX * WHEEL_TOUCH_SCROLL_DELTA_SCALE,
3168+
-WHEEL_TOUCH_SCROLL_MAX_STEP,
3169+
WHEEL_TOUCH_SCROLL_MAX_STEP,
3170+
),
3171+
y: clampNumber(
3172+
-deltaY * WHEEL_TOUCH_SCROLL_DELTA_SCALE,
3173+
-WHEEL_TOUCH_SCROLL_MAX_STEP,
3174+
WHEEL_TOUCH_SCROLL_MAX_STEP,
3175+
),
3176+
};
3177+
}
3178+
3179+
function endWheelTouchScroll(phase: "ended" | "cancelled" = "ended") {
3180+
if (wheelTouchScrollEndTimeoutRef.current !== 0) {
3181+
window.clearTimeout(wheelTouchScrollEndTimeoutRef.current);
3182+
wheelTouchScrollEndTimeoutRef.current = 0;
3183+
}
3184+
3185+
const active = wheelTouchScrollRef.current;
3186+
wheelTouchScrollRef.current = null;
3187+
if (!active) {
3188+
return;
3189+
}
3190+
3191+
sendControl(active.udid, {
3192+
type: "touch",
3193+
...active.current,
3194+
phase,
3195+
});
3196+
}
3197+
3198+
function scheduleWheelTouchScrollEnd() {
3199+
if (wheelTouchScrollEndTimeoutRef.current !== 0) {
3200+
window.clearTimeout(wheelTouchScrollEndTimeoutRef.current);
3201+
}
3202+
wheelTouchScrollEndTimeoutRef.current = window.setTimeout(() => {
3203+
wheelTouchScrollEndTimeoutRef.current = 0;
3204+
endWheelTouchScroll("ended");
3205+
}, WHEEL_TOUCH_SCROLL_IDLE_MS);
3206+
}
3207+
3208+
function beginWheelTouchScroll(udid: string, anchor: Point, step: Point) {
3209+
const start = wheelTouchStartPoint(anchor, step);
3210+
wheelTouchScrollRef.current = { udid, current: start };
3211+
sendControl(udid, {
3212+
type: "touch",
3213+
...start,
3214+
phase: "began",
3215+
});
3216+
}
3217+
31133218
function flushScrollWheel() {
31143219
scrollFrameRef.current = 0;
31153220
const accumulated = scrollAccumulatorRef.current;
@@ -3120,13 +3225,13 @@ export function AppShell({
31203225

31213226
const deltaX = clampNumber(
31223227
accumulated.x,
3123-
-NATIVE_SCROLL_MAX_DELTA,
3124-
NATIVE_SCROLL_MAX_DELTA,
3228+
-WHEEL_TOUCH_SCROLL_MAX_STEP / WHEEL_TOUCH_SCROLL_DELTA_SCALE,
3229+
WHEEL_TOUCH_SCROLL_MAX_STEP / WHEEL_TOUCH_SCROLL_DELTA_SCALE,
31253230
);
31263231
const deltaY = clampNumber(
31273232
accumulated.y,
3128-
-NATIVE_SCROLL_MAX_DELTA,
3129-
NATIVE_SCROLL_MAX_DELTA,
3233+
-WHEEL_TOUCH_SCROLL_MAX_STEP / WHEEL_TOUCH_SCROLL_DELTA_SCALE,
3234+
WHEEL_TOUCH_SCROLL_MAX_STEP / WHEEL_TOUCH_SCROLL_DELTA_SCALE,
31303235
);
31313236
const remainingX = accumulated.x - deltaX;
31323237
const remainingY = accumulated.y - deltaY;
@@ -3135,15 +3240,15 @@ export function AppShell({
31353240
scrollFrameRef.current = window.requestAnimationFrame(flushScrollWheel);
31363241
}
31373242

3138-
sendScrollWheelNow(
3243+
sendWheelTouchScrollNow(
31393244
deltaX,
31403245
deltaY,
31413246
scrollPointRef.current.x,
31423247
scrollPointRef.current.y,
31433248
);
31443249
}
31453250

3146-
function sendScrollWheel(
3251+
function sendWheelTouchScroll(
31473252
deltaX: number,
31483253
deltaY: number,
31493254
x: number,
@@ -3162,15 +3267,15 @@ export function AppShell({
31623267
y: Number.isFinite(y) ? clampNumber(y, 0, 1) : 0.5,
31633268
};
31643269
scrollAccumulatorRef.current = {
3165-
x: scrollAccumulatorRef.current.x + deltaX * NATIVE_SCROLL_DELTA_SCALE,
3166-
y: scrollAccumulatorRef.current.y + deltaY * NATIVE_SCROLL_DELTA_SCALE,
3270+
x: scrollAccumulatorRef.current.x + deltaX,
3271+
y: scrollAccumulatorRef.current.y + deltaY,
31673272
};
31683273
if (scrollFrameRef.current === 0) {
31693274
scrollFrameRef.current = window.requestAnimationFrame(flushScrollWheel);
31703275
}
31713276
}
31723277

3173-
function sendScrollWheelNow(
3278+
function sendWheelTouchScrollNow(
31743279
deltaX: number,
31753280
deltaY: number,
31763281
x: number,
@@ -3186,17 +3291,61 @@ export function AppShell({
31863291
) {
31873292
return;
31883293
}
3294+
3295+
const udid = selectedSimulator.udid;
3296+
const anchor = {
3297+
x: clampNumber(x, 0, 1),
3298+
y: clampNumber(y, 0, 1),
3299+
};
3300+
const step = wheelDeltaToTouchStep(deltaX, deltaY);
3301+
if (step.x === 0 && step.y === 0) {
3302+
return;
3303+
}
3304+
3305+
if (wheelTouchScrollRef.current?.udid !== udid) {
3306+
endWheelTouchScroll("cancelled");
3307+
}
3308+
3309+
if (!wheelTouchScrollRef.current) {
3310+
beginWheelTouchScroll(udid, anchor, step);
3311+
}
3312+
3313+
let active = wheelTouchScrollRef.current;
3314+
if (!active) {
3315+
return;
3316+
}
3317+
3318+
let next = clampWheelTouchPoint({
3319+
x: active.current.x + step.x,
3320+
y: active.current.y + step.y,
3321+
});
31893322
if (
3190-
!sendControl(selectedSimulator.udid, {
3191-
type: "scroll",
3192-
deltaX,
3193-
deltaY,
3194-
x: clampNumber(x, 0, 1),
3195-
y: clampNumber(y, 0, 1),
3323+
Math.hypot(next.x - active.current.x, next.y - active.current.y) < 0.001
3324+
) {
3325+
endWheelTouchScroll("ended");
3326+
beginWheelTouchScroll(udid, anchor, step);
3327+
active = wheelTouchScrollRef.current;
3328+
if (!active) {
3329+
return;
3330+
}
3331+
next = clampWheelTouchPoint({
3332+
x: active.current.x + step.x,
3333+
y: active.current.y + step.y,
3334+
});
3335+
}
3336+
3337+
active.current = next;
3338+
if (
3339+
!sendControl(udid, {
3340+
type: "touch",
3341+
...next,
3342+
phase: "moved",
31963343
})
31973344
) {
31983345
setLocalError("Simulator control stream disconnected.");
3346+
return;
31993347
}
3348+
scheduleWheelTouchScrollEnd();
32003349
}
32013350

32023351
function prepareSimulatorInput() {

packages/server/src/api/routes.rs

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -561,10 +561,14 @@ pub(crate) enum ControlMessage {
561561
delta: f64,
562562
},
563563
Scroll {
564-
delta_x: f64,
565-
delta_y: f64,
566-
x: Option<f64>,
567-
y: Option<f64>,
564+
#[serde(rename = "deltaX")]
565+
_delta_x: f64,
566+
#[serde(rename = "deltaY")]
567+
_delta_y: f64,
568+
#[serde(rename = "x")]
569+
_x: Option<f64>,
570+
#[serde(rename = "y")]
571+
_y: Option<f64>,
568572
},
569573
DismissKeyboard,
570574
ToggleSoftwareKeyboard,
@@ -2805,7 +2809,7 @@ async fn run_android_control_message(
28052809
"Digital Crown rotation is only available for Apple Watch simulators.",
28062810
)),
28072811
ControlMessage::Scroll { .. } => Err(AppError::bad_request(
2808-
"Native scroll wheel input is only available for iOS simulators.",
2812+
"Native scroll wheel input is disabled because SimulatorKit scroll packets can destabilize iOS runtimes. Send touch drag input instead.",
28092813
)),
28102814
ControlMessage::ToggleAppearance => android.toggle_appearance(&udid),
28112815
ControlMessage::Touch { .. }
@@ -3071,17 +3075,9 @@ pub(crate) async fn run_control_message(
30713075
}
30723076
}
30733077
ControlMessage::Crown { delta } => session.rotate_crown(delta),
3074-
ControlMessage::Scroll {
3075-
delta_x,
3076-
delta_y,
3077-
x,
3078-
y,
3079-
} => {
3080-
if !delta_x.is_finite() || !delta_y.is_finite() {
3081-
return Err(AppError::bad_request("Scroll deltas must be finite."));
3082-
}
3083-
session.send_scroll(delta_x, delta_y, x.unwrap_or(0.5), y.unwrap_or(0.5))
3084-
}
3078+
ControlMessage::Scroll { .. } => Err(AppError::bad_request(
3079+
"Native scroll wheel input is disabled because SimulatorKit scroll packets can destabilize iOS runtimes. Send touch drag input instead.",
3080+
)),
30853081
ControlMessage::DismissKeyboard => session.send_key(41, 0),
30863082
ControlMessage::ToggleSoftwareKeyboard => session.press_button("software-keyboard", 0),
30873083
ControlMessage::Home => session.press_home(),

packages/server/src/native/bridge.rs

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,35 +1238,6 @@ impl NativeSession {
12381238
}
12391239
}
12401240

1241-
pub fn send_scroll(
1242-
&self,
1243-
delta_x: f64,
1244-
delta_y: f64,
1245-
normalized_x: f64,
1246-
normalized_y: f64,
1247-
) -> Result<(), AppError> {
1248-
if !delta_x.is_finite() || !delta_y.is_finite() {
1249-
return Err(AppError::bad_request("Scroll deltas must be finite."));
1250-
}
1251-
if !normalized_x.is_finite() || !normalized_y.is_finite() {
1252-
return Err(AppError::bad_request("Scroll coordinates must be finite."));
1253-
}
1254-
unsafe {
1255-
let mut error = ptr::null_mut();
1256-
bool_result(
1257-
ffi::xcw_native_session_send_scroll(
1258-
self.handle,
1259-
delta_x,
1260-
delta_y,
1261-
normalized_x,
1262-
normalized_y,
1263-
&mut error,
1264-
),
1265-
error,
1266-
)
1267-
}
1268-
}
1269-
12701241
pub fn open_app_switcher(&self) -> Result<(), AppError> {
12711242
unsafe {
12721243
let mut error = ptr::null_mut();

packages/server/src/native/ffi.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -321,14 +321,6 @@ unsafe extern "C" {
321321
delta: f64,
322322
error_message: *mut *mut c_char,
323323
) -> bool;
324-
pub fn xcw_native_session_send_scroll(
325-
handle: *mut c_void,
326-
delta_x: f64,
327-
delta_y: f64,
328-
normalized_x: f64,
329-
normalized_y: f64,
330-
error_message: *mut *mut c_char,
331-
) -> bool;
332324
pub fn xcw_native_session_open_app_switcher(
333325
handle: *mut c_void,
334326
error_message: *mut *mut c_char,

0 commit comments

Comments
 (0)