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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ The current repo uses the private boot path, private display bridge, and private
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. Shallow snapshots with `maxDepth <= 2` use the tokenized frontmost application translation directly when it is available, and only run the expensive recovery sweep if frontmost lookup fails, so agent-oriented describe loops avoid launchctl and hit-test recovery overhead. Interactive-only snapshots also prune non-actionable native AX leaves during Objective-C serialization before the Rust-side compacting pass; keep this native pruning conservative so selector taps still retain actionable rows plus their ancestors. 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 `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.
On macOS/Xcode 27-era CoreSimulator profiles, `mainScreenWidth`, `mainScreenHeight`, and `mainScreenScale` may be absent from `profile.plist`; DeviceKit chrome rendering must read `capabilities.plist` `ScreenDimensionsCapability` or the primary `displays` entry before falling back to the framebuffer mask PDF. If none of those sources produce usable display geometry, the chrome profile must fail instead of returning a tiny synthetic bezel that hides the stream.
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.
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`.

Expand Down
89 changes: 82 additions & 7 deletions packages/client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { useLiveStream } from "../features/stream/useLiveStream";
import { DebugPanel } from "../features/toolbar/DebugPanel";
import { Toolbar } from "../features/toolbar/Toolbar";
import { SimulatorViewport } from "../features/viewport/SimulatorViewport";
import { isUsableChromeProfile } from "../features/viewport/chromeProfile";
import type {
Point,
Size,
Expand Down Expand Up @@ -98,6 +99,7 @@ import {
clearLegacyVolatileUiState,
DEFAULT_VIEWPORT_STATE,
DEBUG_VISIBLE_STORAGE_KEY,
DEVICE_CHROME_VISIBLE_STORAGE_KEY,
DEVTOOLS_VISIBLE_STORAGE_KEY,
HIERARCHY_VISIBLE_STORAGE_KEY,
nextAccessibilitySourcePreference,
Expand Down Expand Up @@ -531,6 +533,12 @@ export function AppShell({
const [touchOverlayVisible, setTouchOverlayVisible] = useState(() =>
readStoredFlag(TOUCH_OVERLAY_VISIBLE_STORAGE_KEY, true),
);
const [deviceChromeVisible, setDeviceChromeVisible] = useState(() =>
readStoredFlag(DEVICE_CHROME_VISIBLE_STORAGE_KEY, true),
);
const [failedChromeAssetUDIDs, setFailedChromeAssetUDIDs] = useState<
Set<string>
>(() => new Set());
const [streamConfig, setStreamConfig] = useState<StreamConfig>(() =>
defaultStreamConfigForTransport(
remoteStream,
Expand Down Expand Up @@ -879,8 +887,17 @@ export function AppShell({
simulators,
streamError,
]);
const shouldRenderChrome =
const selectedChromeAssetsFailed = Boolean(
selectedSimulator && failedChromeAssetUDIDs.has(selectedSimulator.udid),
);
const selectedSupportsChrome =
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
const deviceChromeToggleActive = Boolean(
selectedSupportsChrome &&
deviceChromeVisible &&
!selectedChromeAssetsFailed,
);
const shouldRenderChrome = deviceChromeToggleActive;
const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null;
const isAndroidViewport = isAndroidSimulator(selectedSimulator);
const selectedHasFixedOrientation =
Expand Down Expand Up @@ -1081,10 +1098,29 @@ export function AppShell({
writeStoredFlag(TOUCH_OVERLAY_VISIBLE_STORAGE_KEY, touchOverlayVisible);
}, [touchOverlayVisible]);

useEffect(() => {
writeStoredFlag(DEVICE_CHROME_VISIBLE_STORAGE_KEY, deviceChromeVisible);
}, [deviceChromeVisible]);

const toggleDevTools = useCallback(() => {
setDevToolsVisible((current) => !current);
}, []);

const toggleDeviceChrome = useCallback(() => {
const udid = selectedSimulator?.udid;
if (!deviceChromeToggleActive && udid) {
setFailedChromeAssetUDIDs((current) => {
if (!current.has(udid)) {
return current;
}
const next = new Set(current);
next.delete(udid);
return next;
});
}
setDeviceChromeVisible(!deviceChromeToggleActive);
}, [deviceChromeToggleActive, selectedSimulator?.udid]);

useEffect(() => {
window.localStorage.setItem(
ACCESSIBILITY_SOURCE_STORAGE_KEY,
Expand Down Expand Up @@ -1534,11 +1570,12 @@ export function AppShell({

let cancelled = false;
let remaining = chromeAssetUrls.length;
let failed = false;

setChromeLoaded(false);

function markComplete() {
if (cancelled) {
if (cancelled || failed) {
return;
}
remaining -= 1;
Expand All @@ -1547,6 +1584,22 @@ export function AppShell({
}
}

function markFailed() {
if (cancelled || failed) {
return;
}
failed = true;
setChromeLoaded(true);
if (selectedSimulator?.udid) {
setFailedChromeAssetUDIDs((current) => {
if (current.has(selectedSimulator.udid)) {
return current;
}
return new Set(current).add(selectedSimulator.udid);
});
}
}

const images = chromeAssetUrls.map((url) => {
const image = new Image();
let completed = false;
Expand All @@ -1559,10 +1612,16 @@ export function AppShell({
};
image.decoding = "async";
image.onload = completeImage;
image.onerror = completeImage;
image.onerror = markFailed;
image.src = url;
if (image.complete) {
window.setTimeout(completeImage, 0);
window.setTimeout(() => {
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
completeImage();
} else {
markFailed();
}
}, 0);
}
return image;
});
Expand All @@ -1574,19 +1633,25 @@ export function AppShell({
image.onerror = null;
});
};
}, [chromeAssetUrls, chromeRequired]);
}, [chromeAssetUrls, chromeRequired, selectedSimulator?.udid]);

useEffect(() => {
let cancelled = false;

async function loadChromeProfile() {
if (!selectedSimulator) {
if (!selectedSimulator || !selectedSupportsChrome) {
setChromeProfile(null);
setChromeProfileReady(true);
return;
}
const udid = selectedSimulator.udid;
try {
const profile = await fetchChromeProfile(selectedSimulator.udid);
const profile = await fetchChromeProfile(udid);
if (!isUsableChromeProfile(profile)) {
throw new Error(
"Device chrome profile did not include usable geometry.",
);
}
if (!cancelled) {
setChromeProfile(profile);
setChromeProfileReady(true);
Expand All @@ -1595,6 +1660,12 @@ export function AppShell({
if (!cancelled) {
setChromeProfile(null);
setChromeProfileReady(true);
setFailedChromeAssetUDIDs((current) => {
if (current.has(udid)) {
return current;
}
return new Set(current).add(udid);
});
}
}
}
Expand All @@ -1608,6 +1679,7 @@ export function AppShell({
selectedSimulator?.privateDisplay?.displayWidth,
selectedSimulator?.privateDisplay?.rotationQuarterTurns,
selectedSimulator?.udid,
selectedSupportsChrome,
]);

useEffect(() => {
Expand Down Expand Up @@ -2917,6 +2989,7 @@ export function AppShell({
}
}}
onToggleDebug={() => setDebugVisible((current) => !current)}
onToggleDeviceChrome={toggleDeviceChrome}
onToggleDevTools={toggleDevTools}
onToggleHierarchy={() => {
setHierarchyVisible((current) => !current);
Expand Down Expand Up @@ -2956,6 +3029,8 @@ export function AppShell({
)}
streamConfig={streamConfig}
streamTransport={streamTransport}
deviceChromeAvailable={selectedSupportsChrome}
deviceChromeVisible={deviceChromeToggleActive}
simulatorMenuOpen={simulatorMenuOpen}
simulatorMenuRef={simulatorMenuRef}
showStopButton={Boolean(
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/app/uiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const CHROME_DEVTOOLS_VISIBLE_STORAGE_KEY =
"xcw-chrome-devtools-visible";
export const ACCESSIBILITY_SOURCE_STORAGE_KEY = "xcw-hierarchy-source";
export const TOUCH_OVERLAY_VISIBLE_STORAGE_KEY = "xcw-touch-overlay-visible";
export const DEVICE_CHROME_VISIBLE_STORAGE_KEY = "xcw-device-chrome-visible";

const ACCESSIBILITY_SOURCE_ORDER: AccessibilitySource[] = [
"nativescript",
Expand Down
17 changes: 17 additions & 0 deletions packages/client/src/features/simulators/SimulatorMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { simulatorHasFixedOrientation } from "./simulatorDisplay";
interface SimulatorMenuProps {
captureBusy: boolean;
debugVisible: boolean;
deviceChromeAvailable: boolean;
deviceChromeVisible: boolean;
canInstallApp: boolean;
menuOpen: boolean;
menuRef: RefObject<HTMLDivElement | null>;
Expand All @@ -36,6 +38,7 @@ interface SimulatorMenuProps {
onStreamTransportChange: (transport: StreamTransport) => void;
onToggleAppearance: () => void;
onToggleDebug: () => void;
onToggleDeviceChrome: () => void;
onToggleMenu: () => void;
onToggleRecording: () => void;
onToggleSoftwareKeyboard: () => void;
Expand All @@ -54,6 +57,8 @@ interface SimulatorMenuProps {
export function SimulatorMenu({
captureBusy,
debugVisible,
deviceChromeAvailable,
deviceChromeVisible,
canInstallApp,
menuOpen,
menuRef,
Expand All @@ -76,6 +81,7 @@ export function SimulatorMenu({
onStreamTransportChange,
onToggleAppearance,
onToggleDebug,
onToggleDeviceChrome,
onToggleMenu,
onToggleRecording,
onToggleSoftwareKeyboard,
Expand Down Expand Up @@ -200,6 +206,17 @@ export function SimulatorMenu({
)}
</select>
</label>
<label
className={`menu-toggle ${!deviceChromeAvailable ? "disabled" : ""}`}
>
<input
checked={deviceChromeVisible}
disabled={!deviceChromeAvailable}
onChange={onToggleDeviceChrome}
type="checkbox"
/>
<span>Bezel</span>
</label>
</div>
<div className="menu-divider" />
<div className="menu-actions">
Expand Down
9 changes: 9 additions & 0 deletions packages/client/src/features/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { SimulatorPickerMenu } from "../simulators/SimulatorPickerMenu";

interface ToolbarProps {
debugVisible: boolean;
deviceChromeAvailable: boolean;
deviceChromeVisible: boolean;
devToolsVisible: boolean;
error: string;
filteredSimulators: SimulatorMetadata[];
Expand Down Expand Up @@ -53,6 +55,7 @@ interface ToolbarProps {
onStreamTransportChange: (transport: StreamTransport) => void;
onToggleAppearance: () => void;
onToggleDebug: () => void;
onToggleDeviceChrome: () => void;
onToggleDevTools: () => void;
onToggleHierarchy: () => void;
onToggleMenu: () => void;
Expand Down Expand Up @@ -86,6 +89,8 @@ export function Toolbar({
closeSimulatorMenu,
closeMenu,
debugVisible,
deviceChromeAvailable,
deviceChromeVisible,
devToolsVisible,
error,
filteredSimulators,
Expand Down Expand Up @@ -116,6 +121,7 @@ export function Toolbar({
onStreamTransportChange,
onToggleAppearance,
onToggleDebug,
onToggleDeviceChrome,
onToggleDevTools,
onToggleHierarchy,
onToggleMenu,
Expand Down Expand Up @@ -196,6 +202,7 @@ export function Toolbar({
onStreamTransportChange={onStreamTransportChange}
onToggleAppearance={onToggleAppearance}
onToggleDebug={onToggleDebug}
onToggleDeviceChrome={onToggleDeviceChrome}
onToggleMenu={onToggleMenu}
onToggleRecording={onToggleRecording}
onToggleSoftwareKeyboard={onToggleSoftwareKeyboard}
Expand All @@ -209,6 +216,8 @@ export function Toolbar({
canInstallApp={canInstallApp}
streamConfig={streamConfig}
streamTransport={streamTransport}
deviceChromeAvailable={deviceChromeAvailable}
deviceChromeVisible={deviceChromeVisible}
touchOverlayVisible={touchOverlayVisible}
/>
<SimulatorPickerMenu
Expand Down
57 changes: 57 additions & 0 deletions packages/client/src/features/viewport/chromeProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";

import type { ChromeProfile } from "../../api/types";
import { isUsableChromeProfile } from "./chromeProfile";

function profile(overrides: Partial<ChromeProfile> = {}): ChromeProfile {
return {
buttons: [],
chromeStyle: "asset",
contentHeight: 844,
contentWidth: 390,
contentX: 31,
contentY: 22,
cornerRadius: 46,
hasScreenMask: false,
screenHeight: 844,
screenWidth: 390,
screenX: 31,
screenY: 22,
totalHeight: 888,
totalWidth: 452,
...overrides,
};
}

describe("isUsableChromeProfile", () => {
it("accepts a profile whose screen geometry fits inside the chrome", () => {
expect(isUsableChromeProfile(profile())).toBe(true);
});

it("rejects tiny screen geometry produced by missing display metadata", () => {
expect(isUsableChromeProfile(profile({ screenHeight: 1 }))).toBe(false);
expect(isUsableChromeProfile(profile({ screenWidth: 1 }))).toBe(false);
});

it("rejects screen geometry outside the chrome bounds", () => {
expect(
isUsableChromeProfile(profile({ screenX: 80, screenWidth: 390 })),
).toBe(false);
expect(
isUsableChromeProfile(profile({ screenY: 60, screenHeight: 844 })),
).toBe(false);
});

it("rejects partial content geometry", () => {
expect(
isUsableChromeProfile(
profile({
contentHeight: undefined,
contentWidth: 390,
contentX: 31,
contentY: 22,
}),
),
).toBe(false);
});
});
Loading
Loading