diff --git a/AGENTS.md b/AGENTS.md index 6bf57f90..c280cfbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/packages/client/src/app/AppShell.tsx b/packages/client/src/app/AppShell.tsx index 499f93a3..962d0e09 100644 --- a/packages/client/src/app/AppShell.tsx +++ b/packages/client/src/app/AppShell.tsx @@ -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, @@ -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, @@ -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 + >(() => new Set()); const [streamConfig, setStreamConfig] = useState(() => defaultStreamConfigForTransport( remoteStream, @@ -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 = @@ -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, @@ -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; @@ -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; @@ -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; }); @@ -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); @@ -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); + }); } } } @@ -1608,6 +1679,7 @@ export function AppShell({ selectedSimulator?.privateDisplay?.displayWidth, selectedSimulator?.privateDisplay?.rotationQuarterTurns, selectedSimulator?.udid, + selectedSupportsChrome, ]); useEffect(() => { @@ -2917,6 +2989,7 @@ export function AppShell({ } }} onToggleDebug={() => setDebugVisible((current) => !current)} + onToggleDeviceChrome={toggleDeviceChrome} onToggleDevTools={toggleDevTools} onToggleHierarchy={() => { setHierarchyVisible((current) => !current); @@ -2956,6 +3029,8 @@ export function AppShell({ )} streamConfig={streamConfig} streamTransport={streamTransport} + deviceChromeAvailable={selectedSupportsChrome} + deviceChromeVisible={deviceChromeToggleActive} simulatorMenuOpen={simulatorMenuOpen} simulatorMenuRef={simulatorMenuRef} showStopButton={Boolean( diff --git a/packages/client/src/app/uiState.ts b/packages/client/src/app/uiState.ts index 6f329d73..084f9300 100644 --- a/packages/client/src/app/uiState.ts +++ b/packages/client/src/app/uiState.ts @@ -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", diff --git a/packages/client/src/features/simulators/SimulatorMenu.tsx b/packages/client/src/features/simulators/SimulatorMenu.tsx index 1b963f81..b0859ab3 100644 --- a/packages/client/src/features/simulators/SimulatorMenu.tsx +++ b/packages/client/src/features/simulators/SimulatorMenu.tsx @@ -14,6 +14,8 @@ import { simulatorHasFixedOrientation } from "./simulatorDisplay"; interface SimulatorMenuProps { captureBusy: boolean; debugVisible: boolean; + deviceChromeAvailable: boolean; + deviceChromeVisible: boolean; canInstallApp: boolean; menuOpen: boolean; menuRef: RefObject; @@ -36,6 +38,7 @@ interface SimulatorMenuProps { onStreamTransportChange: (transport: StreamTransport) => void; onToggleAppearance: () => void; onToggleDebug: () => void; + onToggleDeviceChrome: () => void; onToggleMenu: () => void; onToggleRecording: () => void; onToggleSoftwareKeyboard: () => void; @@ -54,6 +57,8 @@ interface SimulatorMenuProps { export function SimulatorMenu({ captureBusy, debugVisible, + deviceChromeAvailable, + deviceChromeVisible, canInstallApp, menuOpen, menuRef, @@ -76,6 +81,7 @@ export function SimulatorMenu({ onStreamTransportChange, onToggleAppearance, onToggleDebug, + onToggleDeviceChrome, onToggleMenu, onToggleRecording, onToggleSoftwareKeyboard, @@ -200,6 +206,17 @@ export function SimulatorMenu({ )} +
diff --git a/packages/client/src/features/toolbar/Toolbar.tsx b/packages/client/src/features/toolbar/Toolbar.tsx index 128eb85a..685325ff 100644 --- a/packages/client/src/features/toolbar/Toolbar.tsx +++ b/packages/client/src/features/toolbar/Toolbar.tsx @@ -25,6 +25,8 @@ import { SimulatorPickerMenu } from "../simulators/SimulatorPickerMenu"; interface ToolbarProps { debugVisible: boolean; + deviceChromeAvailable: boolean; + deviceChromeVisible: boolean; devToolsVisible: boolean; error: string; filteredSimulators: SimulatorMetadata[]; @@ -53,6 +55,7 @@ interface ToolbarProps { onStreamTransportChange: (transport: StreamTransport) => void; onToggleAppearance: () => void; onToggleDebug: () => void; + onToggleDeviceChrome: () => void; onToggleDevTools: () => void; onToggleHierarchy: () => void; onToggleMenu: () => void; @@ -86,6 +89,8 @@ export function Toolbar({ closeSimulatorMenu, closeMenu, debugVisible, + deviceChromeAvailable, + deviceChromeVisible, devToolsVisible, error, filteredSimulators, @@ -116,6 +121,7 @@ export function Toolbar({ onStreamTransportChange, onToggleAppearance, onToggleDebug, + onToggleDeviceChrome, onToggleDevTools, onToggleHierarchy, onToggleMenu, @@ -196,6 +202,7 @@ export function Toolbar({ onStreamTransportChange={onStreamTransportChange} onToggleAppearance={onToggleAppearance} onToggleDebug={onToggleDebug} + onToggleDeviceChrome={onToggleDeviceChrome} onToggleMenu={onToggleMenu} onToggleRecording={onToggleRecording} onToggleSoftwareKeyboard={onToggleSoftwareKeyboard} @@ -209,6 +216,8 @@ export function Toolbar({ canInstallApp={canInstallApp} streamConfig={streamConfig} streamTransport={streamTransport} + deviceChromeAvailable={deviceChromeAvailable} + deviceChromeVisible={deviceChromeVisible} touchOverlayVisible={touchOverlayVisible} /> = {}): 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); + }); +}); diff --git a/packages/client/src/features/viewport/chromeProfile.ts b/packages/client/src/features/viewport/chromeProfile.ts new file mode 100644 index 00000000..837ec4c8 --- /dev/null +++ b/packages/client/src/features/viewport/chromeProfile.ts @@ -0,0 +1,69 @@ +import type { ChromeProfile } from "../../api/types"; + +export function isUsableChromeProfile( + profile: ChromeProfile | null, +): profile is ChromeProfile { + if (!profile) { + return false; + } + + const requiredNumbers = [ + profile.totalWidth, + profile.totalHeight, + profile.screenX, + profile.screenY, + profile.screenWidth, + profile.screenHeight, + profile.cornerRadius, + ]; + if (!requiredNumbers.every(Number.isFinite)) { + return false; + } + if ( + profile.totalWidth <= 0 || + profile.totalHeight <= 0 || + profile.screenWidth < 8 || + profile.screenHeight < 8 || + profile.screenX < -0.5 || + profile.screenY < -0.5 || + profile.screenX + profile.screenWidth > profile.totalWidth + 0.5 || + profile.screenY + profile.screenHeight > profile.totalHeight + 0.5 + ) { + return false; + } + + if ( + profile.contentWidth != null || + profile.contentHeight != null || + profile.contentX != null || + profile.contentY != null + ) { + const contentNumbers = [ + profile.contentX, + profile.contentY, + profile.contentWidth, + profile.contentHeight, + ]; + if ( + !contentNumbers.every( + (value) => typeof value === "number" && Number.isFinite(value), + ) + ) { + return false; + } + if ( + (profile.contentWidth ?? 0) < 8 || + (profile.contentHeight ?? 0) < 8 || + (profile.contentX ?? 0) < -0.5 || + (profile.contentY ?? 0) < -0.5 || + (profile.contentX ?? 0) + (profile.contentWidth ?? 0) > + profile.totalWidth + 0.5 || + (profile.contentY ?? 0) + (profile.contentHeight ?? 0) > + profile.totalHeight + 0.5 + ) { + return false; + } + } + + return true; +} diff --git a/packages/client/src/styles/components.css b/packages/client/src/styles/components.css index 7fb17266..2fcf1373 100644 --- a/packages/client/src/styles/components.css +++ b/packages/client/src/styles/components.css @@ -464,6 +464,29 @@ opacity: 0.55; } +.menu-toggle { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + color: var(--text); + font-size: 12px; + cursor: pointer; +} + +.menu-toggle input { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--accent); +} + +.menu-toggle.disabled { + color: var(--text-muted); + cursor: default; + opacity: 0.55; +} + .menu-debug-panel { padding: 6px 4px 4px; } @@ -2516,6 +2539,13 @@ a.hierarchy-node-source:hover, box-shadow: 0 0 0 1px var(--screen-bg); } +.screen-only-shell .device-screen { + box-shadow: + 0 18px 48px rgba(0, 0, 0, 0.38), + 0 2px 10px rgba(0, 0, 0, 0.24), + 0 0 0 1px rgba(0, 0, 0, 0.28); +} + .device-screen.android-screen { background: transparent; } diff --git a/packages/server/native/XCWChromeRenderer.m b/packages/server/native/XCWChromeRenderer.m index 037c9aa7..e7627cfa 100644 --- a/packages/server/native/XCWChromeRenderer.m +++ b/packages/server/native/XCWChromeRenderer.m @@ -2,6 +2,7 @@ #import #import +#import static NSString * const XCWChromeRendererErrorDomain = @"SimDeck.ChromeRenderer"; @@ -37,6 +38,14 @@ + (CGRect)blackScreenBoundsForChromeInfo:(NSDictionary *)chromeInfo matchingDisplaySize:(CGSize)displaySize; + (CGFloat)framebufferMaskCornerRadiusForChromeInfo:(NSDictionary *)chromeInfo pointScreenWidth:(CGFloat)pointScreenWidth; ++ (nullable NSDictionary *)capabilitiesForChromeInfo:(NSDictionary *)chromeInfo; ++ (nullable NSDictionary *)screenDimensionsForChromeInfo:(NSDictionary *)chromeInfo; ++ (nullable NSDictionary *)primaryDisplayForChromeInfo:(NSDictionary *)chromeInfo; ++ (CGSize)displayPixelSizeForChromeInfo:(NSDictionary *)chromeInfo + error:(NSError * _Nullable __autoreleasing *)error; ++ (CGFloat)screenScaleForChromeInfo:(NSDictionary *)chromeInfo; ++ (BOOL)validateChromeProfile:(NSDictionary *)profile + error:(NSError * _Nullable __autoreleasing *)error; @end @implementation XCWChromeRenderer @@ -57,21 +66,7 @@ + (CGSize)displayPixelSizeForDeviceName:(NSString *)deviceName return CGSizeZero; } - NSDictionary *plist = chromeInfo[@"plist"]; - CGFloat width = [self numberValue:plist[@"mainScreenWidth"]]; - CGFloat height = [self numberValue:plist[@"mainScreenHeight"]]; - if (width <= 0.0 || height <= 0.0) { - if (error != NULL) { - *error = [NSError errorWithDomain:XCWChromeRendererErrorDomain - code:16 - userInfo:@{ - NSLocalizedDescriptionKey: [NSString stringWithFormat:@"The device profile for %@ did not specify a framebuffer size.", deviceName ?: @""], - }]; - } - return CGSizeZero; - } - - return CGSizeMake(width, height); + return [self displayPixelSizeForChromeInfo:chromeInfo error:error]; } + (nullable NSData *)PNGDataForDeviceName:(NSString *)deviceName @@ -441,10 +436,20 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName NSString *sensorName = [plist[@"sensorBarImage"] isKindOfClass:[NSString class]] ? plist[@"sensorBarImage"] : @""; BOOL hasModernPhoneSensor = [self shouldRenderPhoneChromeFromSlices:plist sensorName:sensorName]; BOOL hasComposite = !hasModernPhoneSensor && [self compositeAssetPathForChromeInfo:chromeInfo].length > 0; - CGFloat screenScale = MAX([self numberValue:plist[@"mainScreenScale"]], 1.0); + CGFloat screenScale = [self screenScaleForChromeInfo:chromeInfo]; CGSize profileScreenSize = [self screenSizeForChromeInfo:chromeInfo chromeSize:compositeSize screenScale:screenScale]; + if (profileScreenSize.width <= 0.0 || profileScreenSize.height <= 0.0) { + if (error != NULL) { + *error = [NSError errorWithDomain:XCWChromeRendererErrorDomain + code:16 + userInfo:@{ + NSLocalizedDescriptionKey: @"The CoreSimulator device profile did not specify usable display dimensions.", + }]; + } + return nil; + } CGFloat pointScreenWidth = profileScreenSize.width; CGFloat pointScreenHeight = profileScreenSize.height; @@ -546,7 +551,7 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName chromeSize:compositeSize chromeOffset:CGPointMake(chromeX, chromeY)]; - return @{ + NSDictionary *profile = @{ @"totalWidth": @(CGRectGetWidth(fullFrame)), @"totalHeight": @(CGRectGetHeight(fullFrame)), @"chromeX": @(chromeX), @@ -566,6 +571,10 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName @"hasScreenMask": @(hasScreenMask), @"buttons": buttons, }; + if (![self validateChromeProfile:profile error:error]) { + return nil; + } + return profile; } + (nullable NSDictionary *)chromeInfoForDeviceName:(NSString *)deviceName @@ -640,12 +649,26 @@ + (nullable NSDictionary *)chromeInfoForDeviceName:(NSString *)deviceName return nil; } - return @{ + NSMutableDictionary *chromeInfo = [@{ @"plist": plist, @"json": json, @"chromePath": chromePath, @"profileResourcesPath": profilePath.stringByDeletingLastPathComponent, - }; + } mutableCopy]; + + NSString *capabilitiesPath = [profilePath.stringByDeletingLastPathComponent stringByAppendingPathComponent:@"capabilities.plist"]; + NSData *capabilitiesData = [NSData dataWithContentsOfFile:capabilitiesPath]; + if (capabilitiesData != nil) { + NSDictionary *capabilities = [NSPropertyListSerialization propertyListWithData:capabilitiesData + options:NSPropertyListImmutable + format:nil + error:nil]; + if ([capabilities isKindOfClass:[NSDictionary class]]) { + chromeInfo[@"capabilities"] = capabilities; + } + } + + return chromeInfo; } + (CGSize)compositeSizeForChromeInfo:(NSDictionary *)chromeInfo @@ -661,12 +684,22 @@ + (CGSize)compositeSizeForChromeInfo:(NSDictionary *)chromeInfo NSDictionary *paths = [json[@"paths"] isKindOfClass:[NSDictionary class]] ? json[@"paths"] : @{}; NSDictionary *bord = [paths[@"simpleOutsideBorder"] isKindOfClass:[NSDictionary class]] ? paths[@"simpleOutsideBorder"] : @{}; NSDictionary *bordI = [bord[@"insets"] isKindOfClass:[NSDictionary class]] ? bord[@"insets"] : @{}; - CGFloat screenScale = MAX([self numberValue:plist[@"mainScreenScale"]], 1.0); + CGFloat screenScale = [self screenScaleForChromeInfo:chromeInfo]; CGSize screenSize = [self screenSizeForChromeInfo:chromeInfo chromeSize:CGSizeZero screenScale:screenScale]; CGFloat screenWidth = screenSize.width; CGFloat screenHeight = screenSize.height; + if (screenWidth <= 0.0 || screenHeight <= 0.0) { + if (error != NULL) { + *error = [NSError errorWithDomain:XCWChromeRendererErrorDomain + code:11 + userInfo:@{ + NSLocalizedDescriptionKey: @"The DeviceKit chrome metadata did not include usable display dimensions.", + }]; + } + return CGSizeZero; + } CGFloat bezelLeft = [self numberValue:sizing[@"leftWidth"]] + [self numberValue:bordI[@"left"]]; CGFloat bezelRight = [self numberValue:sizing[@"rightWidth"]] + [self numberValue:bordI[@"right"]]; CGFloat bezelTop = [self numberValue:sizing[@"topHeight"]] + [self numberValue:bordI[@"top"]]; @@ -1230,12 +1263,175 @@ + (nullable NSDictionary *)inputNamed:(NSString *)buttonName return nil; } ++ (nullable NSDictionary *)capabilitiesForChromeInfo:(NSDictionary *)chromeInfo { + NSDictionary *root = [chromeInfo[@"capabilities"] isKindOfClass:[NSDictionary class]] + ? chromeInfo[@"capabilities"] + : nil; + if (root == nil) { + return nil; + } + NSDictionary *capabilities = [root[@"capabilities"] isKindOfClass:[NSDictionary class]] + ? root[@"capabilities"] + : root; + return capabilities; +} + ++ (nullable NSDictionary *)screenDimensionsForChromeInfo:(NSDictionary *)chromeInfo { + NSDictionary *capabilities = [self capabilitiesForChromeInfo:chromeInfo]; + NSDictionary *screenDimensions = [capabilities[@"ScreenDimensionsCapability"] isKindOfClass:[NSDictionary class]] + ? capabilities[@"ScreenDimensionsCapability"] + : nil; + return screenDimensions; +} + ++ (nullable NSDictionary *)primaryDisplayForChromeInfo:(NSDictionary *)chromeInfo { + NSDictionary *capabilities = [self capabilitiesForChromeInfo:chromeInfo]; + NSArray *displays = [capabilities[@"displays"] isKindOfClass:[NSArray class]] + ? capabilities[@"displays"] + : @[]; + NSString *profileChromeIdentifier = [chromeInfo[@"plist"][@"chromeIdentifier"] isKindOfClass:[NSString class]] + ? chromeInfo[@"plist"][@"chromeIdentifier"] + : @""; + NSDictionary *firstDisplay = nil; + NSDictionary *firstIntegratedDisplay = nil; + NSDictionary *firstPrimaryDisplay = nil; + + for (id displayValue in displays) { + if (![displayValue isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary *display = displayValue; + if (firstDisplay == nil) { + firstDisplay = display; + } + NSString *displayType = [display[@"displayType"] isKindOfClass:[NSString class]] ? display[@"displayType"] : @""; + NSString *deviceName = [display[@"deviceName"] isKindOfClass:[NSString class]] ? display[@"deviceName"] : @""; + NSString *displayChromeIdentifier = [display[@"chromeIdentifier"] isKindOfClass:[NSString class]] ? display[@"chromeIdentifier"] : @""; + if (firstIntegratedDisplay == nil && [displayType isEqualToString:@"integrated"]) { + firstIntegratedDisplay = display; + } + if (firstPrimaryDisplay == nil && [deviceName isEqualToString:@"primary"]) { + firstPrimaryDisplay = display; + } + if (profileChromeIdentifier.length > 0 && + [displayChromeIdentifier isEqualToString:profileChromeIdentifier]) { + return display; + } + } + + return firstPrimaryDisplay ?: firstIntegratedDisplay ?: firstDisplay; +} + ++ (CGSize)displayPixelSizeForChromeInfo:(NSDictionary *)chromeInfo + error:(NSError * _Nullable __autoreleasing *)error { + NSDictionary *plist = chromeInfo[@"plist"]; + CGFloat width = [self numberValue:plist[@"mainScreenWidth"]]; + CGFloat height = [self numberValue:plist[@"mainScreenHeight"]]; + if (width <= 0.0 || height <= 0.0) { + NSDictionary *screenDimensions = [self screenDimensionsForChromeInfo:chromeInfo]; + width = [self numberValue:screenDimensions[@"main-screen-width"]]; + height = [self numberValue:screenDimensions[@"main-screen-height"]]; + } + if (width <= 0.0 || height <= 0.0) { + NSDictionary *display = [self primaryDisplayForChromeInfo:chromeInfo]; + width = [self numberValue:display[@"width"]]; + height = [self numberValue:display[@"height"]]; + } + if (width <= 0.0 || height <= 0.0) { + CGSize maskSize = [self framebufferMaskSizeForChromeInfo:chromeInfo]; + width = maskSize.width; + height = maskSize.height; + } + if (width <= 0.0 || height <= 0.0) { + if (error != NULL) { + *error = [NSError errorWithDomain:XCWChromeRendererErrorDomain + code:16 + userInfo:@{ + NSLocalizedDescriptionKey: @"The CoreSimulator device profile did not specify a framebuffer size.", + }]; + } + return CGSizeZero; + } + return CGSizeMake(width, height); +} + ++ (CGFloat)screenScaleForChromeInfo:(NSDictionary *)chromeInfo { + NSDictionary *plist = chromeInfo[@"plist"]; + CGFloat scale = [self numberValue:plist[@"mainScreenScale"]]; + if (scale <= 0.0) { + NSDictionary *screenDimensions = [self screenDimensionsForChromeInfo:chromeInfo]; + scale = [self numberValue:screenDimensions[@"main-screen-scale"]]; + } + if (scale <= 0.0) { + NSDictionary *display = [self primaryDisplayForChromeInfo:chromeInfo]; + scale = [self numberValue:display[@"scale"]]; + } + if (scale <= 0.0) { + NSDictionary *capabilities = [self capabilitiesForChromeInfo:chromeInfo]; + NSDictionary *artworkTraits = [capabilities[@"ArtworkTraits"] isKindOfClass:[NSDictionary class]] + ? capabilities[@"ArtworkTraits"] + : nil; + scale = [self numberValue:artworkTraits[@"ArtworkDeviceScaleFactor"]]; + } + return MAX(scale, 1.0); +} + ++ (BOOL)validateChromeProfile:(NSDictionary *)profile + error:(NSError * _Nullable __autoreleasing *)error { + CGFloat totalWidth = [self numberValue:profile[@"totalWidth"]]; + CGFloat totalHeight = [self numberValue:profile[@"totalHeight"]]; + CGFloat screenX = [self numberValue:profile[@"screenX"]]; + CGFloat screenY = [self numberValue:profile[@"screenY"]]; + CGFloat screenWidth = [self numberValue:profile[@"screenWidth"]]; + CGFloat screenHeight = [self numberValue:profile[@"screenHeight"]]; + CGFloat contentWidth = [profile[@"contentWidth"] respondsToSelector:@selector(doubleValue)] + ? [self numberValue:profile[@"contentWidth"]] + : screenWidth; + CGFloat contentHeight = [profile[@"contentHeight"] respondsToSelector:@selector(doubleValue)] + ? [self numberValue:profile[@"contentHeight"]] + : screenHeight; + BOOL valid = + isfinite(totalWidth) && + isfinite(totalHeight) && + isfinite(screenX) && + isfinite(screenY) && + isfinite(screenWidth) && + isfinite(screenHeight) && + isfinite(contentWidth) && + isfinite(contentHeight) && + totalWidth > 0.0 && + totalHeight > 0.0 && + screenWidth >= 8.0 && + screenHeight >= 8.0 && + contentWidth >= 8.0 && + contentHeight >= 8.0 && + screenX >= -0.5 && + screenY >= -0.5 && + screenX + screenWidth <= totalWidth + 0.5 && + screenY + screenHeight <= totalHeight + 0.5; + if (valid) { + return YES; + } + if (error != NULL) { + *error = [NSError errorWithDomain:XCWChromeRendererErrorDomain + code:19 + userInfo:@{ + NSLocalizedDescriptionKey: @"The DeviceKit chrome profile did not include usable screen geometry.", + }]; + } + return NO; +} + + (CGSize)screenSizeForChromeInfo:(NSDictionary *)chromeInfo chromeSize:(CGSize)chromeSize screenScale:(CGFloat)screenScale { NSDictionary *plist = chromeInfo[@"plist"]; - CGFloat rawWidth = [self numberValue:plist[@"mainScreenWidth"]]; - CGFloat rawHeight = [self numberValue:plist[@"mainScreenHeight"]]; + CGSize rawSize = [self displayPixelSizeForChromeInfo:chromeInfo error:nil]; + CGFloat rawWidth = rawSize.width; + CGFloat rawHeight = rawSize.height; + if (rawWidth <= 0.0 || rawHeight <= 0.0) { + return CGSizeZero; + } CGFloat scale = MAX(screenScale, 1.0); if (![self isWatchProfile:plist]) { return CGSizeMake(rawWidth / scale, rawHeight / scale); @@ -1271,7 +1467,7 @@ + (CGFloat)inputCoordinateScaleForChromeInfo:(NSDictionary *)chromeInfo return [self inputScaleForChromeInfo:chromeInfo chromeSize:chromeSize]; } - CGFloat screenScale = MAX([self numberValue:plist[@"mainScreenScale"]], 1.0); + CGFloat screenScale = [self screenScaleForChromeInfo:chromeInfo]; NSDictionary *json = chromeInfo[@"json"]; NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{}; NSDictionary *sizing = [images[@"sizing"] isKindOfClass:[NSDictionary class]] ? images[@"sizing"] : @{}; @@ -1283,8 +1479,9 @@ + (CGFloat)inputCoordinateScaleForChromeInfo:(NSDictionary *)chromeInfo [self numberValue:stand[@"height"]] - [self numberValue:sizing[@"topHeight"]] - [self numberValue:sizing[@"bottomHeight"]]; - CGFloat screenWidth = [self numberValue:plist[@"mainScreenWidth"]]; - CGFloat screenHeight = [self numberValue:plist[@"mainScreenHeight"]]; + CGSize pixelSize = [self displayPixelSizeForChromeInfo:chromeInfo error:nil]; + CGFloat screenWidth = pixelSize.width; + CGFloat screenHeight = pixelSize.height; if (nominalWidth <= 0.0 || nominalHeight <= 0.0 || screenWidth <= 0.0 || screenHeight <= 0.0) { return screenScale; } @@ -1318,7 +1515,7 @@ + (CGFloat)inputVerticalAdjustmentForInput:(NSDictionary *)input ? maskSize : [self screenSizeForChromeInfo:chromeInfo chromeSize:chromeSize - screenScale:MAX([self numberValue:plist[@"mainScreenScale"]], 1.0)]; + screenScale:[self screenScaleForChromeInfo:chromeInfo]]; if (slotHeight <= 0.0 || displaySize.height <= 0.0) { return 0.0; }