Skip to content

Commit 707312c

Browse files
authored
fix: handle Apple Watch and Apple TV input safely
Fix Apple TV web-client input by routing WebRTC control messages through the tvOS tap/swipe-to-remote-key translator, and keep fixed-orientation devices from being rotated in the web viewport.
1 parent 12e8cdc commit 707312c

12 files changed

Lines changed: 1001 additions & 250 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Private simulator behavior is implemented locally in:
8080
The current repo uses the private boot path, private display bridge, and private accessibility translation bridge directly. The browser streams frames from that bridge, injects touch and keyboard events through the same native session layer, inspects accessibility through `AccessibilityPlatformTranslation`, and renders device chrome from `cli/XCWChromeRenderer.*`.
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.
83-
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 `IndigoHIDMessageForScrollEvent` with the same digitizer target as touch input.
83+
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.
8484
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`.
8585

8686
## Build and Run

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 312 additions & 163 deletions
Large diffs are not rendered by default.

client/src/app/AppShell.tsx

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { useKeyboardInput } from "../features/input/useKeyboardInput";
5555
import { usePointerInput } from "../features/input/usePointerInput";
5656
import {
5757
shouldRenderNativeChrome,
58+
simulatorHasFixedOrientation,
5859
simulatorRuntimeLabel,
5960
} from "../features/simulators/simulatorDisplay";
6061
import { useSimulatorList } from "../features/simulators/useSimulatorList";
@@ -828,6 +829,12 @@ export function AppShell({
828829
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
829830
const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null;
830831
const isAndroidViewport = isAndroidSimulator(selectedSimulator);
832+
const selectedHasFixedOrientation =
833+
selectedSimulator != null &&
834+
simulatorHasFixedOrientation(selectedSimulator);
835+
const viewportRotationQuarterTurns = selectedHasFixedOrientation
836+
? 0
837+
: rotationQuarterTurns;
831838
const androidDisplayKey =
832839
isAndroidViewport && selectedSimulator
833840
? androidDisplayKeyForSimulator(selectedSimulator)
@@ -871,7 +878,7 @@ export function AppShell({
871878
chromeProfile: viewportChromeProfile,
872879
deviceNaturalSize: effectiveDeviceNaturalSize,
873880
pan,
874-
rotationQuarterTurns,
881+
rotationQuarterTurns: viewportRotationQuarterTurns,
875882
reservedBottomInset: zoomDockReservedHeight,
876883
viewMode,
877884
zoom,
@@ -1042,13 +1049,22 @@ export function AppShell({
10421049
...(current.viewportByUDID ?? {}),
10431050
[selectedSimulator.udid]: {
10441051
pan,
1045-
rotationQuarterTurns,
1052+
rotationQuarterTurns: selectedHasFixedOrientation
1053+
? 0
1054+
: rotationQuarterTurns,
10461055
viewMode,
10471056
zoom,
10481057
},
10491058
},
10501059
}));
1051-
}, [pan, rotationQuarterTurns, selectedSimulator?.udid, viewMode, zoom]);
1060+
}, [
1061+
pan,
1062+
rotationQuarterTurns,
1063+
selectedHasFixedOrientation,
1064+
selectedSimulator?.udid,
1065+
viewMode,
1066+
zoom,
1067+
]);
10521068

10531069
useEffect(() => {
10541070
if (!selectedSimulator) {
@@ -1124,7 +1140,11 @@ export function AppShell({
11241140
}
11251141
: nextViewportState.pan,
11261142
);
1127-
setRotationQuarterTurns(nextViewportState.rotationQuarterTurns);
1143+
setRotationQuarterTurns(
1144+
simulatorHasFixedOrientation(selectedSimulator)
1145+
? 0
1146+
: nextViewportState.rotationQuarterTurns,
1147+
);
11281148
setLocalError("");
11291149
setAccessibilityRoots([]);
11301150
setAccessibilitySelectedId(
@@ -1305,7 +1325,7 @@ export function AppShell({
13051325
}, [isBooted]);
13061326

13071327
useEffect(() => {
1308-
if (isAndroidViewport) {
1328+
if (isAndroidViewport || selectedHasFixedOrientation) {
13091329
setRotationQuarterTurns((current) =>
13101330
normalizeQuarterTurns(current) === 0 ? current : 0,
13111331
);
@@ -1322,7 +1342,11 @@ export function AppShell({
13221342
beginZoomAnimation();
13231343
return simulatorRotationQuarterTurns;
13241344
});
1325-
}, [isAndroidViewport, simulatorRotationQuarterTurns]);
1345+
}, [
1346+
isAndroidViewport,
1347+
selectedHasFixedOrientation,
1348+
simulatorRotationQuarterTurns,
1349+
]);
13261350

13271351
useEffect(() => {
13281352
if (!isAndroidViewport || !selectedSimulator?.isBooted) {
@@ -1523,7 +1547,7 @@ export function AppShell({
15231547
canvasSize,
15241548
effectiveDeviceNaturalSize,
15251549
viewportChromeProfile,
1526-
rotationQuarterTurns,
1550+
viewportRotationQuarterTurns,
15271551
viewMode === "manual" ? zoomDockReservedHeight : 0,
15281552
);
15291553
return nextPan.x === currentPan.x && nextPan.y === currentPan.y
@@ -1534,7 +1558,7 @@ export function AppShell({
15341558
canvasSize,
15351559
effectiveDeviceNaturalSize,
15361560
effectiveZoom,
1537-
rotationQuarterTurns,
1561+
viewportRotationQuarterTurns,
15381562
viewportChromeProfile,
15391563
viewMode,
15401564
zoomDockReservedHeight,
@@ -1623,7 +1647,7 @@ export function AppShell({
16231647
onMultiTouchPreview: showTouchIndicators,
16241648
pan,
16251649
reservedBottomInset: zoomDockReservedHeight,
1626-
rotationQuarterTurns,
1650+
rotationQuarterTurns: viewportRotationQuarterTurns,
16271651
setPan,
16281652
});
16291653

@@ -1729,7 +1753,7 @@ export function AppShell({
17291753
const deviceFrameSize = shellSize(
17301754
effectiveDeviceNaturalSize,
17311755
viewportChromeProfile,
1732-
rotationQuarterTurns,
1756+
viewportRotationQuarterTurns,
17331757
);
17341758
const naturalShellSize = shellSize(
17351759
effectiveDeviceNaturalSize,
@@ -1745,7 +1769,7 @@ export function AppShell({
17451769
transform: buildShellRotationTransform(
17461770
effectiveDeviceNaturalSize,
17471771
viewportChromeProfile,
1748-
rotationQuarterTurns,
1772+
viewportRotationQuarterTurns,
17491773
),
17501774
};
17511775

@@ -2010,7 +2034,7 @@ export function AppShell({
20102034
canvasSize,
20112035
effectiveDeviceNaturalSize,
20122036
viewportChromeProfile,
2013-
rotationQuarterTurns,
2037+
viewportRotationQuarterTurns,
20142038
zoomDockReservedHeight,
20152039
);
20162040
effectiveZoomRef.current = clampedScale;
@@ -2110,7 +2134,7 @@ export function AppShell({
21102134
fitScale,
21112135
pan: currentPan,
21122136
reservedBottomInset: zoomDockReservedHeight,
2113-
rotationQuarterTurns,
2137+
rotationQuarterTurns: viewportRotationQuarterTurns,
21142138
viewMode,
21152139
zoom,
21162140
}).pan,
@@ -2598,6 +2622,9 @@ export function AppShell({
25982622
if (!selectedSimulator) {
25992623
return;
26002624
}
2625+
if (selectedHasFixedOrientation) {
2626+
return;
2627+
}
26012628
const androidViewport = isAndroidSimulator(selectedSimulator);
26022629
beginZoomAnimation();
26032630
if (androidViewport) {
@@ -2784,7 +2811,7 @@ export function AppShell({
27842811
onZoomIn={() => applyZoom(effectiveZoom * ZOOM_STEP)}
27852812
onZoomOut={() => applyZoom(effectiveZoom / ZOOM_STEP)}
27862813
outerCanvasRef={handleOuterCanvasRef}
2787-
rotationQuarterTurns={rotationQuarterTurns}
2814+
rotationQuarterTurns={viewportRotationQuarterTurns}
27882815
screenAspect={screenAspect}
27892816
screenClassName={isAndroidViewport ? "android-screen" : undefined}
27902817
selectedSimulator={selectedSimulator}

client/src/features/simulators/SimulatorMenu.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
StreamQualityPreset,
1313
StreamTransport,
1414
} from "../stream/streamTypes";
15+
import { simulatorHasFixedOrientation } from "./simulatorDisplay";
1516
import { SimulatorRow } from "./SimulatorRow";
1617

1718
interface SimulatorMenuProps {
@@ -116,6 +117,9 @@ export function SimulatorMenu({
116117
)
117118
? []
118119
: [{ label: String(streamConfig.fps), value: streamConfig.fps }];
120+
const canRotateSelectedSimulator =
121+
selectedSimulator != null &&
122+
!simulatorHasFixedOrientation(selectedSimulator);
119123
return (
120124
<div className="menu-wrap" ref={menuRef}>
121125
<button
@@ -331,15 +335,17 @@ export function SimulatorMenu({
331335
>
332336
App Switcher
333337
</button>
334-
<button
335-
className="menu-action mobile-menu-action"
336-
onClick={() => {
337-
onRotateRight();
338-
onCloseMenu();
339-
}}
340-
>
341-
Rotate Right
342-
</button>
338+
{canRotateSelectedSimulator ? (
339+
<button
340+
className="menu-action mobile-menu-action"
341+
onClick={() => {
342+
onRotateRight();
343+
onCloseMenu();
344+
}}
345+
>
346+
Rotate Right
347+
</button>
348+
) : null}
343349
<button
344350
className="menu-action"
345351
onClick={() => {

client/src/features/simulators/simulatorDisplay.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import type { SimulatorMetadata } from "../../api/types";
44
import {
55
shouldRenderNativeChrome,
6+
simulatorHasFixedOrientation,
67
simulatorRuntimeLabel,
78
} from "./simulatorDisplay";
89

@@ -40,7 +41,7 @@ describe("simulatorDisplay", () => {
4041
).toBe(true);
4142
});
4243

43-
it("keeps native chrome off for device families without supported bezels", () => {
44+
it("enables native chrome for Apple TV simulators", () => {
4445
expect(
4546
shouldRenderNativeChrome(
4647
simulator({
@@ -49,7 +50,7 @@ describe("simulatorDisplay", () => {
4950
name: "Apple TV 4K (3rd generation)",
5051
}),
5152
),
52-
).toBe(false);
53+
).toBe(true);
5354
});
5455

5556
it("keeps native chrome off for Android emulators", () => {
@@ -63,4 +64,34 @@ describe("simulatorDisplay", () => {
6364
),
6465
).toBe(false);
6566
});
67+
68+
it("marks Apple TV and Apple Watch simulators as fixed-orientation devices", () => {
69+
expect(
70+
simulatorHasFixedOrientation(
71+
simulator({
72+
deviceTypeIdentifier:
73+
"com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K",
74+
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.tvOS-26-0",
75+
}),
76+
),
77+
).toBe(true);
78+
expect(
79+
simulatorHasFixedOrientation(
80+
simulator({
81+
deviceTypeIdentifier:
82+
"com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Ultra-3-49mm",
83+
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.watchOS-26-0",
84+
}),
85+
),
86+
).toBe(true);
87+
expect(
88+
simulatorHasFixedOrientation(
89+
simulator({
90+
deviceTypeIdentifier:
91+
"com.apple.CoreSimulator.SimDeviceType.iPhone-17",
92+
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0",
93+
}),
94+
),
95+
).toBe(false);
96+
});
6697
});

client/src/features/simulators/simulatorDisplay.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,52 @@ export function simulatorRuntimeLabel(simulator: SimulatorMetadata): string {
1414
export function shouldRenderNativeChrome(
1515
simulator: SimulatorMetadata,
1616
): boolean {
17-
const identifier = simulator.deviceTypeIdentifier ?? "";
18-
const name = simulator.name ?? "";
19-
const deviceTypeName = simulator.deviceTypeName ?? "";
17+
if (simulator.platform === "android-emulator") {
18+
return false;
19+
}
20+
const metadata = simulatorMetadataText(simulator);
2021
return (
21-
identifier.includes(".iPhone-") ||
22-
identifier.includes(".iPad-") ||
23-
identifier.includes(".Apple-Watch-") ||
24-
name.startsWith("iPhone") ||
25-
name.startsWith("iPad") ||
26-
name.startsWith("Apple Watch") ||
27-
deviceTypeName.startsWith("Apple Watch")
22+
metadata.includes("iphone") ||
23+
metadata.includes("ipad") ||
24+
metadata.includes("apple-watch") ||
25+
metadata.includes("apple watch") ||
26+
metadata.includes("apple-tv") ||
27+
metadata.includes("apple tv") ||
28+
metadata.includes("appletv")
2829
);
2930
}
3031

32+
export function simulatorHasFixedOrientation(
33+
simulator: SimulatorMetadata | null,
34+
): boolean {
35+
if (!simulator || simulator.platform === "android-emulator") {
36+
return false;
37+
}
38+
const metadata = simulatorMetadataText(simulator);
39+
return (
40+
metadata.includes("tvos") ||
41+
metadata.includes("watchos") ||
42+
metadata.includes("apple-tv") ||
43+
metadata.includes("apple tv") ||
44+
metadata.includes("appletv") ||
45+
metadata.includes("apple-watch") ||
46+
metadata.includes("apple watch")
47+
);
48+
}
49+
50+
function simulatorMetadataText(simulator: SimulatorMetadata): string {
51+
return [
52+
simulator.name,
53+
simulator.deviceTypeName,
54+
simulator.deviceTypeIdentifier,
55+
simulator.runtimeName,
56+
simulator.runtimeIdentifier,
57+
]
58+
.filter(Boolean)
59+
.join(" ")
60+
.toLowerCase();
61+
}
62+
3163
function formatRuntimeLabel(value: string | undefined): string | null {
3264
const trimmed = value?.trim();
3365
if (!trimmed) {

client/src/features/toolbar/Toolbar.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
StreamQualityPreset,
2020
StreamTransport,
2121
} from "../stream/streamTypes";
22+
import { simulatorHasFixedOrientation } from "../simulators/simulatorDisplay";
2223
import { SimulatorMenu } from "../simulators/SimulatorMenu";
2324

2425
interface ToolbarProps {
@@ -117,6 +118,9 @@ export function Toolbar({
117118
touchOverlayVisible,
118119
}: ToolbarProps) {
119120
const [errorCopied, setErrorCopied] = useState(false);
121+
const canRotateSelectedSimulator =
122+
selectedSimulator != null &&
123+
!simulatorHasFixedOrientation(selectedSimulator);
120124

121125
useEffect(() => {
122126
setErrorCopied(false);
@@ -266,14 +270,16 @@ export function Toolbar({
266270
>
267271
<AppearanceIcon />
268272
</button>
269-
<button
270-
aria-label="Rotate Right"
271-
className="tbtn icon-btn toolbar-mobile-hidden"
272-
onClick={onRotateRight}
273-
title="Rotate Right"
274-
>
275-
<RotateRightIcon />
276-
</button>
273+
{canRotateSelectedSimulator ? (
274+
<button
275+
aria-label="Rotate Right"
276+
className="tbtn icon-btn toolbar-mobile-hidden"
277+
onClick={onRotateRight}
278+
title="Rotate Right"
279+
>
280+
<RotateRightIcon />
281+
</button>
282+
) : null}
277283
</div>
278284
) : null}
279285
{error ? (

0 commit comments

Comments
 (0)