mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Add mobile emulator screenshot export
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
createFromBufferMock,
|
||||
fromWebContentsMock,
|
||||
handleMock,
|
||||
showSaveDialogMock,
|
||||
toPngMock,
|
||||
writeFileMock
|
||||
} = vi.hoisted(() => ({
|
||||
createFromBufferMock: vi.fn(),
|
||||
fromWebContentsMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
showSaveDialogMock: vi.fn(),
|
||||
toPngMock: vi.fn(),
|
||||
writeFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
fromWebContents: fromWebContentsMock
|
||||
},
|
||||
dialog: {
|
||||
showSaveDialog: showSaveDialogMock
|
||||
},
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
},
|
||||
nativeImage: {
|
||||
createFromBuffer: createFromBufferMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: writeFileMock
|
||||
}))
|
||||
|
||||
import {
|
||||
buildEmulatorScreenshotDefaultPath,
|
||||
encodeEmulatorScreenshotPng,
|
||||
registerEmulatorScreenshotHandlers
|
||||
} from './emulator-screenshot'
|
||||
|
||||
describe('emulator screenshot IPC', () => {
|
||||
beforeEach(() => {
|
||||
createFromBufferMock.mockReset()
|
||||
fromWebContentsMock.mockReset()
|
||||
handleMock.mockReset()
|
||||
showSaveDialogMock.mockReset()
|
||||
toPngMock.mockReset()
|
||||
writeFileMock.mockReset()
|
||||
createFromBufferMock.mockReturnValue({
|
||||
isEmpty: () => false,
|
||||
toPNG: toPngMock
|
||||
})
|
||||
toPngMock.mockReturnValue(Buffer.from('png-bytes'))
|
||||
})
|
||||
|
||||
it('builds a sanitized PNG default filename', () => {
|
||||
expect(
|
||||
buildEmulatorScreenshotDefaultPath(
|
||||
'iPhone 16 Pro / Debug',
|
||||
new Date('2026-06-11T20:31:04.123Z')
|
||||
)
|
||||
).toBe('orca-iPhone-16-Pro-Debug-2026-06-11-20-31-04.png')
|
||||
})
|
||||
|
||||
it('encodes the frame as PNG', () => {
|
||||
const input = new Uint8Array([1, 2, 3]).buffer
|
||||
|
||||
expect(encodeEmulatorScreenshotPng(input)).toEqual(Buffer.from('png-bytes'))
|
||||
expect(createFromBufferMock).toHaveBeenCalledWith(Buffer.from([1, 2, 3]))
|
||||
})
|
||||
|
||||
it('throws when the frame is not a valid image', () => {
|
||||
createFromBufferMock.mockReturnValue({
|
||||
isEmpty: () => true,
|
||||
toPNG: toPngMock
|
||||
})
|
||||
|
||||
expect(() => encodeEmulatorScreenshotPng(new Uint8Array([1]).buffer)).toThrow(
|
||||
'Emulator screenshot frame is not a valid image.'
|
||||
)
|
||||
})
|
||||
|
||||
it('writes the selected screenshot file', async () => {
|
||||
const parentWindow = { id: 1 }
|
||||
fromWebContentsMock.mockReturnValue(parentWindow)
|
||||
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/tmp/sim.png' })
|
||||
registerEmulatorScreenshotHandlers()
|
||||
const handler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'emulator:saveScreenshot'
|
||||
)?.[1]
|
||||
|
||||
await expect(
|
||||
handler({ sender: { id: 7 } }, { bytes: new Uint8Array([4, 5]).buffer, deviceName: 'Phone' })
|
||||
).resolves.toEqual({ canceled: false, destinationPath: '/tmp/sim.png' })
|
||||
|
||||
expect(showSaveDialogMock).toHaveBeenCalledWith(parentWindow, {
|
||||
defaultPath: expect.stringMatching(/^orca-Phone-/),
|
||||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||
})
|
||||
expect(writeFileMock).toHaveBeenCalledWith('/tmp/sim.png', Buffer.from('png-bytes'))
|
||||
})
|
||||
|
||||
it('does not write a file when the save dialog is canceled', async () => {
|
||||
showSaveDialogMock.mockResolvedValue({ canceled: true })
|
||||
registerEmulatorScreenshotHandlers()
|
||||
const handler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'emulator:saveScreenshot'
|
||||
)?.[1]
|
||||
|
||||
await expect(
|
||||
handler({ sender: {} }, { bytes: new Uint8Array([4, 5]).buffer, deviceName: 'Phone' })
|
||||
).resolves.toEqual({ canceled: true })
|
||||
|
||||
expect(writeFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BrowserWindow, dialog, ipcMain, nativeImage } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
|
||||
export type SaveEmulatorScreenshotResult =
|
||||
| { canceled: true }
|
||||
| { canceled: false; destinationPath: string }
|
||||
|
||||
type SaveEmulatorScreenshotArgs = {
|
||||
bytes: ArrayBuffer
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
function sanitizeFilenameSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return sanitized || 'mobile-emulator'
|
||||
}
|
||||
|
||||
function timestampForFilename(date = new Date()): string {
|
||||
return date
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, '')
|
||||
.replace(/[:T]/g, '-')
|
||||
}
|
||||
|
||||
export function buildEmulatorScreenshotDefaultPath(deviceName?: string, date = new Date()): string {
|
||||
const device = sanitizeFilenameSegment(deviceName || 'mobile-emulator')
|
||||
return `orca-${device}-${timestampForFilename(date)}.png`
|
||||
}
|
||||
|
||||
function bufferFromArrayBuffer(bytes: ArrayBuffer): Buffer {
|
||||
return Buffer.from(new Uint8Array(bytes))
|
||||
}
|
||||
|
||||
export function encodeEmulatorScreenshotPng(bytes: ArrayBuffer): Buffer {
|
||||
const image = nativeImage.createFromBuffer(bufferFromArrayBuffer(bytes))
|
||||
if (image.isEmpty()) {
|
||||
throw new Error('Emulator screenshot frame is not a valid image.')
|
||||
}
|
||||
return image.toPNG()
|
||||
}
|
||||
|
||||
export function registerEmulatorScreenshotHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'emulator:saveScreenshot',
|
||||
async (event, args: SaveEmulatorScreenshotArgs): Promise<SaveEmulatorScreenshotResult> => {
|
||||
if (!(args?.bytes instanceof ArrayBuffer) || args.bytes.byteLength === 0) {
|
||||
throw new Error('Emulator screenshot frame is empty.')
|
||||
}
|
||||
|
||||
const pngBuffer = encodeEmulatorScreenshotPng(args.bytes)
|
||||
const defaultPath = buildEmulatorScreenshotDefaultPath(args.deviceName)
|
||||
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const dialogOptions = {
|
||||
defaultPath,
|
||||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||
}
|
||||
const result = parentWindow
|
||||
? await dialog.showSaveDialog(parentWindow, dialogOptions)
|
||||
: await dialog.showSaveDialog(dialogOptions)
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
await writeFile(result.filePath, pngBuffer)
|
||||
return { canceled: false, destinationPath: result.filePath }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,8 @@ const {
|
||||
registerSkillsHandlersMock,
|
||||
registerWorkspaceSpaceHandlersMock,
|
||||
registerWorkspacePortHandlersMock,
|
||||
registerEmulatorFrameStreamHandlersMock
|
||||
registerEmulatorFrameStreamHandlersMock,
|
||||
registerEmulatorScreenshotHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
@@ -99,7 +100,8 @@ const {
|
||||
registerSkillsHandlersMock: vi.fn(),
|
||||
registerWorkspaceSpaceHandlersMock: vi.fn(),
|
||||
registerWorkspacePortHandlersMock: vi.fn(),
|
||||
registerEmulatorFrameStreamHandlersMock: vi.fn()
|
||||
registerEmulatorFrameStreamHandlersMock: vi.fn(),
|
||||
registerEmulatorScreenshotHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./onboarding', () => ({
|
||||
@@ -214,6 +216,10 @@ vi.mock('./emulator-frame-stream', () => ({
|
||||
registerEmulatorFrameStreamHandlers: registerEmulatorFrameStreamHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./emulator-screenshot', () => ({
|
||||
registerEmulatorScreenshotHandlers: registerEmulatorScreenshotHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem', () => ({
|
||||
registerFilesystemHandlers: registerFilesystemHandlersMock
|
||||
}))
|
||||
@@ -339,6 +345,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerWorkspaceSpaceHandlersMock.mockReset()
|
||||
registerWorkspacePortHandlersMock.mockReset()
|
||||
registerEmulatorFrameStreamHandlersMock.mockReset()
|
||||
registerEmulatorScreenshotHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes the store through to handler registrars that need it', () => {
|
||||
@@ -403,6 +410,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled()
|
||||
expect(registerEmulatorScreenshotHandlersMock).toHaveBeenCalled()
|
||||
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -44,6 +44,7 @@ import { registerShellHandlers } from './shell'
|
||||
import { registerPetHandlers } from './pet'
|
||||
import { registerUIHandlers } from './ui'
|
||||
import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream'
|
||||
import { registerEmulatorScreenshotHandlers } from './emulator-screenshot'
|
||||
import { registerSpeechHandlers } from './speech'
|
||||
import { registerCodexAccountHandlers } from './codex-accounts'
|
||||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
@@ -147,6 +148,7 @@ export function registerCoreHandlers(
|
||||
registerSessionHandlers(store)
|
||||
registerUIHandlers(store)
|
||||
registerEmulatorFrameStreamHandlers()
|
||||
registerEmulatorScreenshotHandlers()
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
registerWorkspacePortHandlers(store)
|
||||
if (commitMessageAgentEnv) {
|
||||
|
||||
@@ -443,6 +443,10 @@ export type EmulatorApi = {
|
||||
streamId: string
|
||||
}>
|
||||
stopFrameStream: (args: { streamId: string }) => Promise<void>
|
||||
saveScreenshot: (args: {
|
||||
bytes: ArrayBuffer
|
||||
deviceName?: string
|
||||
}) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }>
|
||||
onFrameStreamFrame: (
|
||||
callback: (data: { streamId: string; bytes: ArrayBuffer }) => void
|
||||
) => () => void
|
||||
|
||||
@@ -2133,6 +2133,11 @@ const api = {
|
||||
}> => ipcRenderer.invoke('emulator:frameStreamStart', args),
|
||||
stopFrameStream: (args: { streamId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('emulator:frameStreamStop', args),
|
||||
saveScreenshot: (args: {
|
||||
bytes: ArrayBuffer
|
||||
deviceName?: string
|
||||
}): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> =>
|
||||
ipcRenderer.invoke('emulator:saveScreenshot', args),
|
||||
onFrameStreamFrame: (
|
||||
callback: (data: { streamId: string; bytes: ArrayBuffer }) => void
|
||||
): (() => void) => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Tab } from '../../../../shared/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { isMacOs } from './emulator-pane-types'
|
||||
import { EmulatorUnavailablePane } from './emulator-unavailable-pane'
|
||||
import { EmulatorPaneToolbar } from './emulator-pane-toolbar'
|
||||
import { EmulatorDeviceFrame } from './emulator-device-frame'
|
||||
import { useEmulatorPaneSession } from './use-emulator-pane-session'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
|
||||
type EmulatorPaneProps = {
|
||||
tab?: Tab
|
||||
@@ -22,6 +25,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
|
||||
}
|
||||
|
||||
function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneProps) {
|
||||
const [latestFrameBytes, setLatestFrameBytes] = useState<ArrayBuffer | null>(null)
|
||||
const {
|
||||
devices,
|
||||
selectedUdid,
|
||||
@@ -45,6 +49,56 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP
|
||||
autoAttachOnMount: isActive
|
||||
})
|
||||
|
||||
const handleFrameBytes = useCallback((bytes: ArrayBuffer | null) => {
|
||||
setLatestFrameBytes(bytes)
|
||||
}, [])
|
||||
|
||||
const handleSaveScreenshot = useCallback(async () => {
|
||||
if (!latestFrameBytes) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.emulator.pane.EmulatorPane.5d5fd13391',
|
||||
'No emulator frame is available yet.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.api.emulator.saveScreenshot({
|
||||
bytes: latestFrameBytes,
|
||||
deviceName: displayName
|
||||
})
|
||||
// Why: native save-dialog cancellation is an intentional user choice,
|
||||
// not a failed screenshot action.
|
||||
if (result.canceled) {
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
translate('auto.components.emulator.pane.EmulatorPane.360a8a4e68', 'Screenshot saved'),
|
||||
{
|
||||
action: result.destinationPath
|
||||
? {
|
||||
label: translate('auto.components.emulator.pane.EmulatorPane.1a3df04ae1', 'Open'),
|
||||
onClick: () => {
|
||||
void window.api.shell.openPath(result.destinationPath as string)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractIpcErrorMessage(
|
||||
error,
|
||||
translate(
|
||||
'auto.components.emulator.pane.EmulatorPane.bef0bb890f',
|
||||
'Failed to save screenshot.'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [displayName, latestFrameBytes])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background text-sm text-foreground">
|
||||
<EmulatorPaneToolbar
|
||||
@@ -58,9 +112,11 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP
|
||||
void attach(udid)
|
||||
}}
|
||||
onAttach={() => void attach(selectedUdid ?? undefined)}
|
||||
onSaveScreenshot={() => void handleSaveScreenshot()}
|
||||
onShutdown={() => void shutdown(selectedUdid ?? undefined)}
|
||||
onHome={() => void sendButton('home')}
|
||||
onRotate={() => void sendRotate()}
|
||||
canSaveScreenshot={isLive && Boolean(latestFrameBytes)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
@@ -85,6 +141,7 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP
|
||||
deviceName={displayName}
|
||||
loading={loading}
|
||||
isLive={isLive}
|
||||
onFrameBytes={handleFrameBytes}
|
||||
onTap={(x, y) => void sendTap(x, y)}
|
||||
onGesture={(points) => void sendGesture(points)}
|
||||
/>
|
||||
|
||||
@@ -39,6 +39,7 @@ type EmulatorDeviceFrameProps = {
|
||||
deviceName?: string
|
||||
loading: boolean
|
||||
isLive: boolean
|
||||
onFrameBytes?: (bytes: ArrayBuffer | null) => void
|
||||
onTap: (x: number, y: number) => void
|
||||
onGesture: (points: EmulatorGesturePoint[]) => void
|
||||
}
|
||||
@@ -53,11 +54,7 @@ type PendingWheelGesture = {
|
||||
timerId: number | null
|
||||
}
|
||||
|
||||
type ScreenCoordinateEvent = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
currentTarget: HTMLDivElement
|
||||
}
|
||||
type ScreenCoordinateEvent = PointerEvent<HTMLDivElement>
|
||||
|
||||
export function EmulatorDeviceFrame({
|
||||
previewUrl,
|
||||
@@ -66,6 +63,7 @@ export function EmulatorDeviceFrame({
|
||||
deviceName,
|
||||
loading,
|
||||
isLive,
|
||||
onFrameBytes,
|
||||
onTap,
|
||||
onGesture
|
||||
}: EmulatorDeviceFrameProps) {
|
||||
@@ -404,6 +402,7 @@ export function EmulatorDeviceFrame({
|
||||
chrome doubles up with iOS's real status bar and makes bezels lie. */}
|
||||
<EmulatorScreenStreamContent
|
||||
loading={loading}
|
||||
onFrameBytes={onFrameBytes}
|
||||
onStreamError={handleStreamError}
|
||||
onStreamSize={handleStreamSize}
|
||||
previewUrl={previewUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, Power, RotateCw, Smartphone } from 'lucide-react'
|
||||
import { Camera, Home, Power, RotateCw, Smartphone } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,9 +20,11 @@ type EmulatorPaneToolbarProps = {
|
||||
selectedUdid: string | null
|
||||
onSelectDevice: (udid: string) => void
|
||||
onAttach: () => void
|
||||
onSaveScreenshot: () => void
|
||||
onShutdown: () => void
|
||||
onHome: () => void
|
||||
onRotate: () => void
|
||||
canSaveScreenshot: boolean
|
||||
}
|
||||
|
||||
export function EmulatorPaneToolbar({
|
||||
@@ -33,9 +35,11 @@ export function EmulatorPaneToolbar({
|
||||
selectedUdid,
|
||||
onSelectDevice,
|
||||
onAttach,
|
||||
onSaveScreenshot,
|
||||
onShutdown,
|
||||
onHome,
|
||||
onRotate
|
||||
onRotate,
|
||||
canSaveScreenshot
|
||||
}: EmulatorPaneToolbarProps) {
|
||||
// Why: the toolbar chip describes Orca's preview/control stream, not the
|
||||
// lower-level CoreSimulator boot state.
|
||||
@@ -80,6 +84,30 @@ export function EmulatorPaneToolbar({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon-xs"
|
||||
className="size-7"
|
||||
onClick={onSaveScreenshot}
|
||||
disabled={!canSaveScreenshot || loading}
|
||||
aria-label={translate(
|
||||
'auto.components.emulator.pane.emulator.pane.toolbar.1e5dfdd399',
|
||||
'Save screenshot'
|
||||
)}
|
||||
>
|
||||
<Camera className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.emulator.pane.toolbar.1e5dfdd399',
|
||||
'Save screenshot'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -90,11 +90,12 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderStream(streamKey = 'abc'): Promise<void> {
|
||||
async function renderStream(streamKey = 'abc', onFrameBytes = vi.fn()): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmulatorScreenStreamContent
|
||||
loading={false}
|
||||
onFrameBytes={onFrameBytes}
|
||||
onStreamError={vi.fn()}
|
||||
onStreamSize={vi.fn()}
|
||||
previewUrl="http://127.0.0.1:3100/stream.mjpeg"
|
||||
@@ -108,7 +109,8 @@ async function renderStream(streamKey = 'abc'): Promise<void> {
|
||||
|
||||
describe('EmulatorScreenStreamContent', () => {
|
||||
it('renders IPC-delivered frames as blob URLs instead of loading the MJPEG URL directly', async () => {
|
||||
await renderStream()
|
||||
const onFrameBytes = vi.fn()
|
||||
await renderStream('abc', onFrameBytes)
|
||||
|
||||
expect(startFrameStream).toHaveBeenCalledWith({
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
@@ -116,12 +118,14 @@ describe('EmulatorScreenStreamContent', () => {
|
||||
})
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
|
||||
const frameBytes = new Uint8Array([1, 2, 3]).buffer
|
||||
await act(async () => {
|
||||
frameListeners[0]?.({ streamId: 'stream-1', bytes: new Uint8Array([1, 2, 3]).buffer })
|
||||
frameListeners[0]?.({ streamId: 'stream-1', bytes: frameBytes })
|
||||
})
|
||||
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe('blob:emulator-frame-1')
|
||||
expect(onFrameBytes).toHaveBeenLastCalledWith(frameBytes)
|
||||
})
|
||||
|
||||
it('clears the previous frame while a new stream key is connecting', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ type StreamSize = {
|
||||
|
||||
type EmulatorScreenStreamContentProps = {
|
||||
loading: boolean
|
||||
onFrameBytes?: (bytes: ArrayBuffer | null) => void
|
||||
onStreamError: () => void
|
||||
onStreamSize: (size: StreamSize) => void
|
||||
previewUrl?: string
|
||||
@@ -20,6 +21,7 @@ type EmulatorScreenStreamContentProps = {
|
||||
|
||||
export function EmulatorScreenStreamContent({
|
||||
loading,
|
||||
onFrameBytes,
|
||||
onStreamError,
|
||||
onStreamSize,
|
||||
previewUrl,
|
||||
@@ -39,6 +41,10 @@ export function EmulatorScreenStreamContent({
|
||||
}
|
||||
}, [frameStream.error, onStreamError])
|
||||
|
||||
useEffect(() => {
|
||||
onFrameBytes?.(frameStream.frameBytes)
|
||||
}, [frameStream.frameBytes, onFrameBytes])
|
||||
|
||||
if (showStream && frameStream.frameUrl) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -5,6 +5,7 @@ const FIRST_FRAME_TIMEOUT_MS = 6_000
|
||||
|
||||
type EmulatorFrameStreamState = {
|
||||
error: string | null
|
||||
frameBytes: ArrayBuffer | null
|
||||
frameUrl: string | null
|
||||
streamIdentity: string | null
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export function useEmulatorFrameStream(
|
||||
const streamIdentity = getFrameStreamIdentity(streamUrl, streamKey, enabled)
|
||||
const [state, setState] = useState<EmulatorFrameStreamState>({
|
||||
error: null,
|
||||
frameBytes: null,
|
||||
frameUrl: null,
|
||||
streamIdentity: null
|
||||
})
|
||||
@@ -37,7 +39,7 @@ export function useEmulatorFrameStream(
|
||||
useEffect(() => {
|
||||
const emulatorApi = window.api?.emulator
|
||||
if (!enabled || !streamUrl || !emulatorApi?.startFrameStream) {
|
||||
setState({ error: null, frameUrl: null, streamIdentity: null })
|
||||
setState({ error: null, frameBytes: null, frameUrl: null, streamIdentity: null })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,7 +81,7 @@ export function useEmulatorFrameStream(
|
||||
const nextFrameUrl = createFrameUrl(bytes)
|
||||
const previousFrameUrl = currentFrameUrlRef.current
|
||||
currentFrameUrlRef.current = nextFrameUrl
|
||||
setState({ error: null, frameUrl: nextFrameUrl, streamIdentity })
|
||||
setState({ error: null, frameBytes: bytes, frameUrl: nextFrameUrl, streamIdentity })
|
||||
if (previousFrameUrl) {
|
||||
URL.revokeObjectURL(previousFrameUrl)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export function useEmulatorFrameStream(
|
||||
|
||||
// Why: a device switch gets a fresh stream identity, so stale frames from
|
||||
// the previous device are hidden while the new MJPEG stream starts.
|
||||
setState({ error: null, frameUrl: null, streamIdentity })
|
||||
setState({ error: null, frameBytes: null, frameUrl: null, streamIdentity })
|
||||
void emulatorApi
|
||||
.startFrameStream({ streamUrl, streamKey })
|
||||
.then(({ streamId }) => {
|
||||
@@ -114,6 +116,7 @@ export function useEmulatorFrameStream(
|
||||
clearFirstFrameTimer()
|
||||
setState({
|
||||
error: error instanceof Error ? error.message : 'Stream disconnected',
|
||||
frameBytes: null,
|
||||
frameUrl: null,
|
||||
streamIdentity
|
||||
})
|
||||
@@ -132,7 +135,7 @@ export function useEmulatorFrameStream(
|
||||
}, [enabled, streamIdentity, streamKey, streamUrl])
|
||||
|
||||
if (state.streamIdentity !== streamIdentity) {
|
||||
return { error: null, frameUrl: null }
|
||||
return { error: null, frameBytes: null, frameUrl: null }
|
||||
}
|
||||
return { error: state.error, frameUrl: state.frameUrl }
|
||||
return { error: state.error, frameBytes: state.frameBytes, frameUrl: state.frameUrl }
|
||||
}
|
||||
|
||||
@@ -9207,7 +9207,11 @@
|
||||
"emulator": {
|
||||
"pane": {
|
||||
"EmulatorPane": {
|
||||
"59b08fa031": "No emulator connected"
|
||||
"59b08fa031": "No emulator connected",
|
||||
"5d5fd13391": "No emulator frame is available yet.",
|
||||
"360a8a4e68": "Screenshot saved",
|
||||
"1a3df04ae1": "Open",
|
||||
"bef0bb890f": "Failed to save screenshot."
|
||||
},
|
||||
"emulator": {
|
||||
"device": {
|
||||
@@ -9224,7 +9228,8 @@
|
||||
"6bd8dff42a": "Rotate",
|
||||
"3d836b879c": "Choose emulator",
|
||||
"81b3571a07": "Connect",
|
||||
"868c0f2938": "Working…"
|
||||
"868c0f2938": "Working…",
|
||||
"1e5dfdd399": "Save screenshot"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
|
||||
@@ -9207,7 +9207,11 @@
|
||||
"emulator": {
|
||||
"pane": {
|
||||
"EmulatorPane": {
|
||||
"59b08fa031": "Ningún emulador conectado"
|
||||
"59b08fa031": "Ningún emulador conectado",
|
||||
"5d5fd13391": "No emulator frame is available yet.",
|
||||
"360a8a4e68": "Screenshot saved",
|
||||
"1a3df04ae1": "Open",
|
||||
"bef0bb890f": "Failed to save screenshot."
|
||||
},
|
||||
"emulator": {
|
||||
"device": {
|
||||
@@ -9224,7 +9228,8 @@
|
||||
"6bd8dff42a": "Girar",
|
||||
"3d836b879c": "Elige emulador",
|
||||
"81b3571a07": "Conectar",
|
||||
"868c0f2938": "Laboral…"
|
||||
"868c0f2938": "Laboral…",
|
||||
"1e5dfdd399": "Save screenshot"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
|
||||
@@ -9207,7 +9207,11 @@
|
||||
"emulator": {
|
||||
"pane": {
|
||||
"EmulatorPane": {
|
||||
"59b08fa031": "エミュレータが接続されていません"
|
||||
"59b08fa031": "エミュレータが接続されていません",
|
||||
"5d5fd13391": "No emulator frame is available yet.",
|
||||
"360a8a4e68": "Screenshot saved",
|
||||
"1a3df04ae1": "Open",
|
||||
"bef0bb890f": "Failed to save screenshot."
|
||||
},
|
||||
"emulator": {
|
||||
"device": {
|
||||
@@ -9224,7 +9228,8 @@
|
||||
"6bd8dff42a": "回転",
|
||||
"3d836b879c": "エミュレータの選択",
|
||||
"81b3571a07": "接続",
|
||||
"868c0f2938": "処理中…"
|
||||
"868c0f2938": "処理中…",
|
||||
"1e5dfdd399": "Save screenshot"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
|
||||
@@ -9207,7 +9207,11 @@
|
||||
"emulator": {
|
||||
"pane": {
|
||||
"EmulatorPane": {
|
||||
"59b08fa031": "연결된 에뮬레이터가 없습니다."
|
||||
"59b08fa031": "연결된 에뮬레이터가 없습니다.",
|
||||
"5d5fd13391": "No emulator frame is available yet.",
|
||||
"360a8a4e68": "Screenshot saved",
|
||||
"1a3df04ae1": "Open",
|
||||
"bef0bb890f": "Failed to save screenshot."
|
||||
},
|
||||
"emulator": {
|
||||
"device": {
|
||||
@@ -9224,7 +9228,8 @@
|
||||
"6bd8dff42a": "회전",
|
||||
"3d836b879c": "에뮬레이터 선택",
|
||||
"81b3571a07": "연결",
|
||||
"868c0f2938": "일하고 있는…"
|
||||
"868c0f2938": "일하고 있는…",
|
||||
"1e5dfdd399": "Save screenshot"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
|
||||
@@ -9207,7 +9207,11 @@
|
||||
"emulator": {
|
||||
"pane": {
|
||||
"EmulatorPane": {
|
||||
"59b08fa031": "没有连接模拟器"
|
||||
"59b08fa031": "没有连接模拟器",
|
||||
"5d5fd13391": "No emulator frame is available yet.",
|
||||
"360a8a4e68": "Screenshot saved",
|
||||
"1a3df04ae1": "Open",
|
||||
"bef0bb890f": "Failed to save screenshot."
|
||||
},
|
||||
"emulator": {
|
||||
"device": {
|
||||
@@ -9224,7 +9228,8 @@
|
||||
"6bd8dff42a": "旋转",
|
||||
"3d836b879c": "选择模拟器",
|
||||
"81b3571a07": "连接",
|
||||
"868c0f2938": "处理中…"
|
||||
"868c0f2938": "处理中…",
|
||||
"1e5dfdd399": "Save screenshot"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
|
||||
@@ -1568,6 +1568,7 @@ function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> {
|
||||
onAutoAttach: () => noopUnsubscribe,
|
||||
startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')),
|
||||
stopFrameStream: () => Promise.resolve(),
|
||||
saveScreenshot: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')),
|
||||
onFrameStreamFrame: () => noopUnsubscribe,
|
||||
onFrameStreamError: () => noopUnsubscribe
|
||||
} as unknown as NonNullable<Partial<PreloadApi>['emulator']>
|
||||
|
||||
Reference in New Issue
Block a user