Add mobile emulator screenshot export

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 14:57:53 -07:00
co-authored by Orca
parent 64072f0128
commit d3c77f3e1f
18 changed files with 359 additions and 27 deletions
+119
View File
@@ -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()
})
})
+71
View File
@@ -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 }
}
)
}
+10 -2
View File
@@ -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()
+2
View File
@@ -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) {
+4
View File
@@ -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
+5
View File
@@ -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 }
}
+7 -2
View File
@@ -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": {
+7 -2
View File
@@ -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": {
+7 -2
View File
@@ -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": {
+7 -2
View File
@@ -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": {
+7 -2
View File
@@ -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": {
+1
View File
@@ -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']>