Introduce emulatur launch dialog

Summary:
Changelog: Flipper can now launch iOS simulators by using `File > Launch Emulator...`

In the Sandy designs the device selector dropdown no longer shows the option to launch an emulator. So added a button to app inspect and the main menu instead.

I found it always a bit funny that we can launch android emulators, but not iOS emulators. Turns out that launching them is actually not very complex, so added capabilities to launch ios emulators

Reviewed By: jknoxville

Differential Revision: D24021737

fbshipit-source-id: c106cc2246921e008f9c808ebb811d8e333aa93b
This commit is contained in:
Michel Weststrate
2020-10-01 05:32:07 -07:00
committed by Facebook GitHub Bot
parent 4e6ecac43e
commit 17baa3084c
9 changed files with 273 additions and 45 deletions

View File

@@ -28,6 +28,7 @@ import constants from './fb-stubs/constants';
import {Logger} from './fb-interfaces/Logger'; import {Logger} from './fb-interfaces/Logger';
import {NormalizedMenuEntry, buildInMenuEntries} from 'flipper-plugin'; import {NormalizedMenuEntry, buildInMenuEntries} from 'flipper-plugin';
import {StyleGuide} from './sandy-chrome/StyleGuide'; import {StyleGuide} from './sandy-chrome/StyleGuide';
import {showEmulatorLauncher} from './sandy-chrome/appinspect/LaunchEmulator';
export type DefaultKeyboardAction = keyof typeof buildInMenuEntries; export type DefaultKeyboardAction = keyof typeof buildInMenuEntries;
export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help';
@@ -226,6 +227,12 @@ function getTemplate(
}); });
} }
const fileSubmenu: MenuItemConstructorOptions[] = [ const fileSubmenu: MenuItemConstructorOptions[] = [
{
label: 'Launch Emulator...',
click() {
showEmulatorLauncher(store);
},
},
{ {
label: 'Preferences', label: 'Preferences',
accelerator: 'Cmd+,', accelerator: 'Cmd+,',

View File

@@ -9,20 +9,19 @@
import {Button, styled} from 'flipper'; import {Button, styled} from 'flipper';
import {connect, ReactReduxContext} from 'react-redux'; import {connect, ReactReduxContext} from 'react-redux';
import {spawn} from 'child_process';
import {dirname} from 'path';
import {selectDevice, preferDevice} from '../reducers/connections'; import {selectDevice, preferDevice} from '../reducers/connections';
import { import {
setActiveSheet, setActiveSheet,
ActiveSheet, ActiveSheet,
ACTIVE_SHEET_JS_EMULATOR_LAUNCHER, ACTIVE_SHEET_JS_EMULATOR_LAUNCHER,
} from '../reducers/application'; } from '../reducers/application';
import which from 'which';
import {showOpenDialog} from '../utils/exportData'; import {showOpenDialog} from '../utils/exportData';
import BaseDevice from '../devices/BaseDevice'; import BaseDevice from '../devices/BaseDevice';
import React, {Component} from 'react'; import React, {Component} from 'react';
import {State} from '../reducers'; import {State} from '../reducers';
import GK from '../fb-stubs/GK'; import GK from '../fb-stubs/GK';
import {launchEmulator} from '../devices/AndroidDevice';
type StateFromProps = { type StateFromProps = {
selectedDevice: BaseDevice | null | undefined; selectedDevice: BaseDevice | null | undefined;
@@ -44,25 +43,8 @@ const DropdownButton = styled(Button)({
}); });
class DevicesButton extends Component<Props> { class DevicesButton extends Component<Props> {
launchEmulator = (name: string) => { launchEmulator = async (name: string) => {
// On Linux, you must run the emulator from the directory it's in because await launchEmulator(name);
// reasons ...
which('emulator')
.then((emulatorPath) => {
if (emulatorPath) {
const child = spawn(emulatorPath, [`@${name}`], {
detached: true,
cwd: dirname(emulatorPath),
});
child.stderr.on('data', (data) => {
console.error(`Android emulator error: ${data}`);
});
child.on('error', console.error);
} else {
throw new Error('Could not get emulator path');
}
})
.catch(console.error);
this.props.preferDevice(name); this.props.preferDevice(name);
}; };

View File

@@ -13,6 +13,9 @@ import {Priority} from 'adbkit-logcat';
import ArchivedDevice from './ArchivedDevice'; import ArchivedDevice from './ArchivedDevice';
import {createWriteStream} from 'fs'; import {createWriteStream} from 'fs';
import type {LogLevel, DeviceType} from 'flipper-plugin'; import type {LogLevel, DeviceType} from 'flipper-plugin';
import which from 'which';
import {spawn} from 'child_process';
import {dirname} from 'path';
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder'; const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
@@ -189,3 +192,24 @@ export default class AndroidDevice extends BaseDevice {
return destination; return destination;
} }
} }
export async function launchEmulator(name: string) {
// On Linux, you must run the emulator from the directory it's in because
// reasons ...
return which('emulator')
.then((emulatorPath) => {
if (emulatorPath) {
const child = spawn(emulatorPath, [`@${name}`], {
detached: true,
cwd: dirname(emulatorPath),
});
child.stderr.on('data', (data) => {
console.error(`Android emulator error: ${data}`);
});
child.on('error', (e) => console.error(e));
} else {
throw new Error('Could not get emulator path');
}
})
.catch((e) => console.error(e));
}

View File

@@ -30,7 +30,13 @@ type iOSSimulatorDevice = {
udid: string; udid: string;
}; };
type IOSDeviceParams = {udid: string; type: DeviceType; name: string}; export type IOSDeviceParams = {
udid: string;
type: DeviceType;
name: string;
deviceTypeIdentifier?: string;
state?: string;
};
const exec = promisify(child_process.exec); const exec = promisify(child_process.exec);
@@ -79,7 +85,7 @@ if (typeof window !== 'undefined') {
async function queryDevices(store: Store, logger: Logger): Promise<any> { async function queryDevices(store: Store, logger: Logger): Promise<any> {
return Promise.all([ return Promise.all([
checkXcodeVersionMismatch(store), checkXcodeVersionMismatch(store),
getActiveSimulators().then((devices) => { getSimulators(true).then((devices) => {
processDevices(store, logger, devices, 'emulator'); processDevices(store, logger, devices, 'emulator');
}), }),
getActiveDevices(store.getState().settingsState.idbPath).then( getActiveDevices(store.getState().settingsState.idbPath).then(
@@ -144,36 +150,50 @@ function processDevices(
} }
} }
function getActiveSimulators(): Promise<Array<IOSDeviceParams>> { function getDeviceSetPath() {
const deviceSetPath = process.env.DEVICE_SET_PATH return process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH] ? ['--set', process.env.DEVICE_SET_PATH]
: []; : [];
}
export function getSimulators(
bootedOnly: boolean,
): Promise<Array<IOSDeviceParams>> {
return promisify(execFile)( return promisify(execFile)(
'xcrun', 'xcrun',
['simctl', ...deviceSetPath, 'list', 'devices', '--json'], ['simctl', ...getDeviceSetPath(), 'list', 'devices', '--json'],
{ {
encoding: 'utf8', encoding: 'utf8',
}, },
) )
.then(({stdout}) => JSON.parse(stdout).devices) .then(({stdout}) => JSON.parse(stdout).devices)
.then((simulatorDevices: Array<iOSSimulatorDevice>) => { .then((simulatorDevices: Array<iOSSimulatorDevice>) => {
const simulators: Array<iOSSimulatorDevice> = Object.values( const simulators = Object.values(simulatorDevices).flat();
simulatorDevices,
).reduce((acc: Array<iOSSimulatorDevice>, cv) => acc.concat(cv), []);
return simulators return simulators
.filter( .filter(
(simulator) => simulator.state === 'Booted' && isAvailable(simulator), (simulator) =>
(!bootedOnly || simulator.state === 'Booted') &&
isAvailable(simulator),
) )
.map((simulator) => { .map((simulator) => {
return { return {
udid: simulator.udid, ...simulator,
type: 'emulator', type: 'emulator',
name: simulator.name,
} as IOSDeviceParams; } as IOSDeviceParams;
}); });
}) })
.catch((_) => []); .catch((e) => {
console.error(e);
return Promise.resolve([]);
});
}
export function launchSimulator(udid: string): Promise<any> {
return promisify(execFile)(
'xcrun',
['simctl', ...getDeviceSetPath(), 'boot', udid],
{encoding: 'utf8'},
);
} }
function getActiveDevices(idbPath: string): Promise<Array<IOSDeviceParams>> { function getActiveDevices(idbPath: string): Promise<Array<IOSDeviceParams>> {
@@ -232,7 +252,7 @@ export async function getActiveDevicesAndSimulators(
store: Store, store: Store,
): Promise<Array<IOSDevice>> { ): Promise<Array<IOSDevice>> {
const activeDevices: Array<Array<IOSDeviceParams>> = await Promise.all([ const activeDevices: Array<Array<IOSDeviceParams>> = await Promise.all([
getActiveSimulators(), getSimulators(true),
getActiveDevices(store.getState().settingsState.idbPath), getActiveDevices(store.getState().settingsState.idbPath),
]); ]);
const allDevices = activeDevices[0].concat(activeDevices[1]); const allDevices = activeDevices[0].concat(activeDevices[1]);

View File

@@ -22,7 +22,7 @@ import {SandyContext} from './SandyContext';
import {ConsoleLogs} from '../chrome/ConsoleLogs'; import {ConsoleLogs} from '../chrome/ConsoleLogs';
import {setStaticView} from '../reducers/connections'; import {setStaticView} from '../reducers/connections';
import {toggleLeftSidebarVisible} from '../reducers/application'; import {toggleLeftSidebarVisible} from '../reducers/application';
import {AppInspect} from './AppInspect'; import {AppInspect} from './appinspect/AppInspect';
export type ToplevelNavItem = 'appinspect' | 'flipperlogs' | undefined; export type ToplevelNavItem = 'appinspect' | 'flipperlogs' | undefined;
export type ToplevelProps = { export type ToplevelProps = {

View File

@@ -9,14 +9,17 @@
import React from 'react'; import React from 'react';
import {Button, Dropdown, Menu, Radio, Input} from 'antd'; import {Button, Dropdown, Menu, Radio, Input} from 'antd';
import {LeftSidebar, SidebarTitle, InfoIcon} from './LeftSidebar'; import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar';
import { import {
AppleOutlined, AppleOutlined,
AndroidOutlined, AndroidOutlined,
SettingOutlined, SettingOutlined,
RocketOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {Layout, Link} from '../ui'; import {Layout, Link} from '../../ui';
import {theme} from './theme'; import {theme} from '../theme';
import {useStore as useReduxStore} from 'react-redux';
import {showEmulatorLauncher} from './LaunchEmulator';
const appTooltip = ( const appTooltip = (
<> <>
@@ -30,6 +33,7 @@ const appTooltip = (
); );
export function AppInspect() { export function AppInspect() {
const store = useReduxStore();
return ( return (
<LeftSidebar> <LeftSidebar>
<Layout.Top scrollable> <Layout.Top scrollable>
@@ -43,7 +47,14 @@ export function AppInspect() {
<Layout.Horizontal gap> <Layout.Horizontal gap>
<Button icon={<SettingOutlined />} type="link" /> <Button icon={<SettingOutlined />} type="link" />
<Button icon={<SettingOutlined />} type="link" /> <Button icon={<SettingOutlined />} type="link" />
<Button icon={<SettingOutlined />} type="link" /> <Button
icon={<RocketOutlined />}
type="link"
title="Start Emulator / Simulator..."
onClick={() => {
showEmulatorLauncher(store);
}}
/>
</Layout.Horizontal> </Layout.Horizontal>
</Layout.Vertical> </Layout.Vertical>
</Layout.Container> </Layout.Container>

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useEffect, useState} from 'react';
import {Modal, Button, message, Alert} from 'antd';
import {AndroidOutlined, AppleOutlined} from '@ant-design/icons';
import {renderReactRoot} from '../../utils/renderReactRoot';
import {Store} from '../../reducers';
import {useStore} from '../../utils/useStore';
import {launchEmulator} from '../../devices/AndroidDevice';
import Layout from '../../ui/components/Layout';
import {
launchSimulator,
getSimulators,
IOSDeviceParams,
} from '../../dispatcher/iOSDevice';
export function showEmulatorLauncher(store: Store) {
renderReactRoot(
(unmount) => (
<LaunchEmulatorDialog onClose={unmount} getSimulators={getSimulators} />
),
store,
);
}
type GetSimulators = typeof getSimulators;
export function LaunchEmulatorDialog({
onClose,
getSimulators,
}: {
onClose: () => void;
getSimulators: GetSimulators;
}) {
const iosEnabled = useStore((state) => state.settingsState.enableIOS);
const androidEmulators = useStore((state) =>
state.settingsState.enableAndroid ? state.connections.androidEmulators : [],
);
const [iosEmulators, setIosEmulators] = useState<IOSDeviceParams[]>([]);
useEffect(() => {
if (!iosEnabled) {
return;
}
getSimulators(false).then((emulators) => {
setIosEmulators(
emulators.filter(
(device) =>
device.state === 'Shutdown' &&
device.deviceTypeIdentifier?.match(/iPhone|iPad/i),
),
);
});
}, [iosEnabled, getSimulators]);
const items = [
...androidEmulators.map((name) => (
<Button
key={name}
icon={<AndroidOutlined />}
onClick={() => {
launchEmulator(name)
.catch((e) => {
console.error(e);
message.error('Failed to start emulator:' + e);
})
.finally(onClose);
}}>
{name}
</Button>
)),
...iosEmulators.map((device) => (
<Button
key={device.udid}
icon={<AppleOutlined />}
onClick={() => {
launchSimulator(device.udid)
.catch((e) => {
console.error(e);
message.error('Failed to start simulator:' + e);
})
.finally(onClose);
}}>
{device.name}
</Button>
)),
];
return (
<Modal
visible
onCancel={onClose}
title="Launch Emulator"
footer={null}
bodyStyle={{maxHeight: 400, overflow: 'auto'}}>
<Layout.Vertical gap>
{items.length ? items : <Alert message="No emulators available" />}
</Layout.Vertical>
</Modal>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {fireEvent, render} from '@testing-library/react';
import {Provider} from 'react-redux';
import {createStore} from 'redux';
import {LaunchEmulatorDialog} from '../LaunchEmulator';
import {rootReducer} from '../../../store';
import {act} from 'react-dom/test-utils';
import {sleep} from '../../../utils';
jest.mock('../../../devices/AndroidDevice', () => ({
launchEmulator: jest.fn(() => Promise.resolve([])),
}));
import {launchEmulator} from '../../../devices/AndroidDevice';
test('Can render and launch android apps', async () => {
const store = createStore(rootReducer);
const onClose = jest.fn();
const renderer = render(
<Provider store={store}>
<LaunchEmulatorDialog
onClose={onClose}
getSimulators={() => Promise.resolve([])}
/>
</Provider>,
);
expect(await renderer.findByText(/No emulators/)).toMatchInlineSnapshot(`
<span
class="ant-alert-message"
>
No emulators available
</span>
`);
act(() => {
store.dispatch({
type: 'REGISTER_ANDROID_EMULATORS',
payload: ['emulator1', 'emulator2'],
});
});
expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(`
Array [
<span>
emulator1
</span>,
<span>
emulator2
</span>,
]
`);
expect(onClose).not.toBeCalled();
fireEvent.click(renderer.getByText('emulator2'));
await sleep(1000);
expect(onClose).toBeCalled();
expect(launchEmulator).toBeCalledWith('emulator2');
});

View File

@@ -7,7 +7,10 @@
* @format * @format
*/ */
import React from 'react';
import {render, unmountComponentAtNode} from 'react-dom'; import {render, unmountComponentAtNode} from 'react-dom';
import {Provider} from 'react-redux';
import {Store} from '../reducers/';
/** /**
* This utility creates a fresh react render hook, which is great to render elements imperatively, like opening dialogs. * This utility creates a fresh react render hook, which is great to render elements imperatively, like opening dialogs.
@@ -15,13 +18,16 @@ import {render, unmountComponentAtNode} from 'react-dom';
*/ */
export function renderReactRoot( export function renderReactRoot(
handler: (unmount: () => void) => React.ReactElement, handler: (unmount: () => void) => React.ReactElement,
store: Store,
): void { ): void {
const div = document.body.appendChild(document.createElement('div')); const div = document.body.appendChild(document.createElement('div'));
render( render(
handler(() => { <Provider store={store}>
{handler(() => {
unmountComponentAtNode(div); unmountComponentAtNode(div);
div.remove(); div.remove();
}), })}
</Provider>,
div, div,
); );
} }