diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index 742ff08df..476c79e23 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -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)} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts index 9cfd4d265..f1c567e9f 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts index 62aac8367..0948ad3db 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts @@ -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 diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx index 12409234f..b2a9621ba 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -67,6 +67,7 @@ async function renderFrame(isActive: boolean): Promise { wsUrl="ws://127.0.0.1:3100/ws" loading={false} isLive={true} + visualOrientation="portrait" isActive={isActive} onTap={vi.fn()} onGesture={vi.fn()} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx index b32824bb9..dd198c82b 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx @@ -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()} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx index 7f9e2cda8..9b278241d 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -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, + '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(null) const [streamError, setStreamError] = useState(false) const [streamSize, setStreamSize] = useState(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) => { @@ -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' }} > -
- {/* Why: the stream is the actual emulator screen; fake in-screen - chrome doubles up with iOS's real status bar and makes bezels lie. */} - -
+ previewUrl={previewUrl} + screenAspectRatio={screenAspectRatio} + showStream={Boolean(showStream)} + streamError={streamError} + streamKey={streamKey} + streamRotation={visualStreamGeometry.streamRotation} + /> diff --git a/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts b/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts new file mode 100644 index 000000000..b778034f1 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts @@ -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 + })) +} diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts b/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts index b199863bd..3d22461c5 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts +++ b/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts @@ -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 }) + }) }) diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx index 141048430..af19c21e4 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx @@ -90,7 +90,10 @@ afterEach(() => { vi.restoreAllMocks() }) -async function renderStream(streamKey = 'abc'): Promise { +async function renderStream( + streamKey = 'abc', + props?: { screenAspectRatio?: number; streamRotation?: -90 | 0 | 90 } +): Promise { await act(async () => { root.render( { 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 () => { diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx index 3e86ed145..4b83495e0 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx @@ -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://; 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 ( { const { naturalWidth, naturalHeight } = event.currentTarget @@ -123,3 +136,18 @@ export function EmulatorScreenStreamContent({ ) } + +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}%` + } +} diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx new file mode 100644 index 000000000..325116ff5 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx @@ -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 + onKeyDown: KeyboardEventHandler + onPaste: ClipboardEventHandler + onPointerCancel: PointerEventHandler + onPointerDown: PointerEventHandler + onPointerMove: PointerEventHandler + onPointerUp: PointerEventHandler + onStreamError: () => void + onStreamSize: (size: StreamSize) => void + onWheel: WheelEventHandler + 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 ( +
+ {/* Why: the stream is the actual emulator screen; fake in-screen chrome + doubles up with iOS's real status bar and makes bezels lie. */} + +
+ ) +} diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts index c760c4f51..42f9d9cab 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts @@ -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('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 } } diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx index a3342c94c..a9a67bd11 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx @@ -49,6 +49,7 @@ const deviceList = devices.map((device) => ({ })) let attachDeferred: Deferred +let rotateDeferred: Deferred let container: HTMLDivElement let root: Root let latest: ReturnType | null = null @@ -113,6 +114,7 @@ describe('useEmulatorPaneSession', () => { globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true attachDeferred = createDeferred() + rotateDeferred = createDeferred() 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() + }) + await flushEffects() + + let rotatePromise: Promise = 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`.' diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts index 0a1dac53c..2ac04a9ca 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts @@ -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(prelaunchedState.liveTarget) const deviceRefreshErrorRef = useRef(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,