Skip to content

Commit 11eb019

Browse files
committed
fix: restore reliable multi-touch streaming
1 parent 7b5267e commit 11eb019

15 files changed

Lines changed: 280 additions & 229 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ The current repo uses the private boot path, private display bridge, and private
8181
CoreSimulator service contexts resolve the active developer directory from `DEVELOPER_DIR`, then `xcode-select -p`, then `/Applications/Xcode.app/Contents/Developer`. The display bridge prefers direct CoreSimulator screen IOSurface callbacks and activates the SimulatorKit offscreen renderable view only if direct callbacks are unavailable.
8282
Accessibility recovery may use simulator launchctl UIKit application state plus hit-tested translations to recover candidate foreground pids; the returned tree must still be rooted at tokenized `AXPTranslator` application objects, because `translationApplicationObjectForPid:` can omit the bridge delegate token after private display lifecycle changes. Full-tree snapshots merge those recovered roots with the private frontmost application translation. When multiple candidate application roots are discovered, serialize all of them in preferred order: non-extension app roots first, then largest translated roots, with `.appex`/PlugIns processes de-prioritized so SpringBoard and Safari app roots stay primary while widgets and WebContent roots remain debuggable. Widget renderer extension roots may report local frames; normalize those roots and children against matching SpringBoard widget placeholder frames before returning the snapshot.
8383
Physical chrome button support uses DeviceKit `chrome.json` input geometry for browser hit targets. Volume, action, mute, Apple Watch digital crown, Watch side button, and Watch left-side button dispatch through `IndigoHIDMessageForHIDArbitrary` with consumer/telephony/vendor HID usage pairs from the device chrome metadata; home, lock, and app-switcher remain on the existing SimulatorKit button paths. Apple Watch Digital Crown rotation dispatches through `IndigoHIDMessageForDigitalCrownEvent` when SimulatorKit exposes it, with `IndigoHIDMessageForScrollEvent(..., target=0x34)` as the fallback. tvOS simulators do not support direct screen touch; browser/API tap maps to Enter, swipe maps to arrow keys, and the native bridge rejects tvOS touch packets before they reach guest `SimulatorHID`. watchOS/tvOS skip dynamic pointer/mouse service warm-up because those guest runtimes abort on unsupported virtual services. Apple TV and Apple Watch simulators are fixed-orientation devices, so client and server rotation paths must not expose or dispatch device rotation for those families.
84-
Two-point multi-touch dispatch prefers the current SimulatorKit/Indigo packet constructor and falls back to SimDeck's manual Indigo packet adapter. The fallback must preserve `began`/`moved`/`ended` mouse event types instead of collapsing movement into down/up packets, because touch recognizers in guest apps otherwise see pinches as ordinary single-finger swipes.
84+
Two-point multi-touch dispatch prefers the current SimulatorKit/Indigo packet constructor and falls back to SimDeck's manual Indigo packet adapter. On Xcode 26 SimulatorKit, the constructor expects pixel-space points and stable two-finger movement requires sending `LeftMouseDown` for both `began` and `moved`, then `LeftMouseUp` for `ended`/`cancelled`; using `LeftMouseDragged` for multi-touch moves only advances one contact in UIKit. Do not coalesce multi-touch move packets in the WebSocket or WebRTC control paths, because gesture recognizers need the intermediate two-contact samples.
8585
WebKit inspection uses the simulator `webinspectord` Unix socket named `com.apple.webinspectord_sim.socket` and WebKit's binary-plist Remote Inspector selectors. It lists only WebKit content that the runtime exposes as inspectable. For app-owned `WKWebView` on iOS 16.4 and newer, the app must set `isInspectable = true`.
8686

8787
## Build and Run

cli/DFPrivateSimulatorDisplayBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ NS_SWIFT_NAME(PrivateSimulatorDisplayBridge)
4848
@property (nonatomic, readonly) CGSize displaySize;
4949
@property (nonatomic, readonly) NSInteger rotationQuarterTurns;
5050

51+
- (void)updateInputDisplaySize:(CGSize)displaySize;
52+
5153
- (nullable CVPixelBufferRef)copyPixelBuffer CF_RETURNS_RETAINED;
5254

5355
- (BOOL)sendTouchAtNormalizedX:(double)normalizedX

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,17 @@ static uint32_t DFIndigoMouseEventTypeForPhase(DFPrivateSimulatorTouchPhase phas
12281228
}
12291229
}
12301230

1231+
static uint32_t DFIndigoMultiTouchMouseEventTypeForPhase(DFPrivateSimulatorTouchPhase phase) {
1232+
switch (phase) {
1233+
case DFPrivateSimulatorTouchPhaseBegan:
1234+
case DFPrivateSimulatorTouchPhaseMoved:
1235+
return DFIndigoMouseEventDown;
1236+
case DFPrivateSimulatorTouchPhaseEnded:
1237+
case DFPrivateSimulatorTouchPhaseCancelled:
1238+
return DFIndigoMouseEventUp;
1239+
}
1240+
}
1241+
12311242
static IndigoHIDMessage *DFCreateIndigoTouchMessageDirect(CGPoint normalizedPoint,
12321243
NSSize displaySize,
12331244
DFPrivateSimulatorTouchPhase phase,
@@ -1238,11 +1249,11 @@ static uint32_t DFIndigoMouseEventTypeForPhase(DFPrivateSimulatorTouchPhase phas
12381249
return NULL;
12391250
}
12401251

1241-
CGPoint ratioPoint = CGPointMake(
1242-
fmax(0.0, fmin(1.0, normalizedPoint.x)),
1243-
fmax(0.0, fmin(1.0, normalizedPoint.y))
1252+
CGPoint pixelPoint = CGPointMake(
1253+
fmax(0.0, fmin(1.0, normalizedPoint.x)) * displaySize.width,
1254+
fmax(0.0, fmin(1.0, normalizedPoint.y)) * displaySize.height
12441255
);
1245-
return mouseMessage(&ratioPoint,
1256+
return mouseMessage(&pixelPoint,
12461257
NULL,
12471258
target,
12481259
(NSEventType)DFIndigoMouseEventTypeForPhase(phase),
@@ -1260,18 +1271,18 @@ static uint32_t DFIndigoMouseEventTypeForPhase(DFPrivateSimulatorTouchPhase phas
12601271
return NULL;
12611272
}
12621273

1263-
CGPoint ratioPoint = CGPointMake(
1264-
fmax(0.0, fmin(1.0, normalizedPoint1.x)),
1265-
fmax(0.0, fmin(1.0, normalizedPoint1.y))
1274+
CGPoint pixelPoint = CGPointMake(
1275+
fmax(0.0, fmin(1.0, normalizedPoint1.x)) * displaySize.width,
1276+
fmax(0.0, fmin(1.0, normalizedPoint1.y)) * displaySize.height
12661277
);
1267-
CGPoint secondRatioPoint = CGPointMake(
1268-
fmax(0.0, fmin(1.0, normalizedPoint2.x)),
1269-
fmax(0.0, fmin(1.0, normalizedPoint2.y))
1278+
CGPoint secondPixelPoint = CGPointMake(
1279+
fmax(0.0, fmin(1.0, normalizedPoint2.x)) * displaySize.width,
1280+
fmax(0.0, fmin(1.0, normalizedPoint2.y)) * displaySize.height
12701281
);
1271-
return mouseMessage(&ratioPoint,
1272-
&secondRatioPoint,
1282+
return mouseMessage(&pixelPoint,
1283+
&secondPixelPoint,
12731284
target,
1274-
(NSEventType)DFIndigoMouseEventTypeForPhase(phase),
1285+
(NSEventType)DFIndigoMultiTouchMouseEventTypeForPhase(phase),
12751286
displaySize,
12761287
DFIndigoHIDEdgeNone);
12771288
}
@@ -1449,7 +1460,7 @@ static id DFCreateLegacyHIDClientForDevice(id device, NSError **error) {
14491460
return NULL;
14501461
}
14511462

1452-
NSEventType eventType = (NSEventType)DFIndigoMouseEventTypeForPhase(phase);
1463+
NSEventType eventType = (NSEventType)DFIndigoMultiTouchMouseEventTypeForPhase(phase);
14531464
CGPoint ratioPoint = CGPointMake(
14541465
fmax(0.0, fmin(1.0, normalizedPoint1.x)),
14551466
fmax(0.0, fmin(1.0, normalizedPoint1.y))
@@ -3845,6 +3856,25 @@ - (NSInteger)rotationQuarterTurns {
38453856
return turns;
38463857
}
38473858

3859+
- (void)updateInputDisplaySize:(CGSize)displaySize {
3860+
if (displaySize.width <= 0.0 || displaySize.height <= 0.0 ||
3861+
!isfinite(displaySize.width) || !isfinite(displaySize.height)) {
3862+
return;
3863+
}
3864+
3865+
dispatch_block_t work = ^{
3866+
self->_displayPixelSize = displaySize;
3867+
DFReconcileRotationWithDisplaySize(&self->_deviceRotationDegrees, self->_displayPixelSize);
3868+
DFLog(@"Configured private HID display size %.0fx%.0f", displaySize.width, displaySize.height);
3869+
};
3870+
3871+
if (dispatch_get_specific(DFPrivateSimulatorCallbackQueueKey) != NULL) {
3872+
work();
3873+
} else {
3874+
dispatch_sync(_callbackQueue, work);
3875+
}
3876+
}
3877+
38483878
- (BOOL)sendTouchAtNormalizedX:(double)normalizedX
38493879
normalizedY:(double)normalizedY
38503880
phase:(DFPrivateSimulatorTouchPhase)phase
@@ -4101,7 +4131,9 @@ - (BOOL)sendMultiTouchAtNormalizedX1:(double)normalizedX1
41014131
displaySize,
41024132
phase,
41034133
target);
4134+
NSString *messageSource = @"direct";
41044135
if (message == NULL) {
4136+
messageSource = @"manual";
41054137
const NSUInteger maxAttempts = phase == DFPrivateSimulatorTouchPhaseMoved ? 12 : 3;
41064138
for (NSUInteger attempt = 0; attempt < maxAttempts; attempt++) {
41074139
message = (IndigoHIDMessage *)DFCreateIndigoMultiTouchMessage(CGPointMake(x1, y1),
@@ -4130,7 +4162,7 @@ - (BOOL)sendMultiTouchAtNormalizedX1:(double)normalizedX1
41304162
}
41314163

41324164
if (phase != DFPrivateSimulatorTouchPhaseMoved) {
4133-
DFLog(@"Sending %@ Indigo HID multi-touch target=0x%x p1=(%.4f, %.4f) p2=(%.4f, %.4f) within %.0fx%.0f", phaseLabel, target, x1, y1, x2, y2, displaySize.width, displaySize.height);
4165+
DFLog(@"Sending %@ Indigo HID multi-touch (%@) target=0x%x p1=(%.4f, %.4f) p2=(%.4f, %.4f) within %.0fx%.0f", phaseLabel, messageSource, target, x1, y1, x2, y2, displaySize.width, displaySize.height);
41344166
}
41354167
success = YES;
41364168
};

cli/XCWChromeRenderer.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ NS_ASSUME_NONNULL_BEGIN
2020
error:(NSError * _Nullable * _Nullable)error;
2121
+ (nullable NSDictionary<NSString *, id> *)profileForDeviceName:(NSString *)deviceName
2222
error:(NSError * _Nullable * _Nullable)error;
23+
+ (CGSize)displayPixelSizeForDeviceName:(NSString *)deviceName
24+
error:(NSError * _Nullable * _Nullable)error;
2325

2426
@end
2527

cli/XCWChromeRenderer.m

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ @implementation XCWChromeRenderer
5050
return [self profileForChromeInfo:chromeInfo error:error];
5151
}
5252

53+
+ (CGSize)displayPixelSizeForDeviceName:(NSString *)deviceName
54+
error:(NSError * _Nullable __autoreleasing *)error {
55+
NSDictionary *chromeInfo = [self chromeInfoForDeviceName:deviceName error:error];
56+
if (chromeInfo == nil) {
57+
return CGSizeZero;
58+
}
59+
60+
NSDictionary *plist = chromeInfo[@"plist"];
61+
CGFloat width = [self numberValue:plist[@"mainScreenWidth"]];
62+
CGFloat height = [self numberValue:plist[@"mainScreenHeight"]];
63+
if (width <= 0.0 || height <= 0.0) {
64+
if (error != NULL) {
65+
*error = [NSError errorWithDomain:XCWChromeRendererErrorDomain
66+
code:16
67+
userInfo:@{
68+
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"The device profile for %@ did not specify a framebuffer size.", deviceName ?: @""],
69+
}];
70+
}
71+
return CGSizeZero;
72+
}
73+
74+
return CGSizeMake(width, height);
75+
}
76+
5377
+ (nullable NSData *)PNGDataForDeviceName:(NSString *)deviceName
5478
error:(NSError * _Nullable __autoreleasing *)error {
5579
return [self PNGDataForDeviceName:deviceName includeButtons:YES error:error];

cli/native/XCWNativeBridge.m

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,16 @@ static BOOL XCWTouchPhaseFromString(NSString *phase, DFPrivateSimulatorTouchPhas
648648
error:&error];
649649
if (bridge == nil) {
650650
XCWSetErrorMessage(errorMessage, error);
651+
return nil;
652+
}
653+
654+
NSDictionary *simulator = XCWSimulatorRecordForUDID(udid, NULL);
655+
NSString *deviceName = simulator[@"deviceTypeName"] ?: simulator[@"name"] ?: @"";
656+
NSError *displaySizeError = nil;
657+
CGSize displaySize = [XCWChromeRenderer displayPixelSizeForDeviceName:deviceName
658+
error:&displaySizeError];
659+
if (displaySize.width > 0.0 && displaySize.height > 0.0) {
660+
[bridge updateInputDisplaySize:displaySize];
651661
}
652662
return bridge;
653663
}

client/src/app/AppShell.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,19 +2050,6 @@ export function AppShell({
20502050
return state;
20512051
}, []);
20522052

2053-
useEffect(() => {
2054-
if (!selectedSimulator?.isBooted) {
2055-
closeControlSocket();
2056-
return;
2057-
}
2058-
ensureControlSocket(selectedSimulator.udid);
2059-
}, [
2060-
closeControlSocket,
2061-
ensureControlSocket,
2062-
selectedSimulator?.isBooted,
2063-
selectedSimulator?.udid,
2064-
]);
2065-
20662053
function sendControl(udid: string, message: ControlMessage): boolean {
20672054
if (isMoveControlMessage(message)) {
20682055
pendingControlMoveRef.current = { message, udid };

client/src/app/controlMessages.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
33
import { isMoveControlMessage } from "./controlMessages";
44

55
describe("controlMessages", () => {
6-
it("marks all continuous touch moves as coalescible", () => {
6+
it("marks single-touch moves as coalescible", () => {
77
expect(
88
isMoveControlMessage({ type: "touch", x: 0.4, y: 0.5, phase: "moved" }),
99
).toBe(true);
@@ -16,6 +16,12 @@ describe("controlMessages", () => {
1616
edge: "bottom",
1717
}),
1818
).toBe(true);
19+
});
20+
21+
it("does not coalesce multi-touch, gesture boundaries, or discrete controls", () => {
22+
expect(
23+
isMoveControlMessage({ type: "touch", x: 0.4, y: 0.5, phase: "began" }),
24+
).toBe(false);
1925
expect(
2026
isMoveControlMessage({
2127
type: "multiTouch",
@@ -25,12 +31,6 @@ describe("controlMessages", () => {
2531
y2: 0.5,
2632
phase: "moved",
2733
}),
28-
).toBe(true);
29-
});
30-
31-
it("does not coalesce gesture boundaries or discrete controls", () => {
32-
expect(
33-
isMoveControlMessage({ type: "touch", x: 0.4, y: 0.5, phase: "began" }),
3434
).toBe(false);
3535
expect(
3636
isMoveControlMessage({

client/src/app/controlMessages.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import type { ControlMessage } from "../api/controls";
22

33
export function isMoveControlMessage(message: ControlMessage): boolean {
44
return (
5-
(message.type === "touch" ||
6-
message.type === "edgeTouch" ||
7-
message.type === "multiTouch") &&
5+
(message.type === "touch" || message.type === "edgeTouch") &&
86
message.phase === "moved"
97
);
108
}

client/src/features/input/usePointerInput.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type ActiveGesture =
4343
};
4444

4545
const TWO_FINGER_SPREAD = 0.16;
46+
const PINCH_MINIMUM_SPREAD = 0.16;
4647
const BOTTOM_EDGE_GESTURE_START_Y = 0.93;
4748

4849
export function usePointerInput({
@@ -78,8 +79,25 @@ export function usePointerInput({
7879
};
7980
}
8081

81-
function mirrorAroundCenter(point: Point): Point {
82-
return clampPoint({ x: 1 - point.x, y: 1 - point.y });
82+
function pinchPointsAroundCenter(
83+
anchor: Point,
84+
previousFirst?: Point,
85+
): [Point, Point] {
86+
let dx = anchor.x - 0.5;
87+
let dy = anchor.y - 0.5;
88+
const halfMinimumSpread = PINCH_MINIMUM_SPREAD / 2;
89+
const distance = Math.hypot(dx, dy);
90+
if (distance < halfMinimumSpread) {
91+
const fallbackDx = previousFirst ? previousFirst.x - 0.5 : 1;
92+
const fallbackDy = previousFirst ? previousFirst.y - 0.5 : 0;
93+
const fallbackDistance = Math.hypot(fallbackDx, fallbackDy) || 1;
94+
dx = (fallbackDx / fallbackDistance) * halfMinimumSpread;
95+
dy = (fallbackDy / fallbackDistance) * halfMinimumSpread;
96+
}
97+
return [
98+
clampPoint({ x: 0.5 + dx, y: 0.5 + dy }),
99+
clampPoint({ x: 0.5 - dx, y: 0.5 - dy }),
100+
];
83101
}
84102

85103
function previewMultiTouch(phase: TouchPhase, first: Point, second: Point) {
@@ -193,8 +211,7 @@ export function usePointerInput({
193211
return;
194212
}
195213

196-
const first = clampPoint(coords);
197-
const second = mirrorAroundCenter(first);
214+
const [first, second] = pinchPointsAroundCenter(coords);
198215
activeGestureRef.current = {
199216
kind: "pinch",
200217
pointerId: event.pointerId,
@@ -226,8 +243,7 @@ export function usePointerInput({
226243
}
227244

228245
if (active.kind === "pinch") {
229-
const first = clampPoint(coords);
230-
const second = mirrorAroundCenter(first);
246+
const [first, second] = pinchPointsAroundCenter(coords, active.first);
231247
activeGestureRef.current = { ...active, first, second };
232248
sendMultiTouch("moved", first, second);
233249
return;
@@ -280,8 +296,9 @@ export function usePointerInput({
280296
);
281297

282298
if (active.kind === "pinch") {
283-
const first = coords ? clampPoint(coords) : active.first;
284-
const second = coords ? mirrorAroundCenter(first) : active.second;
299+
const [first, second] = coords
300+
? pinchPointsAroundCenter(coords, active.first)
301+
: [active.first, active.second];
285302
sendMultiTouch(phase, first, second);
286303
return;
287304
}

0 commit comments

Comments
 (0)