iOS get devices/targets/simulators cleanup

Summary: ^

Reviewed By: passy

Differential Revision: D48781211

fbshipit-source-id: 71133c07d15ca6a380d85e582d55cbdb192b5a19
This commit is contained in:
Lorenzo Blasa
2023-08-30 04:24:05 -07:00
committed by Facebook GitHub Bot
parent 0045f15e2a
commit 3e8f94ceda
9 changed files with 170 additions and 180 deletions

View File

@@ -7,6 +7,8 @@
* @format
*/
import {DeviceType, OS} from './server-types';
export interface PluginDetails {
name: string;
specVersion: number;
@@ -57,17 +59,6 @@ export interface SupportedApp {
readonly type?: DeviceType;
}
export type OS =
| 'iOS'
| 'Android'
| 'Metro'
| 'Windows'
| 'MacOS'
| 'Browser'
| 'Linux';
export type DeviceType = 'emulator' | 'physical' | 'dummy';
export type PluginType = 'client' | 'device';
export type DeviceSpec = 'KaiOS';

View File

@@ -10,11 +10,9 @@
import {FlipperDoctor} from './doctor';
import {
DeviceSpec,
DeviceType,
DownloadablePluginDetails,
InstalledPluginDetails,
MarketplacePluginDetails,
OS as PluginOS,
UpdatablePluginDetails,
} from './PluginDetails';
import {ServerAddOnStartDetails} from './ServerAddOn';
@@ -39,7 +37,7 @@ export type FlipperServerState =
| 'error'
| 'closed';
export type DeviceOS = PluginOS;
export type DeviceOS = OS;
export type DeviceDescription = {
readonly os: DeviceOS;
@@ -172,12 +170,22 @@ export type FlipperServerEvents = {
'server-log': LoggerInfo;
};
export type IOSDeviceParams = {
export type OS =
| 'iOS'
| 'Android'
| 'Metro'
| 'Windows'
| 'MacOS'
| 'Browser'
| 'Linux';
export type DeviceType = 'physical' | 'emulator' | 'dummy';
export type DeviceTarget = {
udid: string;
type: DeviceType;
name: string;
osVersion?: string;
deviceTypeIdentifier?: string;
state?: string;
};
@@ -298,7 +306,7 @@ export type FlipperServerCommands = {
'android-get-emulators': () => Promise<string[]>;
'android-launch-emulator': (name: string, coldboot: boolean) => Promise<void>;
'android-adb-kill': () => Promise<void>;
'ios-get-simulators': (bootedOnly: boolean) => Promise<IOSDeviceParams[]>;
'ios-get-simulators': (bootedOnly: boolean) => Promise<DeviceTarget[]>;
'ios-launch-simulator': (udid: string) => Promise<void>;
'ios-idb-kill': () => Promise<void>;
'persist-settings': (settings: Settings) => Promise<void>;

View File

@@ -496,7 +496,7 @@ export class FlipperServerImpl implements FlipperServer {
},
'ios-launch-simulator': async (udid) => {
assertNotNull(this.ios);
return this.ios.simctlBridge.launchSimulator(udid);
return this.ios.launchSimulator(udid);
},
'ios-idb-kill': async () => {
assertNotNull(this.ios);

View File

@@ -8,10 +8,14 @@
*/
import fs from 'fs-extra';
import iosUtil from './iOSContainerUtility';
import iosUtil, {
getDeviceSetPath,
isIdbAvailable,
queryTargetsWithXcode,
} from './iOSContainerUtility';
import child_process from 'child_process';
import type {IOSDeviceParams} from 'flipper-common';
import type {DeviceTarget} from 'flipper-common';
import {DeviceType, uuid} from 'flipper-common';
import path from 'path';
import {ChildProcessPromise, exec, execFile} from 'promisify-child-process';
@@ -42,16 +46,6 @@ interface IOSInstalledAppDescriptor {
debuggableStatus: boolean;
}
function getOSVersionFromXCRunOutput(s: string): string | undefined {
// E.g. 'com.apple.CoreSimulator.SimRuntime.iOS-16-1'
const match = s.match(
/com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)/,
);
if (match) {
return `${match[1]}.${match[2]}`;
}
}
export interface IOSBridge {
startLogListener: (
udid: string,
@@ -63,7 +57,7 @@ export interface IOSBridge {
serial: string,
outputFile: string,
) => child_process.ChildProcess;
getActiveDevices: (bootedOnly: boolean) => Promise<Array<IOSDeviceParams>>;
getActiveDevices: (bootedOnly: boolean) => Promise<Array<DeviceTarget>>;
installApp: (
serial: string,
ipaPath: string,
@@ -77,6 +71,7 @@ export interface IOSBridge {
bundleId: string,
dst: string,
) => Promise<void>;
launchSimulator(udid: string): Promise<any>;
}
export class IDBBridge implements IOSBridge {
@@ -84,6 +79,10 @@ export class IDBBridge implements IOSBridge {
private idbPath: string,
private enablePhysicalDevices: boolean,
) {}
async launchSimulator(udid: string): Promise<any> {
await this._execIdb(`boot --udid ${udid}`);
await execFile('open', ['-a', 'simulator']);
}
async getInstalledApps(serial: string): Promise<IOSInstalledAppDescriptor[]> {
const {stdout} = await this._execIdb(`list-apps --udid ${serial}`);
@@ -150,9 +149,9 @@ export class IDBBridge implements IOSBridge {
await this._execIdb(`install ${ipaPath} --udid ${serial}`);
}
async getActiveDevices(_bootedOnly: boolean): Promise<IOSDeviceParams[]> {
async getActiveDevices(bootedOnly: boolean): Promise<DeviceTarget[]> {
return iosUtil
.targets(this.idbPath, this.enablePhysicalDevices)
.targets(this.idbPath, this.enablePhysicalDevices, bootedOnly)
.catch((e) => {
console.warn('Failed to get active iOS devices:', e.message);
return [];
@@ -285,32 +284,11 @@ export class SimctlBridge implements IOSBridge {
);
}
async getActiveDevices(bootedOnly: boolean): Promise<Array<IOSDeviceParams>> {
return execFile('xcrun', [
'simctl',
...getDeviceSetPath(),
'list',
'devices',
'--json',
])
.then(({stdout}) => JSON.parse(stdout!.toString()).devices)
.then((simulatorDevices: {[key: string]: Array<iOSSimulatorDevice>}) =>
Object.keys(simulatorDevices).flatMap((key: string) =>
simulatorDevices[key]
.filter(
(simulator: iOSSimulatorDevice) =>
(!bootedOnly || simulator.state === 'Booted') &&
isSimulatorAvailable(simulator),
)
.map((simulator: iOSSimulatorDevice) => {
return {
...simulator,
type: 'emulator',
osVersion: getOSVersionFromXCRunOutput(key),
} as IOSDeviceParams;
}),
),
);
async getActiveDevices(bootedOnly: boolean): Promise<Array<DeviceTarget>> {
const devices = await queryTargetsWithXcode();
return devices.filter(
(target) => !bootedOnly || (bootedOnly && target.state === 'booted'),
);
}
async launchSimulator(udid: string): Promise<any> {
@@ -319,27 +297,6 @@ export class SimctlBridge implements IOSBridge {
}
}
function isSimulatorAvailable(simulator: iOSSimulatorDevice): boolean {
// For some users "availability" is set, for others it's "isAvailable"
// It's not clear which key is set, so we are checking both.
// We've also seen isAvailable return "YES" and true, depending on version.
return (
simulator.availability === '(available)' ||
simulator.isAvailable === 'YES' ||
simulator.isAvailable === true
);
}
async function isIdbAvailable(idbPath: string): Promise<boolean> {
if (!idbPath) {
return false;
}
return fs.promises
.access(idbPath, fs.constants.X_OK)
.then((_) => true)
.catch((_) => false);
}
function getLogExtraArgs(deviceType: DeviceType) {
if (deviceType === 'physical') {
return [
@@ -364,7 +321,6 @@ function makeTempScreenshotFilePath() {
}
async function unzip(filePath: string, destination: string): Promise<void> {
// TODO: probably shouldn't involve shelling out.
await exec(`unzip -qq -o ${filePath} -d ${destination}`);
if (!(await fs.pathExists(path.join(destination, 'Payload')))) {
throw new Error(
@@ -379,12 +335,6 @@ async function readScreenshotIntoBuffer(imagePath: string): Promise<Buffer> {
return buffer;
}
export function getDeviceSetPath() {
return process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
}
export async function makeIOSBridge(
idbPath: string,
isXcodeDetected: boolean,

View File

@@ -14,12 +14,12 @@ import {
getFlipperServerConfig,
setFlipperServerConfig,
} from '../../../FlipperServerConfig';
import {IOSDeviceParams} from 'flipper-common';
import {DeviceTarget} from 'flipper-common';
let fakeSimctlBridge: any;
let fakeIDBBridge: any;
let fakeFlipperServer: any;
const fakeDevices: IOSDeviceParams[] = [
const fakeDevices: DeviceTarget[] = [
{
udid: 'luke',
type: 'emulator',
@@ -122,7 +122,7 @@ test('test queryDevices when simctl used', async () => {
fakeFlipperServer,
getFlipperServerConfig().settings,
);
ios.simctlBridge = fakeSimctlBridge;
ios.ctlBridge = fakeSimctlBridge;
await ios.queryDevices(fakeSimctlBridge);
@@ -145,7 +145,7 @@ test('test queryDevices when idb used', async () => {
fakeFlipperServer,
getFlipperServerConfig().settings,
);
ios.simctlBridge = fakeSimctlBridge;
ios.ctlBridge = fakeSimctlBridge;
await ios.queryDevices(fakeIDBBridge);

View File

@@ -48,6 +48,7 @@ export default class iOSCertificateProvider extends CertificateProvider {
const targets = await iosUtil.targets(
this.idbConfig.idbPath,
this.idbConfig.enablePhysicalIOS,
true,
clientQuery,
);
if (targets.length === 0) {

View File

@@ -8,11 +8,10 @@
*/
import {Mutex} from 'async-mutex';
import {exec as unsafeExec, Output} from 'promisify-child-process';
import {reportPlatformFailures} from 'flipper-common';
import {exec as unsafeExec, Output, execFile} from 'promisify-child-process';
import {DeviceTarget, DeviceType, reportPlatformFailures} from 'flipper-common';
import {promises, constants} from 'fs';
import memoize from 'lodash.memoize';
import {notNull} from '../../utils/typeUtils';
import {promisify} from 'util';
import child_process from 'child_process';
import fs from 'fs-extra';
@@ -25,6 +24,25 @@ export type IdbConfig = {
enablePhysicalIOS: boolean;
};
export type IdbTarget = {
udid: string;
type: string;
name: string;
os_version: string;
architecture: string;
state?: string;
target_type?: string | DeviceType;
};
export type XcodeTarget = {
state: 'Booted' | 'Shutdown' | 'Shutting Down';
availability?: string;
isAvailable?: 'YES' | 'NO' | true | false;
name: string;
osVersion?: string;
udid: string;
};
// Use debug to get helpful logs when idb fails
const IDB_LOG_LEVEL = 'DEBUG';
const LOG_TAG = 'iOSContainerUtility';
@@ -32,30 +50,11 @@ const CMD_RECORD_THROTTLE_COUNT = 10;
const mutex = new Mutex();
type IdbTarget = {
name: string;
udid: string;
state: 'Booted' | 'Shutdown';
type: string | DeviceType;
target_type?: string | DeviceType;
os_version: string;
architecture: string;
};
export type DeviceType = 'physical' | 'emulator';
export type DeviceTarget = {
udid: string;
type: DeviceType;
name: string;
osVersion?: string;
};
let idbDeviceListing = 0;
let idbCompanionDeviceListing = 0;
let xcodeDeviceListing = 0;
async function isAvailable(idbPath: string): Promise<boolean> {
export async function isIdbAvailable(idbPath: string): Promise<boolean> {
if (!idbPath) {
return false;
}
@@ -74,16 +73,50 @@ async function safeExec(
return await unsafeExec(command).finally(release);
}
async function queryTargetsWithXcode(
context: any,
export function getDeviceSetPath() {
return process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
}
export function isSimulatorAvailable(simulator: XcodeTarget): boolean {
// For some users "availability" is set, for others it's "isAvailable"
// It's not clear which key is set, so we are checking both.
// We've also seen isAvailable return "YES" and true, depending on version.
return (
simulator.availability === '(available)' ||
simulator.isAvailable === 'YES' ||
simulator.isAvailable === true
);
}
function getOSVersionFromXCRunOutput(s: string): string | undefined {
// E.g. 'com.apple.CoreSimulator.SimRuntime.iOS-16-1'
const match = s.match(
/com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)/,
);
if (match) {
return `${match[1]}.${match[2]}`;
}
}
export async function queryTargetsWithXcode(
context?: any,
): Promise<Array<DeviceTarget>> {
const cmd = 'xcrun xctrace list devices';
const cmd = 'xcrun simctl list devices --json';
const description = 'Query available devices with Xcode';
const troubleshoot = `Xcode command line tools are not installed.
Run 'xcode-select --install' from terminal.`;
try {
const {stdout} = await safeExec(cmd);
const {stdout} = await execFile('xcrun', [
'simctl',
...getDeviceSetPath(),
'list',
'devices',
'--json',
]);
if (!stdout) {
recorder.event('cmd', {
cmd,
@@ -105,17 +138,22 @@ async function queryTargetsWithXcode(
});
}
return stdout
.toString()
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => /(.+) \([^(]+\) \[(.*)\]( \(Simulator\))?/.exec(line))
.filter(notNull)
.filter(([_match, _name, _udid, isSim]) => !isSim)
.map<DeviceTarget>(([_match, name, udid]) => {
return {udid, type: 'physical', name};
});
const devices = JSON.parse(stdout.toString()).devices as {
[key: string]: Array<XcodeTarget>;
};
return Object.keys(devices).flatMap((key: string) =>
devices[key]
.filter((simulator: XcodeTarget) => isSimulatorAvailable(simulator))
.map((simulator: XcodeTarget) => {
return {
...simulator,
type: 'emulator',
state: simulator.state.toLowerCase(),
osVersion: getOSVersionFromXCRunOutput(key),
} as DeviceTarget;
}),
);
} catch (e) {
recorder.event('cmd', {
cmd,
@@ -176,7 +214,7 @@ async function queryTargetsWithIdb(
}
}
async function queryTargetsWithIdbCompanion(
async function _queryTargetsWithIdbCompanion(
idbCompanionPath: string,
isPhysicalDeviceEnabled: boolean,
context: any,
@@ -187,7 +225,7 @@ async function queryTargetsWithIdbCompanion(
const troubleshoot = `Unable to locate idb_companion in '${idbCompanionPath}'.
Try running sudo yum install -y fb-idb`;
if (await isAvailable(idbCompanionPath)) {
if (await isIdbAvailable(idbCompanionPath)) {
try {
const {stdout} = await safeExec(cmd);
if (!stdout) {
@@ -244,9 +282,6 @@ async function queryTargetsWithIdbCompanion(
function parseIdbTarget(line: string): DeviceTarget | undefined {
const parsed: IdbTarget = JSON.parse(line);
if (parsed.state.toLocaleLowerCase() !== 'booted') {
return;
}
return {
udid: parsed.udid,
type:
@@ -255,6 +290,7 @@ function parseIdbTarget(line: string): DeviceTarget | undefined {
: ('physical' as DeviceType),
name: parsed.name,
osVersion: parsed.os_version,
state: parsed.state?.toLocaleLowerCase(),
};
}
@@ -331,45 +367,36 @@ async function idbDescribeTarget(
async function targets(
idbPath: string,
isPhysicalDeviceEnabled: boolean,
bootedOnly: boolean = false,
context?: any,
): Promise<Array<DeviceTarget>> {
if (process.platform !== 'darwin') {
return [];
}
const bootedFilter = (targets: DeviceTarget[] | undefined) => {
return targets
? targets.filter(
(target) => !bootedOnly || (bootedOnly && target.state === 'booted'),
)
: [];
};
// If companion is started by some external process and its path
// is provided to Flipper via IDB_COMPANION environment variable,
// use that instead and do not query other devices.
// See stack of D36315576 for details
if (process.env.IDB_COMPANION) {
const target = await idbDescribeTarget(idbPath, context);
return target ? [target] : [];
return bootedFilter(target ? [target] : []);
}
const isXcodeInstalled = await isXcodeDetected();
if (!isXcodeInstalled) {
if (!isPhysicalDeviceEnabled) {
recorder.rawError(
'You are trying to connect a physical device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
);
}
const idbCompanionPath = path.dirname(idbPath) + '/idb_companion';
return queryTargetsWithIdbCompanion(
idbCompanionPath,
isPhysicalDeviceEnabled,
context,
);
}
// Not all users have idb installed because you can still use
// Flipper with Simulators without it.
// But idb is MUCH more CPU efficient than xcrun, so
// when installed, use it. This still holds true
// with the move from instruments to xcrun.
if (await memoize(isAvailable)(idbPath)) {
return await queryTargetsWithIdb(idbPath, context);
if (await memoize(isIdbAvailable)(idbPath)) {
const targets = await queryTargetsWithIdb(idbPath, context);
return bootedFilter(targets);
} else {
return queryTargetsWithXcode(context);
const targets = await queryTargetsWithXcode(context);
return bootedFilter(targets);
}
}
@@ -459,7 +486,7 @@ async function pull(
}
async function checkIdbIsInstalled(idbPath: string): Promise<void> {
const isInstalled = await isAvailable(idbPath);
const isInstalled = await isIdbAvailable(idbPath);
if (!isInstalled) {
throw new Error(
`idb is required to use iOS devices. Install it with instructions

View File

@@ -8,7 +8,7 @@
*/
import {ChildProcess} from 'child_process';
import type {IOSDeviceParams} from 'flipper-common';
import type {DeviceTarget} from 'flipper-common';
import path from 'path';
import childProcess from 'child_process';
import {exec} from 'promisify-child-process';
@@ -18,7 +18,6 @@ import {
ERR_NO_IDB_OR_XCODE_AVAILABLE,
IOSBridge,
makeIOSBridge,
SimctlBridge,
} from './IOSBridge';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
@@ -34,7 +33,7 @@ export class IOSDeviceManager {
'MacOS',
'PortForwardingMacApp',
);
simctlBridge: SimctlBridge = new SimctlBridge();
ctlBridge: IOSBridge | undefined;
readonly certificateProvider: iOSCertificateProvider;
@@ -102,7 +101,7 @@ export class IOSDeviceManager {
return this.processDevices(bridge, devices);
}
private processDevices(bridge: IOSBridge, activeDevices: IOSDeviceParams[]) {
private processDevices(bridge: IOSBridge, activeDevices: DeviceTarget[]) {
const currentDeviceIDs = new Set(
this.flipperServer
.getDevices()
@@ -134,9 +133,23 @@ export class IOSDeviceManager {
});
}
async getBridge(): Promise<IOSBridge> {
if (this.ctlBridge !== undefined) {
return this.ctlBridge;
}
const isDetected = await iosUtil.isXcodeDetected();
this.ctlBridge = await makeIOSBridge(
this.idbConfig.idbPath,
isDetected,
this.idbConfig.enablePhysicalIOS,
);
return this.ctlBridge;
}
public async watchIOSDevices() {
try {
const isDetected = await iosUtil.isXcodeDetected();
if (this.idbConfig.enablePhysicalIOS) {
this.startDevicePortForwarders();
}
@@ -144,11 +157,7 @@ export class IOSDeviceManager {
// Check for version mismatch now for immediate error handling.
await this.checkXcodeVersionMismatch();
// Awaiting the promise here to trigger immediate error handling.
const bridge = await makeIOSBridge(
this.idbConfig.idbPath,
isDetected,
this.idbConfig.enablePhysicalIOS,
);
const bridge = await this.getBridge();
await this.queryDevicesForever(bridge);
} catch (err) {
// This case is expected if both Xcode and idb are missing.
@@ -166,9 +175,10 @@ export class IOSDeviceManager {
}
}
async getSimulators(bootedOnly: boolean): Promise<Array<IOSDeviceParams>> {
async getSimulators(bootedOnly: boolean): Promise<Array<DeviceTarget>> {
try {
return await this.simctlBridge.getActiveDevices(bootedOnly);
const bridge = await this.getBridge();
return await bridge.getActiveDevices(bootedOnly);
} catch (e) {
console.warn('Failed to query simulators:', e);
if (e.message.includes('Xcode license agreements')) {
@@ -183,6 +193,15 @@ export class IOSDeviceManager {
}
}
async launchSimulator(udid: string) {
try {
const bridge = await this.getBridge();
await bridge.launchSimulator(udid);
} catch (e) {
console.warn('Failed to launch simulator:', e);
}
}
private async queryDevicesForever(bridge: IOSBridge) {
try {
await this.queryDevices(bridge);

View File

@@ -26,7 +26,7 @@ import {
theme,
} from 'flipper-plugin';
import {Provider} from 'react-redux';
import {IOSDeviceParams} from 'flipper-common';
import {DeviceTarget} from 'flipper-common';
import {getRenderHostInstance} from 'flipper-frontend-core';
import SettingsSheet from '../../chrome/SettingsSheet';
import {Link} from '../../ui';
@@ -88,7 +88,7 @@ export const LaunchEmulatorDialog = withTrackingScope(
(state) => state.settingsState.enableAndroid,
);
const [iosEmulators, setIosEmulators] = useState<IOSDeviceParams[]>([]);
const [iosEmulators, setIosEmulators] = useState<DeviceTarget[]>([]);
const [androidEmulators, setAndroidEmulators] = useState<string[]>([]);
const [waitingForIos, setWaitingForIos] = useState(iosEnabled);
const [waitingForAndroid, setWaitingForAndroid] = useState(androidEnabled);
@@ -113,13 +113,7 @@ export const LaunchEmulatorDialog = withTrackingScope(
.flipperServer.exec('ios-get-simulators', false)
.then((emulators) => {
setWaitingForIos(false);
setIosEmulators(
emulators.filter(
(device) =>
device.state === 'Shutdown' &&
device.deviceTypeIdentifier?.match(/iPhone|iPad/i),
),
);
setIosEmulators(emulators);
})
.catch((e) => {
console.warn('Failed to find simulators', e);