Fix mobile emulator device rotation (#6326)

This commit is contained in:
Jinwoo Hong 2026-07-06 01:26:36 -07:00 committed by GitHub
parent eb8435950a
commit 2e5057f341
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 435 additions and 88 deletions

View File

@ -29,7 +29,8 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
previewUrl,
wsUrl,
streamKey,
isLive
isLive,
visualOrientation
} = useEmulatorPaneSession({
worktreeId,
tabId: tab?.id,
@ -80,6 +81,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
deviceName={displayName}
loading={loading}
isLive={isLive}
visualOrientation={visualOrientation}
isActive={isActive}
onTap={(x, y) => void sendTap(x, y)}
onGesture={(points) => void sendGesture(points)}

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { fitDeviceFrameToPane, resolveDeviceFrameKind } from './emulator-device-frame-layout'
import {
fitDeviceFrameToPane,
resolveDeviceFrameKind,
resolveVisualScreenAspectRatio,
resolveVisualStreamGeometry
} from './emulator-device-frame-layout'
describe('resolveDeviceFrameKind', () => {
it('prefers device names over aspect-ratio fallback', () => {
@ -13,6 +18,42 @@ describe('resolveDeviceFrameKind', () => {
})
})
describe('resolveVisualScreenAspectRatio', () => {
it('uses the requested orientation even when the stream canvas has not swapped yet', () => {
const portraitStream = { width: 390, height: 844 }
expect(resolveVisualScreenAspectRatio(portraitStream, 'portrait')).toBeCloseTo(390 / 844)
expect(resolveVisualScreenAspectRatio(portraitStream, 'landscape')).toBeCloseTo(844 / 390)
})
it('uses the requested orientation only before a stream frame arrives', () => {
expect(resolveVisualScreenAspectRatio(null, 'portrait')).toBeCloseTo(9 / 19)
expect(resolveVisualScreenAspectRatio(null, 'landscape')).toBeCloseTo(19 / 9)
})
})
describe('resolveVisualStreamGeometry', () => {
it('uses visual orientation for the interactive screen rectangle', () => {
const geometry = resolveVisualStreamGeometry({ width: 390, height: 844 }, 'landscape')
expect(geometry.size).toEqual({
width: 844,
height: 390
})
expect(geometry.streamRotation).toBe(90)
})
it('rotates a stale landscape stream back into portrait geometry', () => {
const geometry = resolveVisualStreamGeometry({ width: 844, height: 390 }, 'portrait')
expect(geometry.size).toEqual({
width: 390,
height: 844
})
expect(geometry.streamRotation).toBe(-90)
})
})
describe('fitDeviceFrameToPane', () => {
it('fits a phone shell and hardware controls inside the pane', () => {
const pane = { width: 600, height: 1000 }
@ -50,4 +91,15 @@ describe('fitDeviceFrameToPane', () => {
expect(layout?.shellWidth).toBeGreaterThan(1)
expect(layout?.shellHeight).toBeGreaterThan(1)
})
it('fits the entire phone frame as landscape after a device rotation', () => {
const pane = { width: 1000, height: 600 }
const aspectRatio = resolveVisualScreenAspectRatio({ width: 844, height: 390 }, 'landscape')
const layout = fitDeviceFrameToPane(pane, aspectRatio, 'phone')
expect(layout).not.toBeNull()
expect(layout?.width).toBeLessThanOrEqual(pane.width)
expect(layout?.height).toBeLessThanOrEqual(pane.height)
expect(layout ? layout.shellWidth / layout.shellHeight : 0).toBeGreaterThan(1)
})
})

View File

@ -5,6 +5,8 @@ export type StreamSize = {
height: number
}
export type EmulatorDeviceVisualOrientation = 'portrait' | 'landscape'
export type PaneSize = {
width: number
height: number
@ -23,6 +25,12 @@ export type DeviceFrameLayout = {
sideButtonThickness: number
}
export type VisualStreamGeometry = {
aspectRatio: number
size: StreamSize | null
streamRotation: -90 | 0 | 90
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value))
const FIT_MARGIN_PX = 0.5
@ -40,6 +48,38 @@ export function resolveDeviceFrameKind(
return screenAspectRatio > 0.62 && screenAspectRatio < 1.62 ? 'tablet' : 'phone'
}
export function resolveVisualScreenAspectRatio(
streamSize: StreamSize | null,
visualOrientation: EmulatorDeviceVisualOrientation
): number {
return resolveVisualStreamGeometry(streamSize, visualOrientation).aspectRatio
}
export function resolveVisualStreamGeometry(
streamSize: StreamSize | null,
visualOrientation: EmulatorDeviceVisualOrientation
): VisualStreamGeometry {
// Why: serve-sim can keep the same canvas dimensions after rotate; the pane's
// physical frame and input rect still need to follow the successful request.
const width = streamSize?.width ?? 9
const height = streamSize?.height ?? 19
const shortSide = Math.min(width, height)
const longSide = Math.max(width, height)
const visualSize =
visualOrientation === 'landscape'
? { width: longSide, height: shortSide }
: { width: shortSide, height: longSide }
const streamIsLandscape = streamSize ? streamSize.width > streamSize.height : false
const visualIsLandscape = visualOrientation === 'landscape'
const streamRotation =
streamSize && streamIsLandscape !== visualIsLandscape ? (visualIsLandscape ? 90 : -90) : 0
return {
aspectRatio: visualSize.width / visualSize.height,
size: streamSize ? visualSize : null,
streamRotation
}
}
function fitScreenToPane(paneSize: PaneSize | null, aspectRatio: number): PaneSize | null {
if (!paneSize || paneSize.width <= 0 || paneSize.height <= 0 || aspectRatio <= 0) {
return null

View File

@ -67,6 +67,7 @@ async function renderFrame(isActive: boolean): Promise<void> {
wsUrl="ws://127.0.0.1:3100/ws"
loading={false}
isLive={true}
visualOrientation="portrait"
isActive={isActive}
onTap={vi.fn()}
onGesture={vi.fn()}

View File

@ -99,6 +99,7 @@ function renderFrame(props?: {
wsUrl="ws://127.0.0.1:3100/ws"
loading={false}
isLive={true}
visualOrientation="portrait"
isActive={true}
onTap={props?.onTap ?? vi.fn()}
onGesture={props?.onGesture ?? vi.fn()}

View File

@ -9,7 +9,9 @@ import {
} from 'react'
import {
fitDeviceFrameToPane,
resolveVisualStreamGeometry,
resolveDeviceFrameKind,
type EmulatorDeviceVisualOrientation,
type StreamSize
} from './emulator-device-frame-layout'
import {
@ -25,12 +27,10 @@ import {
type PointerSample
} from './emulator-screen-gesture'
import { PhoneHardwareButtons } from './emulator-phone-hardware-buttons'
import { EmulatorScreenStreamContent } from './emulator-screen-stream-content'
import { EmulatorScreenSurface } from './emulator-screen-surface'
import { useEmulatorControlStream } from './use-emulator-control-stream'
import { useEmulatorPaneSize } from './use-emulator-pane-size'
import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard'
import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label'
import { cn } from '@/lib/utils'
type EmulatorDeviceFrameProps = {
previewUrl?: string
@ -39,8 +39,8 @@ type EmulatorDeviceFrameProps = {
deviceName?: string
loading: boolean
isLive: boolean
/** False when the pane is backgrounded (hidden tab/worktree). Gates the frame
* stream so a parked emulator stops decoding, matching the pane's visibility. */
visualOrientation: EmulatorDeviceVisualOrientation
/** False when backgrounded; parks the stream with the pane's visibility. */
isActive: boolean
onTap: (x: number, y: number) => void
onGesture: (points: EmulatorGesturePoint[]) => void
@ -55,11 +55,10 @@ type PendingWheelGesture = {
timerId: number | null
}
type ScreenCoordinateEvent = {
clientX: number
clientY: number
currentTarget: HTMLDivElement
}
type ScreenCoordinateEvent = Pick<
PointerEvent<HTMLDivElement>,
'clientX' | 'clientY' | 'currentTarget'
>
export function EmulatorDeviceFrame({
previewUrl,
@ -68,6 +67,7 @@ export function EmulatorDeviceFrame({
deviceName,
loading,
isLive,
visualOrientation,
isActive,
onTap,
onGesture
@ -81,6 +81,10 @@ export function EmulatorDeviceFrame({
const wheelGestureRef = useRef<PendingWheelGesture | null>(null)
const [streamError, setStreamError] = useState(false)
const [streamSize, setStreamSize] = useState<StreamSize | null>(null)
const visualStreamGeometry = useMemo(
() => resolveVisualStreamGeometry(streamSize, visualOrientation),
[streamSize, visualOrientation]
)
const canInteract = isLive && !loading && !streamError
const { cancelKeyboardFrames, sendKeyboardFrames, sendTouch } = useEmulatorControlStream(
wsUrl,
@ -103,9 +107,9 @@ export function EmulatorDeviceFrame({
mapClientPointToSimulatorScreen(
{ clientX: event.clientX, clientY: event.clientY },
event.currentTarget.getBoundingClientRect(),
streamSize
visualStreamGeometry.size
),
[streamSize]
[visualStreamGeometry.size]
)
const sendGesturePoints = useCallback(
@ -262,7 +266,7 @@ export function EmulatorDeviceFrame({
const action = resolveEmulatorPointerAction(
samples,
event.currentTarget.getBoundingClientRect(),
streamSize
visualStreamGeometry.size
)
if (!action) {
return
@ -273,7 +277,7 @@ export function EmulatorDeviceFrame({
sendGesturePoints(action.points)
}
},
[canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, streamSize]
[canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, visualStreamGeometry]
)
const handleWheel = useCallback(
@ -290,7 +294,7 @@ export function EmulatorDeviceFrame({
deltaY: event.deltaY
},
event.currentTarget.getBoundingClientRect(),
streamSize
visualStreamGeometry.size
)
if (!delta) {
return
@ -317,7 +321,7 @@ export function EmulatorDeviceFrame({
timerId: window.setTimeout(flushWheelGesture, WHEEL_GESTURE_IDLE_MS)
}
},
[canInteract, flushWheelGesture, sendTouch, streamSize]
[canInteract, flushWheelGesture, sendTouch, visualStreamGeometry]
)
const handleStreamSize = useCallback((size: NonNullable<StreamSize>) => {
@ -331,20 +335,16 @@ export function EmulatorDeviceFrame({
setStreamError(true)
}, [])
// Why: only decode frames while the pane is visible. A backgrounded but still
// attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG
// blob churn against a hidden canvas, with frames still flowing over IPC (and
// the network for paired SSH/remote clients). The session stays attached, so
// the stream re-fires within a frame on re-show. Mirrors the browser pane's
// park-when-hidden behavior.
// Why: hidden panes still receive emulator frames, including over SSH, so
// parking the stream avoids background decode/IPC churn while staying attached.
const showStream = isActive && isLive && Boolean(previewUrl)
const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
const screenAspectRatioStyle = streamSize
? `${streamSize.width} / ${streamSize.height}`
: '9 / 19'
const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
// Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the
// physical frame still follows the last successful rotate request.
const screenAspectRatio = visualStreamGeometry.aspectRatio
const frameKind = useMemo(
() => resolveDeviceFrameKind(deviceName, screenAspectRatio),
[deviceName, screenAspectRatio]
() => resolveDeviceFrameKind(deviceName, streamAspectRatio),
[deviceName, streamAspectRatio]
)
const frameLayout = useMemo(
() => fitDeviceFrameToPane(paneSize, screenAspectRatio, frameKind),
@ -376,43 +376,28 @@ export function EmulatorDeviceFrame({
borderRadius: frameLayout ? `${frameLayout.outerRadius}px` : '54px'
}}
>
<div
className={cn(
frameLayout
? 'absolute overflow-hidden bg-black ring-1 ring-white/10'
: 'relative w-full overflow-hidden bg-black ring-1 ring-white/10',
isLive && 'touch-none select-none'
)}
style={{
inset: frameLayout ? `${frameLayout.bezel}px` : undefined,
aspectRatio: frameLayout ? undefined : screenAspectRatioStyle,
borderRadius: frameLayout ? `${frameLayout.innerRadius}px` : '44px'
}}
<EmulatorScreenSurface
frameLayout={frameLayout}
isLive={isLive}
keyboardCaptureActive={keyboardCaptureActive}
loading={loading}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onStreamError={handleStreamError}
onStreamSize={handleStreamSize}
onWheel={handleWheel}
role={isLive ? 'application' : undefined}
tabIndex={isLive ? 0 : undefined}
aria-keyshortcuts={keyboardCaptureActive ? 'Escape' : undefined}
aria-label={getEmulatorScreenAriaLabel(isLive, keyboardCaptureActive)}
>
{/* Why: the stream is the actual emulator screen; fake in-screen
chrome doubles up with iOS's real status bar and makes bezels lie. */}
<EmulatorScreenStreamContent
loading={loading}
onStreamError={handleStreamError}
onStreamSize={handleStreamSize}
previewUrl={previewUrl}
showStream={Boolean(showStream)}
streamError={streamError}
streamKey={streamKey}
/>
</div>
previewUrl={previewUrl}
screenAspectRatio={screenAspectRatio}
showStream={Boolean(showStream)}
streamError={streamError}
streamKey={streamKey}
streamRotation={visualStreamGeometry.streamRotation}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
import type { SimulatorDeviceRow } from './emulator-pane-types'
// Raw shape returned by the unified `emulator.listDevices` RPC (iOS simulators + Android AVDs).
export type RawEmulatorDevice = {
id: string
name: string
state: string
detail?: string
isAvailable?: boolean
}
export function toSimulatorDeviceRows(raw: RawEmulatorDevice[]): SimulatorDeviceRow[] {
return raw.map((device) => ({
name: device.name,
udid: device.id,
state: device.state === 'booted' ? 'Booted' : 'Shutdown',
runtime: device.detail,
isAvailable: device.isAvailable
}))
}

View File

@ -5,6 +5,7 @@ import {
resolveEmulatorWheelDelta,
resolveEmulatorPointerAction
} from './emulator-screen-gesture'
import { resolveVisualStreamGeometry } from './emulator-device-frame-layout'
const rect = {
left: 10,
@ -114,4 +115,22 @@ describe('emulator screen gestures', () => {
})
).toBeNull()
})
it('maps the full landscape screen while the stream canvas shape settles', () => {
const landscapeRect = {
left: 0,
top: 0,
width: 400,
height: 200
}
const stalePortraitStream = { width: 390, height: 844 }
expect(
mapClientPointToSimulatorScreen(
{ clientX: 380, clientY: 100 },
landscapeRect,
resolveVisualStreamGeometry(stalePortraitStream, 'landscape').size
)
).toEqual({ x: 0.95, y: 0.5 })
})
})

View File

@ -90,7 +90,10 @@ afterEach(() => {
vi.restoreAllMocks()
})
async function renderStream(streamKey = 'abc'): Promise<void> {
async function renderStream(
streamKey = 'abc',
props?: { screenAspectRatio?: number; streamRotation?: -90 | 0 | 90 }
): Promise<void> {
await act(async () => {
root.render(
<EmulatorScreenStreamContent
@ -98,9 +101,11 @@ async function renderStream(streamKey = 'abc'): Promise<void> {
onStreamError={vi.fn()}
onStreamSize={vi.fn()}
previewUrl="http://127.0.0.1:3100/stream.mjpeg"
screenAspectRatio={props?.screenAspectRatio}
showStream={true}
streamError={false}
streamKey={streamKey}
streamRotation={props?.streamRotation}
/>
)
})
@ -122,6 +127,24 @@ describe('EmulatorScreenStreamContent', () => {
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('blob:emulator-frame-1')
expect(img?.className).toContain('object-contain')
expect(img?.className).not.toContain('object-fill')
})
it('rotates mismatched stream media without stretching it', async () => {
await renderStream('abc', { screenAspectRatio: 844 / 390, streamRotation: 90 })
await act(async () => {
frameListeners[0]?.({ streamId: 'stream-1', bytes: new Uint8Array([1, 2, 3]).buffer })
})
const img = container.querySelector('img')
expect(img?.className).toContain('object-contain')
expect(img?.className).toContain('absolute')
expect(img?.className).not.toContain('object-fill')
expect(img?.style.transform).toBe('translate(-50%, -50%) rotate(90deg)')
expect(img?.style.width).toBe(`${100 / (844 / 390)}%`)
expect(img?.style.height).toBe(`${100 * (844 / 390)}%`)
})
it('clears the previous frame while a new stream key is connecting', async () => {

View File

@ -1,8 +1,9 @@
import { Loader2 } from 'lucide-react'
import { useEffect } from 'react'
import { useEffect, type CSSProperties } from 'react'
import { useEmulatorFrameStream } from './use-emulator-frame-stream'
import { useEmulatorVideoStream } from './use-emulator-video-stream'
import { translate } from '@/i18n/i18n'
import type { VisualStreamGeometry } from './emulator-device-frame-layout'
type StreamSize = {
height: number
@ -14,9 +15,11 @@ type EmulatorScreenStreamContentProps = {
onStreamError: () => void
onStreamSize: (size: StreamSize) => void
previewUrl?: string
screenAspectRatio?: number
showStream: boolean
streamError: boolean
streamKey?: string
streamRotation?: VisualStreamGeometry['streamRotation']
}
// Android sessions stream H.264 over scrcpy://<serial>; iOS uses an MJPEG http URL.
@ -27,9 +30,11 @@ export function EmulatorScreenStreamContent({
onStreamError,
onStreamSize,
previewUrl,
screenAspectRatio = 9 / 19,
showStream,
streamError,
streamKey
streamKey,
streamRotation = 0
}: EmulatorScreenStreamContentProps) {
const androidDeviceId =
previewUrl && previewUrl.startsWith(SCRCPY_PREFIX)
@ -54,11 +59,18 @@ export function EmulatorScreenStreamContent({
}
}, [frameStream.error, video.error, onStreamError])
const mediaStyle = resolveStreamMediaStyle(streamRotation, screenAspectRatio)
const mediaClassName =
streamRotation === 0
? 'block h-full w-full bg-black object-contain'
: 'absolute left-1/2 top-1/2 block max-w-none bg-black object-contain'
if (androidDeviceId && showStream && !video.error) {
return (
<canvas
ref={video.canvasRef}
className="block h-full w-full bg-black object-contain"
className={mediaClassName}
style={mediaStyle}
aria-label={translate(
'auto.components.emulator.pane.emulator.screen.stream.content.5ee64cd44e',
'Emulator screen'
@ -76,8 +88,9 @@ export function EmulatorScreenStreamContent({
'auto.components.emulator.pane.emulator.screen.stream.content.5ee64cd44e',
'Emulator screen'
)}
className="block h-full w-full bg-black object-contain"
className={mediaClassName}
draggable={false}
style={mediaStyle}
onError={onStreamError}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget
@ -123,3 +136,18 @@ export function EmulatorScreenStreamContent({
</div>
)
}
function resolveStreamMediaStyle(
streamRotation: VisualStreamGeometry['streamRotation'],
screenAspectRatio: number
): CSSProperties | undefined {
if (streamRotation === 0 || screenAspectRatio <= 0) {
return undefined
}
return {
height: `${100 * screenAspectRatio}%`,
transform: `translate(-50%, -50%) rotate(${streamRotation}deg)`,
transformOrigin: 'center',
width: `${100 / screenAspectRatio}%`
}
}

View File

@ -0,0 +1,103 @@
import type {
ClipboardEventHandler,
FocusEventHandler,
KeyboardEventHandler,
PointerEventHandler,
WheelEventHandler
} from 'react'
import { cn } from '@/lib/utils'
import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label'
import { EmulatorScreenStreamContent } from './emulator-screen-stream-content'
import type {
DeviceFrameLayout,
StreamSize,
VisualStreamGeometry
} from './emulator-device-frame-layout'
type EmulatorScreenSurfaceProps = {
frameLayout: DeviceFrameLayout | null
isLive: boolean
keyboardCaptureActive: boolean
loading: boolean
onBlur: FocusEventHandler<HTMLDivElement>
onKeyDown: KeyboardEventHandler<HTMLDivElement>
onPaste: ClipboardEventHandler<HTMLDivElement>
onPointerCancel: PointerEventHandler<HTMLDivElement>
onPointerDown: PointerEventHandler<HTMLDivElement>
onPointerMove: PointerEventHandler<HTMLDivElement>
onPointerUp: PointerEventHandler<HTMLDivElement>
onStreamError: () => void
onStreamSize: (size: StreamSize) => void
onWheel: WheelEventHandler<HTMLDivElement>
previewUrl?: string
screenAspectRatio: number
showStream: boolean
streamError: boolean
streamKey?: string
streamRotation: VisualStreamGeometry['streamRotation']
}
export function EmulatorScreenSurface({
frameLayout,
isLive,
keyboardCaptureActive,
loading,
onBlur,
onKeyDown,
onPaste,
onPointerCancel,
onPointerDown,
onPointerMove,
onPointerUp,
onStreamError,
onStreamSize,
onWheel,
previewUrl,
screenAspectRatio,
showStream,
streamError,
streamKey,
streamRotation
}: EmulatorScreenSurfaceProps) {
return (
<div
className={cn(
frameLayout
? 'absolute overflow-hidden bg-black ring-1 ring-white/10'
: 'relative w-full overflow-hidden bg-black ring-1 ring-white/10',
isLive && 'touch-none select-none'
)}
style={{
inset: frameLayout ? `${frameLayout.bezel}px` : undefined,
aspectRatio: frameLayout ? undefined : `${screenAspectRatio}`,
borderRadius: frameLayout ? `${frameLayout.innerRadius}px` : '44px'
}}
onPointerCancel={onPointerCancel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onBlur={onBlur}
onKeyDown={onKeyDown}
onPaste={onPaste}
onWheel={onWheel}
role={isLive ? 'application' : undefined}
tabIndex={isLive ? 0 : undefined}
aria-keyshortcuts={keyboardCaptureActive ? 'Escape' : undefined}
aria-label={getEmulatorScreenAriaLabel(isLive, keyboardCaptureActive)}
>
{/* Why: the stream is the actual emulator screen; fake in-screen chrome
doubles up with iOS's real status bar and makes bezels lie. */}
<EmulatorScreenStreamContent
loading={loading}
onStreamError={onStreamError}
onStreamSize={onStreamSize}
previewUrl={previewUrl}
screenAspectRatio={screenAspectRatio}
showStream={showStream}
streamError={streamError}
streamKey={streamKey}
streamRotation={streamRotation}
/>
</div>
)
}

View File

@ -1,9 +1,13 @@
import { useCallback, useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { EmulatorDeviceVisualOrientation } from './emulator-device-frame-layout'
import type { EmulatorGesturePoint } from './emulator-screen-gesture'
export function useEmulatorPaneControls(worktreeId: string) {
export function useEmulatorPaneControls(worktreeId: string, onRotateSettled?: () => void) {
const nextRotateOrientationRef = useRef<'landscape_left' | 'portrait'>('landscape_left')
const visualOrientationEpochRef = useRef(0)
const [visualOrientation, setVisualOrientation] =
useState<EmulatorDeviceVisualOrientation>('portrait')
const sendTap = useCallback(
async (x: number, y: number) => {
@ -28,13 +32,27 @@ export function useEmulatorPaneControls(worktreeId: string) {
const sendRotate = useCallback(async () => {
const orientation = nextRotateOrientationRef.current
const epoch = visualOrientationEpochRef.current
await callRuntimeRpc({ kind: 'local' }, 'emulator.rotate', {
orientation,
worktree: worktreeId
})
if (visualOrientationEpochRef.current !== epoch) {
return null
}
const nextVisualOrientation = orientation === 'landscape_left' ? 'landscape' : 'portrait'
setVisualOrientation(nextVisualOrientation)
nextRotateOrientationRef.current =
orientation === 'landscape_left' ? 'portrait' : 'landscape_left'
}, [worktreeId])
onRotateSettled?.()
return nextVisualOrientation
}, [onRotateSettled, worktreeId])
return { sendTap, sendButton, sendGesture, sendRotate }
const resetVisualOrientation = useCallback(() => {
visualOrientationEpochRef.current += 1
nextRotateOrientationRef.current = 'landscape_left'
setVisualOrientation('portrait')
}, [])
return { sendTap, sendButton, sendGesture, sendRotate, visualOrientation, resetVisualOrientation }
}

View File

@ -49,6 +49,7 @@ const deviceList = devices.map((device) => ({
}))
let attachDeferred: Deferred<AttachResult>
let rotateDeferred: Deferred<void>
let container: HTMLDivElement
let root: Root
let latest: ReturnType<typeof useEmulatorPaneSession> | null = null
@ -113,6 +114,7 @@ describe('useEmulatorPaneSession', () => {
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
attachDeferred = createDeferred<AttachResult>()
rotateDeferred = createDeferred<void>()
latest = null
consumePrelaunchedSimulatorSession(WORKTREE_ID)
rememberPrelaunchedSimulatorSession(WORKTREE_ID, {
@ -133,6 +135,9 @@ describe('useEmulatorPaneSession', () => {
if (method === 'emulator.attach') {
return runtimeSuccess(await attachDeferred.promise)
}
if (method === 'emulator.rotate') {
return runtimeSuccess(await rotateDeferred.promise)
}
if (method === 'emulator.shutdown') {
return runtimeSuccess({ deviceUdid: 'device-b' })
}
@ -196,6 +201,48 @@ describe('useEmulatorPaneSession', () => {
expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg')
})
it('ignores a rotate response from the previous session after switching devices', async () => {
await act(async () => {
root.render(<Probe />)
})
await flushEffects()
let rotatePromise: Promise<unknown> = Promise.resolve()
await act(async () => {
rotatePromise = latest?.sendRotate() ?? Promise.resolve()
await Promise.resolve()
})
await act(async () => {
container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
await act(async () => {
rotateDeferred.resolve()
await rotatePromise
await Promise.resolve()
})
expect(latest?.visualOrientation).toBe('portrait')
await act(async () => {
attachDeferred.resolve({
attached: true,
info: {
deviceUdid: 'device-b',
displayName: 'iPhone B',
streamUrl: 'http://127.0.0.1:3200/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3200/ws'
}
})
await attachDeferred.promise
await Promise.resolve()
})
expect(latest?.visualOrientation).toBe('portrait')
})
it('keeps simulator discovery setup errors during auto attach', async () => {
const message =
'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'

View File

@ -8,6 +8,7 @@ import {
type SimulatorDeviceRow
} from './emulator-pane-types'
import { markSimulatorDeviceBooted, markSimulatorDeviceShutdown } from './emulator-device-state'
import { toSimulatorDeviceRows, type RawEmulatorDevice } from './emulator-device-row-mapping'
import { useEmulatorPaneControls } from './use-emulator-pane-controls'
import { useEmulatorPaneSessionEvents } from './use-emulator-pane-session-events'
import {
@ -56,25 +57,25 @@ export function useEmulatorPaneSession({
const liveTargetRef = useRef<string | null>(prelaunchedState.liveTarget)
const deviceRefreshErrorRef = useRef<unknown>(null)
const suppressAutoAttachRef = useRef(false)
const { sendTap, sendButton, sendGesture, sendRotate } = useEmulatorPaneControls(worktreeId)
const refreshStreamKey = useCallback(() => setStreamKey(String(Date.now())), [])
const {
sendTap,
sendButton,
sendGesture,
sendRotate,
visualOrientation,
resetVisualOrientation
} = useEmulatorPaneControls(worktreeId, refreshStreamKey)
const refreshDevices = useCallback(async (bootedTarget?: string | null) => {
try {
// Unified list so Android devices/AVDs appear alongside iOS simulators.
const raw = (await callRuntimeRpc({ kind: 'local' }, 'emulator.listDevices', {})) as {
id: string
name: string
state: string
detail?: string
isAvailable?: boolean
}[]
const list: SimulatorDeviceRow[] = raw.map((device) => ({
name: device.name,
udid: device.id,
state: device.state === 'booted' ? 'Booted' : 'Shutdown',
runtime: device.detail,
isAvailable: device.isAvailable
}))
const raw = (await callRuntimeRpc(
{ kind: 'local' },
'emulator.listDevices',
{}
)) as RawEmulatorDevice[]
const list = toSimulatorDeviceRows(raw)
const next = markSimulatorDeviceBooted(list, bootedTarget)
if (!mountedRef.current) {
return next
@ -106,6 +107,9 @@ export function useEmulatorPaneSession({
if (attached && rows !== deviceRows) {
setDevices(rows)
}
if (attached && target && target !== liveTargetRef.current) {
resetVisualOrientation()
}
const row = rows.find((d) => d.udid === target || d.name === target)
const displayName = row?.name || deviceLabel(info)
const enriched = { ...info, displayName, state: attached ? 'Booted' : info?.state }
@ -126,7 +130,7 @@ export function useEmulatorPaneSession({
useAppStore.getState().setTabLabel(tabId, displayName)
}
},
[devices, tabId]
[devices, resetVisualOrientation, tabId]
)
const clearSessionAfterShutdown = useCallback(
@ -141,13 +145,14 @@ export function useEmulatorPaneSession({
liveTargetRef.current = null
suppressAutoAttachRef.current = true
setStreamKey(null)
resetVisualOrientation()
setError(null)
if (tabId) {
const row = devices.find((device) => device.udid === target || device.name === target)
useAppStore.getState().setTabLabel(tabId, row?.name || 'Mobile Emulator')
}
},
[devices, selectedUdid, session, tabId]
[devices, resetVisualOrientation, selectedUdid, session, tabId]
)
const attach = useCallback(
@ -189,6 +194,7 @@ export function useEmulatorPaneSession({
setSession(null)
setStreamKey(null)
liveTargetRef.current = null
resetVisualOrientation()
}
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.attach', {
device: target,
@ -238,6 +244,7 @@ export function useEmulatorPaneSession({
devices,
loading,
refreshDevices,
resetVisualOrientation,
selectedUdid,
tabId,
worktreeId
@ -301,6 +308,7 @@ export function useEmulatorPaneSession({
sendButton,
sendGesture,
sendRotate,
visualOrientation,
displayName: view.displayName,
previewUrl: view.previewUrl,
wsUrl: view.wsUrl,