Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Private simulator behavior is implemented locally in:
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.*`.
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.
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.
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.
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.
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`.

## Build and Run
Expand Down
475 changes: 312 additions & 163 deletions cli/DFPrivateSimulatorDisplayBridge.m

Large diffs are not rendered by default.

55 changes: 41 additions & 14 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useKeyboardInput } from "../features/input/useKeyboardInput";
import { usePointerInput } from "../features/input/usePointerInput";
import {
shouldRenderNativeChrome,
simulatorHasFixedOrientation,
simulatorRuntimeLabel,
} from "../features/simulators/simulatorDisplay";
import { useSimulatorList } from "../features/simulators/useSimulatorList";
Expand Down Expand Up @@ -828,6 +829,12 @@ export function AppShell({
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null;
const isAndroidViewport = isAndroidSimulator(selectedSimulator);
const selectedHasFixedOrientation =
selectedSimulator != null &&
simulatorHasFixedOrientation(selectedSimulator);
const viewportRotationQuarterTurns = selectedHasFixedOrientation
? 0
: rotationQuarterTurns;
const androidDisplayKey =
isAndroidViewport && selectedSimulator
? androidDisplayKeyForSimulator(selectedSimulator)
Expand Down Expand Up @@ -871,7 +878,7 @@ export function AppShell({
chromeProfile: viewportChromeProfile,
deviceNaturalSize: effectiveDeviceNaturalSize,
pan,
rotationQuarterTurns,
rotationQuarterTurns: viewportRotationQuarterTurns,
reservedBottomInset: zoomDockReservedHeight,
viewMode,
zoom,
Expand Down Expand Up @@ -1042,13 +1049,22 @@ export function AppShell({
...(current.viewportByUDID ?? {}),
[selectedSimulator.udid]: {
pan,
rotationQuarterTurns,
rotationQuarterTurns: selectedHasFixedOrientation
? 0
: rotationQuarterTurns,
viewMode,
zoom,
},
},
}));
}, [pan, rotationQuarterTurns, selectedSimulator?.udid, viewMode, zoom]);
}, [
pan,
rotationQuarterTurns,
selectedHasFixedOrientation,
selectedSimulator?.udid,
viewMode,
zoom,
]);

useEffect(() => {
if (!selectedSimulator) {
Expand Down Expand Up @@ -1124,7 +1140,11 @@ export function AppShell({
}
: nextViewportState.pan,
);
setRotationQuarterTurns(nextViewportState.rotationQuarterTurns);
setRotationQuarterTurns(
simulatorHasFixedOrientation(selectedSimulator)
? 0
: nextViewportState.rotationQuarterTurns,
);
setLocalError("");
setAccessibilityRoots([]);
setAccessibilitySelectedId(
Expand Down Expand Up @@ -1305,7 +1325,7 @@ export function AppShell({
}, [isBooted]);

useEffect(() => {
if (isAndroidViewport) {
if (isAndroidViewport || selectedHasFixedOrientation) {
setRotationQuarterTurns((current) =>
normalizeQuarterTurns(current) === 0 ? current : 0,
);
Expand All @@ -1322,7 +1342,11 @@ export function AppShell({
beginZoomAnimation();
return simulatorRotationQuarterTurns;
});
}, [isAndroidViewport, simulatorRotationQuarterTurns]);
}, [
isAndroidViewport,
selectedHasFixedOrientation,
simulatorRotationQuarterTurns,
]);

useEffect(() => {
if (!isAndroidViewport || !selectedSimulator?.isBooted) {
Expand Down Expand Up @@ -1523,7 +1547,7 @@ export function AppShell({
canvasSize,
effectiveDeviceNaturalSize,
viewportChromeProfile,
rotationQuarterTurns,
viewportRotationQuarterTurns,
viewMode === "manual" ? zoomDockReservedHeight : 0,
);
return nextPan.x === currentPan.x && nextPan.y === currentPan.y
Expand All @@ -1534,7 +1558,7 @@ export function AppShell({
canvasSize,
effectiveDeviceNaturalSize,
effectiveZoom,
rotationQuarterTurns,
viewportRotationQuarterTurns,
viewportChromeProfile,
viewMode,
zoomDockReservedHeight,
Expand Down Expand Up @@ -1623,7 +1647,7 @@ export function AppShell({
onMultiTouchPreview: showTouchIndicators,
pan,
reservedBottomInset: zoomDockReservedHeight,
rotationQuarterTurns,
rotationQuarterTurns: viewportRotationQuarterTurns,
setPan,
});

Expand Down Expand Up @@ -1729,7 +1753,7 @@ export function AppShell({
const deviceFrameSize = shellSize(
effectiveDeviceNaturalSize,
viewportChromeProfile,
rotationQuarterTurns,
viewportRotationQuarterTurns,
);
const naturalShellSize = shellSize(
effectiveDeviceNaturalSize,
Expand All @@ -1745,7 +1769,7 @@ export function AppShell({
transform: buildShellRotationTransform(
effectiveDeviceNaturalSize,
viewportChromeProfile,
rotationQuarterTurns,
viewportRotationQuarterTurns,
),
};

Expand Down Expand Up @@ -2010,7 +2034,7 @@ export function AppShell({
canvasSize,
effectiveDeviceNaturalSize,
viewportChromeProfile,
rotationQuarterTurns,
viewportRotationQuarterTurns,
zoomDockReservedHeight,
);
effectiveZoomRef.current = clampedScale;
Expand Down Expand Up @@ -2110,7 +2134,7 @@ export function AppShell({
fitScale,
pan: currentPan,
reservedBottomInset: zoomDockReservedHeight,
rotationQuarterTurns,
rotationQuarterTurns: viewportRotationQuarterTurns,
viewMode,
zoom,
}).pan,
Expand Down Expand Up @@ -2598,6 +2622,9 @@ export function AppShell({
if (!selectedSimulator) {
return;
}
if (selectedHasFixedOrientation) {
return;
}
const androidViewport = isAndroidSimulator(selectedSimulator);
beginZoomAnimation();
if (androidViewport) {
Expand Down Expand Up @@ -2784,7 +2811,7 @@ export function AppShell({
onZoomIn={() => applyZoom(effectiveZoom * ZOOM_STEP)}
onZoomOut={() => applyZoom(effectiveZoom / ZOOM_STEP)}
outerCanvasRef={handleOuterCanvasRef}
rotationQuarterTurns={rotationQuarterTurns}
rotationQuarterTurns={viewportRotationQuarterTurns}
screenAspect={screenAspect}
screenClassName={isAndroidViewport ? "android-screen" : undefined}
selectedSimulator={selectedSimulator}
Expand Down
24 changes: 15 additions & 9 deletions client/src/features/simulators/SimulatorMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
StreamQualityPreset,
StreamTransport,
} from "../stream/streamTypes";
import { simulatorHasFixedOrientation } from "./simulatorDisplay";
import { SimulatorRow } from "./SimulatorRow";

interface SimulatorMenuProps {
Expand Down Expand Up @@ -116,6 +117,9 @@ export function SimulatorMenu({
)
? []
: [{ label: String(streamConfig.fps), value: streamConfig.fps }];
const canRotateSelectedSimulator =
selectedSimulator != null &&
!simulatorHasFixedOrientation(selectedSimulator);
return (
<div className="menu-wrap" ref={menuRef}>
<button
Expand Down Expand Up @@ -331,15 +335,17 @@ export function SimulatorMenu({
>
App Switcher
</button>
<button
className="menu-action mobile-menu-action"
onClick={() => {
onRotateRight();
onCloseMenu();
}}
>
Rotate Right
</button>
{canRotateSelectedSimulator ? (
<button
className="menu-action mobile-menu-action"
onClick={() => {
onRotateRight();
onCloseMenu();
}}
>
Rotate Right
</button>
) : null}
<button
className="menu-action"
onClick={() => {
Expand Down
35 changes: 33 additions & 2 deletions client/src/features/simulators/simulatorDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { SimulatorMetadata } from "../../api/types";
import {
shouldRenderNativeChrome,
simulatorHasFixedOrientation,
simulatorRuntimeLabel,
} from "./simulatorDisplay";

Expand Down Expand Up @@ -40,7 +41,7 @@ describe("simulatorDisplay", () => {
).toBe(true);
});

it("keeps native chrome off for device families without supported bezels", () => {
it("enables native chrome for Apple TV simulators", () => {
expect(
shouldRenderNativeChrome(
simulator({
Expand All @@ -49,7 +50,7 @@ describe("simulatorDisplay", () => {
name: "Apple TV 4K (3rd generation)",
}),
),
).toBe(false);
).toBe(true);
});

it("keeps native chrome off for Android emulators", () => {
Expand All @@ -63,4 +64,34 @@ describe("simulatorDisplay", () => {
),
).toBe(false);
});

it("marks Apple TV and Apple Watch simulators as fixed-orientation devices", () => {
expect(
simulatorHasFixedOrientation(
simulator({
deviceTypeIdentifier:
"com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K",
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.tvOS-26-0",
}),
),
).toBe(true);
expect(
simulatorHasFixedOrientation(
simulator({
deviceTypeIdentifier:
"com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Ultra-3-49mm",
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.watchOS-26-0",
}),
),
).toBe(true);
expect(
simulatorHasFixedOrientation(
simulator({
deviceTypeIdentifier:
"com.apple.CoreSimulator.SimDeviceType.iPhone-17",
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0",
}),
),
).toBe(false);
});
});
52 changes: 42 additions & 10 deletions client/src/features/simulators/simulatorDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,52 @@ export function simulatorRuntimeLabel(simulator: SimulatorMetadata): string {
export function shouldRenderNativeChrome(
simulator: SimulatorMetadata,
): boolean {
const identifier = simulator.deviceTypeIdentifier ?? "";
const name = simulator.name ?? "";
const deviceTypeName = simulator.deviceTypeName ?? "";
if (simulator.platform === "android-emulator") {
return false;
}
const metadata = simulatorMetadataText(simulator);
return (
identifier.includes(".iPhone-") ||
identifier.includes(".iPad-") ||
identifier.includes(".Apple-Watch-") ||
name.startsWith("iPhone") ||
name.startsWith("iPad") ||
name.startsWith("Apple Watch") ||
deviceTypeName.startsWith("Apple Watch")
metadata.includes("iphone") ||
metadata.includes("ipad") ||
metadata.includes("apple-watch") ||
metadata.includes("apple watch") ||
metadata.includes("apple-tv") ||
metadata.includes("apple tv") ||
metadata.includes("appletv")
);
}

export function simulatorHasFixedOrientation(
simulator: SimulatorMetadata | null,
): boolean {
if (!simulator || simulator.platform === "android-emulator") {
return false;
}
const metadata = simulatorMetadataText(simulator);
return (
metadata.includes("tvos") ||
metadata.includes("watchos") ||
metadata.includes("apple-tv") ||
metadata.includes("apple tv") ||
metadata.includes("appletv") ||
metadata.includes("apple-watch") ||
metadata.includes("apple watch")
);
}

function simulatorMetadataText(simulator: SimulatorMetadata): string {
return [
simulator.name,
simulator.deviceTypeName,
simulator.deviceTypeIdentifier,
simulator.runtimeName,
simulator.runtimeIdentifier,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}

function formatRuntimeLabel(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) {
Expand Down
22 changes: 14 additions & 8 deletions client/src/features/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
StreamQualityPreset,
StreamTransport,
} from "../stream/streamTypes";
import { simulatorHasFixedOrientation } from "../simulators/simulatorDisplay";
import { SimulatorMenu } from "../simulators/SimulatorMenu";

interface ToolbarProps {
Expand Down Expand Up @@ -117,6 +118,9 @@ export function Toolbar({
touchOverlayVisible,
}: ToolbarProps) {
const [errorCopied, setErrorCopied] = useState(false);
const canRotateSelectedSimulator =
selectedSimulator != null &&
!simulatorHasFixedOrientation(selectedSimulator);

useEffect(() => {
setErrorCopied(false);
Expand Down Expand Up @@ -266,14 +270,16 @@ export function Toolbar({
>
<AppearanceIcon />
</button>
<button
aria-label="Rotate Right"
className="tbtn icon-btn toolbar-mobile-hidden"
onClick={onRotateRight}
title="Rotate Right"
>
<RotateRightIcon />
</button>
{canRotateSelectedSimulator ? (
<button
aria-label="Rotate Right"
className="tbtn icon-btn toolbar-mobile-hidden"
onClick={onRotateRight}
title="Rotate Right"
>
<RotateRightIcon />
</button>
) : null}
</div>
) : null}
{error ? (
Expand Down
Loading
Loading