Skip to content

Commit ffd9cbf

Browse files
committed
fix: route tvOS input to remote keys and render Apple TV chrome
1 parent 12e8cdc commit ffd9cbf

11 files changed

Lines changed: 932 additions & 218 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: 286 additions & 147 deletions
Large diffs are not rendered by default.

client/src/app/AppShell.tsx

Lines changed: 7 additions & 0 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,9 @@ 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);
831835
const androidDisplayKey =
832836
isAndroidViewport && selectedSimulator
833837
? androidDisplayKeyForSimulator(selectedSimulator)
@@ -2598,6 +2602,9 @@ export function AppShell({
25982602
if (!selectedSimulator) {
25992603
return;
26002604
}
2605+
if (selectedHasFixedOrientation) {
2606+
return;
2607+
}
26012608
const androidViewport = isAndroidSimulator(selectedSimulator);
26022609
beginZoomAnimation();
26032610
if (androidViewport) {

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)