diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index d0609eb0ad5..742ff08df29 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -80,6 +80,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula deviceName={displayName} loading={loading} isLive={isLive} + 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-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx new file mode 100644 index 00000000000..12409234ff4 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EmulatorDeviceFrame } from './emulator-device-frame' + +// Why: a backgrounded but still-attached emulator must stop streaming frames. +// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG +// blob churn / H.264 decode) while the pane is inactive, and that a running +// stream is torn down when the pane is hidden — so this asserts the IPC calls, +// not just the rendered DOM. + +type FrameListener = (data: { streamId: string; bytes: ArrayBuffer }) => void +type ErrorListener = (data: { streamId: string; message: string }) => void + +let container: HTMLDivElement +let root: Root +let startFrameStream: ReturnType +let stopFrameStream: ReturnType +let streamCounter: number + +beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + streamCounter = 0 + startFrameStream = vi.fn(async () => ({ streamId: `stream-${++streamCounter}` })) + stopFrameStream = vi.fn(async () => {}) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:emulator-frame') + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + emulator: { + startFrameStream, + stopFrameStream, + onFrameStreamFrame: (_listener: FrameListener) => () => {}, + onFrameStreamError: (_listener: ErrorListener) => () => {} + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + delete (URL as Partial).createObjectURL + delete (URL as Partial).revokeObjectURL + delete (window as { api?: unknown }).api + vi.restoreAllMocks() +}) + +async function renderFrame(isActive: boolean): Promise { + await act(async () => { + root.render( + + ) + }) +} + +describe('EmulatorDeviceFrame visibility gating', () => { + it('streams frames while the pane is active', async () => { + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledWith( + expect.objectContaining({ streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' }) + ) + }) + + it('does not start a frame stream while the pane is inactive', async () => { + await renderFrame(false) + expect(startFrameStream).not.toHaveBeenCalled() + }) + + it('tears the stream down when the pane becomes inactive and resumes when active again', async () => { + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Parking the pane must stop the stream at the source (no more IPC frames). + await renderFrame(false) + expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' }) + + // Re-showing re-fires the stream; the session was never detached. + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(2) + }) +}) 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 fb7fa74135b..b32824bb9fc 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} + 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 5d03be16158..7f9e2cda8ca 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -39,6 +39,9 @@ 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. */ + isActive: boolean onTap: (x: number, y: number) => void onGesture: (points: EmulatorGesturePoint[]) => void } @@ -65,6 +68,7 @@ export function EmulatorDeviceFrame({ deviceName, loading, isLive, + isActive, onTap, onGesture }: EmulatorDeviceFrameProps) { @@ -327,7 +331,13 @@ export function EmulatorDeviceFrame({ setStreamError(true) }, []) - const showStream = isLive && Boolean(previewUrl) + // 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. + const showStream = isActive && isLive && Boolean(previewUrl) const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 const screenAspectRatioStyle = streamSize ? `${streamSize.width} / ${streamSize.height}`