Move app/server to flipper-server-core

Summary: moved `app/src/server` to `flipper-server-core/src` and fixed any fallout from that (aka integration points I missed on the preparing diffs).

Reviewed By: passy

Differential Revision: D31541378

fbshipit-source-id: 8a7e0169ebefa515781f6e5e0f7b926415d4b7e9
This commit is contained in:
Michel Weststrate
2021-10-12 15:59:44 -07:00
committed by Facebook GitHub Bot
parent 3e7a6b1b4b
commit d88b28330a
73 changed files with 563 additions and 534 deletions

View File

@@ -0,0 +1,184 @@
/**
* 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 fs from 'fs-extra';
import child_process from 'child_process';
import {DeviceType} from 'flipper-plugin-lib';
import {v1 as uuid} from 'uuid';
import path from 'path';
import {exec} from 'promisify-child-process';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
export const ERR_NO_IDB_OR_XCODE_AVAILABLE =
'Neither Xcode nor idb available. Cannot provide iOS device functionality.';
export const ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB =
'Cannot provide logs from a physical device without idb.';
export interface IOSBridge {
startLogListener: (
udid: string,
deviceType: DeviceType,
) => child_process.ChildProcessWithoutNullStreams;
screenshot: (serial: string) => Promise<Buffer>;
navigate: (serial: string, location: string) => Promise<void>;
recordVideo: (
serial: string,
outputFile: string,
) => child_process.ChildProcess;
}
async function isAvailable(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 [
// idb has a --json option, but that doesn't actually work for physical
// devices!
];
} else {
return [
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
];
}
}
export function idbStartLogListener(
idbPath: string,
udid: string,
deviceType: DeviceType,
): child_process.ChildProcessWithoutNullStreams {
return child_process.spawn(
idbPath,
['log', '--udid', udid, '--', ...getLogExtraArgs(deviceType)],
{},
);
}
export function xcrunStartLogListener(udid: string, deviceType: DeviceType) {
if (deviceType === 'physical') {
throw new Error(ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB);
}
const deviceSetPath = process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
return child_process.spawn(
'xcrun',
[
'simctl',
...deviceSetPath,
'spawn',
udid,
'log',
'stream',
...getLogExtraArgs(deviceType),
],
{},
);
}
function makeTempScreenshotFilePath() {
const imageName = uuid() + '.png';
return path.join(getFlipperServerConfig().tmpPath, imageName);
}
async function runScreenshotCommand(
command: string,
imagePath: string,
): Promise<Buffer> {
await exec(command);
const buffer = await fs.readFile(imagePath);
await fs.unlink(imagePath);
return buffer;
}
export async function xcrunScreenshot(serial: string): Promise<Buffer> {
const imagePath = makeTempScreenshotFilePath();
const command = `xcrun simctl io ${serial} screenshot ${imagePath}`;
return runScreenshotCommand(command, imagePath);
}
export async function idbScreenshot(serial: string): Promise<Buffer> {
const imagePath = makeTempScreenshotFilePath();
const command = `idb screenshot --udid ${serial} ${imagePath}`;
return runScreenshotCommand(command, imagePath);
}
export async function xcrunNavigate(
serial: string,
location: string,
): Promise<void> {
exec(`xcrun simctl io ${serial} launch url "${location}"`);
}
export async function idbNavigate(
serial: string,
location: string,
): Promise<void> {
exec(`idb open --udid ${serial} "${location}"`);
}
export function xcrunRecordVideo(
serial: string,
outputFile: string,
): child_process.ChildProcess {
console.log(`Starting screen record via xcrun to ${outputFile}.`);
return exec(
`xcrun simctl io ${serial} recordVideo --codec=h264 --force ${outputFile}`,
);
}
export function idbRecordVideo(
serial: string,
outputFile: string,
): child_process.ChildProcess {
console.log(`Starting screen record via idb to ${outputFile}.`);
return exec(`idb record-video --udid ${serial} ${outputFile}`);
}
export async function makeIOSBridge(
idbPath: string,
isXcodeDetected: boolean,
isAvailableFn: (idbPath: string) => Promise<boolean> = isAvailable,
): Promise<IOSBridge> {
// prefer idb
if (await isAvailableFn(idbPath)) {
return {
startLogListener: idbStartLogListener.bind(null, idbPath),
screenshot: idbScreenshot,
navigate: idbNavigate,
recordVideo: idbRecordVideo,
};
}
// no idb, if it's a simulator and xcode is available, we can use xcrun
if (isXcodeDetected) {
return {
startLogListener: xcrunStartLogListener,
screenshot: xcrunScreenshot,
navigate: xcrunNavigate,
recordVideo: xcrunRecordVideo,
};
}
throw new Error(ERR_NO_IDB_OR_XCODE_AVAILABLE);
}

View File

@@ -0,0 +1,296 @@
/**
* 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 {
DeviceLogLevel,
DeviceLogEntry,
DeviceType,
timeout,
} from 'flipper-common';
import child_process, {ChildProcess} from 'child_process';
import JSONStream from 'JSONStream';
import {Transform} from 'stream';
import {ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB, IOSBridge} from './IOSBridge';
import split2 from 'split2';
import {ServerDevice} from '../ServerDevice';
import {FlipperServerImpl} from '../../FlipperServerImpl';
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
type RawLogEntry = {
eventMessage: string;
machTimestamp: number;
messageType: IOSLogLevel;
processID: number;
processImagePath: string;
processImageUUID: string;
processUniqueID: number;
senderImagePath: string;
senderImageUUID: string;
senderProgramCounter: number;
threadID: number;
timestamp: string;
timezoneName: string;
traceID: string;
};
// https://regex101.com/r/rrl03T/1
// Mar 25 17:06:38 iPhone symptomsd(SymptomEvaluator)[125] <Notice>: Stuff
const logRegex = /(^.{15}) ([^ ]+?) ([^\[]+?)\[(\d+?)\] <(\w+?)>: (.*)$/s;
export default class IOSDevice extends ServerDevice {
log?: child_process.ChildProcessWithoutNullStreams;
buffer: string;
private recordingProcess?: ChildProcess;
private recordingLocation?: string;
private iOSBridge: IOSBridge;
constructor(
flipperServer: FlipperServerImpl,
iOSBridge: IOSBridge,
serial: string,
deviceType: DeviceType,
title: string,
) {
super(flipperServer, {
serial,
deviceType,
title,
os: 'iOS',
icon: 'mobile',
});
this.buffer = '';
this.iOSBridge = iOSBridge;
}
async screenshot(): Promise<Buffer> {
if (!this.connected) {
return Buffer.from([]);
}
return await this.iOSBridge.screenshot(this.serial);
}
async navigateToLocation(location: string) {
return this.iOSBridge.navigate(this.serial, location).catch((err) => {
console.warn(`Failed to navigate to location ${location}:`, err);
return err;
});
}
startLogging() {
this.startLogListener(this.iOSBridge);
}
stopLogging() {
this.log?.kill();
}
startLogListener(iOSBridge: IOSBridge, retries: number = 3) {
if (retries === 0) {
console.warn('Attaching iOS log listener continuously failed.');
return;
}
if (!this.log) {
try {
this.log = iOSBridge.startLogListener(
this.serial,
this.info.deviceType,
);
} catch (e) {
if (e.message === ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB) {
console.warn(e);
} else {
console.error('Failed to initialise device logs:', e);
this.startLogListener(iOSBridge, retries - 1);
}
return;
}
this.log.on('error', (err: Error) => {
console.error('iOS log tailer error', err);
});
this.log.stderr.on('data', (data: Buffer) => {
console.warn('iOS log tailer stderr: ', data.toString());
});
this.log.on('exit', () => {
this.log = undefined;
});
try {
if (this.info.deviceType === 'physical') {
this.log.stdout.pipe(split2('\0')).on('data', (line: string) => {
const parsed = IOSDevice.parseLogLine(line);
if (parsed) {
this.addLogEntry(parsed);
} else {
console.warn('Failed to parse iOS log line: ', line);
}
});
} else {
this.log.stdout
.pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => {
const entry = IOSDevice.parseJsonLogEntry(data);
this.addLogEntry(entry);
});
}
} catch (e) {
console.error('Could not parse iOS log stream.', e);
// restart log stream
this.log.kill();
this.log = undefined;
this.startLogListener(iOSBridge, retries - 1);
}
}
}
static getLogLevel(level: string): DeviceLogLevel {
switch (level) {
case 'Default':
return 'debug';
case 'Info':
return 'info';
case 'Debug':
return 'debug';
case 'Error':
return 'error';
case 'Notice':
return 'verbose';
case 'Fault':
return 'fatal';
default:
return 'unknown';
}
}
static parseLogLine(line: string): DeviceLogEntry | undefined {
const matches = line.match(logRegex);
if (matches) {
return {
date: new Date(Date.parse(matches[1])),
tag: matches[3],
tid: 0,
pid: parseInt(matches[4], 10),
type: IOSDevice.getLogLevel(matches[5]),
message: matches[6],
};
}
return undefined;
}
static parseJsonLogEntry(entry: RawLogEntry): DeviceLogEntry {
let type: DeviceLogLevel = IOSDevice.getLogLevel(entry.messageType);
// when Apple log levels are not used, log messages can be prefixed with
// their loglevel.
if (entry.eventMessage.startsWith('[debug]')) {
type = 'debug';
} else if (entry.eventMessage.startsWith('[info]')) {
type = 'info';
} else if (entry.eventMessage.startsWith('[warn]')) {
type = 'warn';
} else if (entry.eventMessage.startsWith('[error]')) {
type = 'error';
}
// remove type from mesage
entry.eventMessage = entry.eventMessage.replace(
/^\[(debug|info|warn|error)\]/,
'',
);
const tag = entry.processImagePath.split('/').pop() || '';
return {
date: new Date(entry.timestamp),
pid: entry.processID,
tid: entry.threadID,
tag,
message: entry.eventMessage,
type,
};
}
async screenCaptureAvailable() {
return this.info.deviceType === 'emulator' && this.connected;
}
async startScreenCapture(destination: string) {
this.recordingProcess = this.iOSBridge.recordVideo(
this.serial,
destination,
);
this.recordingLocation = destination;
}
async stopScreenCapture(): Promise<string> {
if (this.recordingProcess && this.recordingLocation) {
const prom = new Promise<void>((resolve, _reject) => {
this.recordingProcess!.on(
'exit',
async (_code: number | null, _signal: NodeJS.Signals | null) => {
resolve();
},
);
this.recordingProcess!.kill('SIGINT');
});
const output: string = await timeout<void>(
5000,
prom,
'Timed out to stop a screen capture.',
)
.then(() => {
const {recordingLocation} = this;
this.recordingLocation = undefined;
return recordingLocation!;
})
.catch((e) => {
this.recordingLocation = undefined;
console.warn('Failed to terminate iOS screen recording:', e);
throw e;
});
return output;
}
throw new Error('No recording in progress');
}
disconnect() {
if (this.recordingProcess && this.recordingLocation) {
this.stopScreenCapture();
}
super.disconnect();
}
}
// Used to strip the initial output of the logging utility where it prints out settings.
// We know the log stream is json so it starts with an open brace.
class StripLogPrefix extends Transform {
passedPrefix = false;
_transform(
data: any,
_encoding: string,
callback: (err?: Error, data?: any) => void,
) {
if (this.passedPrefix) {
this.push(data);
} else {
const dataString = data.toString();
const index = dataString.indexOf('[');
if (index >= 0) {
this.push(dataString.substring(index));
this.passedPrefix = true;
}
}
callback();
}
}

View File

@@ -0,0 +1,154 @@
/**
* 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 {makeIOSBridge} from '../IOSBridge';
import childProcess from 'child_process';
import * as promisifyChildProcess from 'promisify-child-process';
jest.mock('child_process');
jest.mock('promisify-child-process');
test('uses xcrun with no idb when xcode is detected', async () => {
const ib = await makeIOSBridge('', true);
ib.startLogListener('deadbeef', 'emulator');
expect(childProcess.spawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'spawn',
'deadbeef',
'log',
'stream',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
],
{},
);
});
test('uses idb when present and xcode detected', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
ib.startLogListener('deadbeef', 'emulator');
expect(childProcess.spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
[
'log',
'--udid',
'deadbeef',
'--',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
],
{},
);
});
test('uses idb when present and xcode detected and physical device connected', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
ib.startLogListener('deadbeef', 'physical');
expect(childProcess.spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
[
'log',
'--udid',
'deadbeef',
'--',
// no further args; not supported by idb atm
],
{},
);
});
test("without idb physical devices can't log", async () => {
const ib = await makeIOSBridge('', true);
expect(ib.startLogListener).toBeDefined(); // since we have xcode
});
test('throws if no iOS support', async () => {
await expect(makeIOSBridge('', false)).rejects.toThrow(
'Neither Xcode nor idb available. Cannot provide iOS device functionality.',
);
});
test.unix(
'uses xcrun to take screenshots with no idb when xcode is detected',
async () => {
const ib = await makeIOSBridge('', true);
ib.screenshot('deadbeef');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'xcrun simctl io deadbeef screenshot /temp/00000000-0000-0000-0000-000000000000.png',
);
},
);
test.unix('uses idb to take screenshots when available', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
ib.screenshot('deadbeef');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'idb screenshot --udid deadbeef /temp/00000000-0000-0000-0000-000000000000.png',
);
});
test('uses xcrun to navigate with no idb when xcode is detected', async () => {
const ib = await makeIOSBridge('', true);
ib.navigate('deadbeef', 'fb://dummy');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'xcrun simctl io deadbeef launch url "fb://dummy"',
);
});
test('uses idb to navigate when available', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
ib.navigate('deadbeef', 'fb://dummy');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'idb open --udid deadbeef "fb://dummy"',
);
});
test('uses xcrun to record with no idb when xcode is detected', async () => {
const ib = await makeIOSBridge('', true);
ib.recordVideo('deadbeef', '/tmp/video.mp4');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'xcrun simctl io deadbeef recordVideo --codec=h264 --force /tmp/video.mp4',
);
});
test('uses idb to record when available', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
ib.recordVideo('deadbeef', '/tmo/video.mp4');
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
'idb record-video --udid deadbeef /tmo/video.mp4',
);
});

View File

@@ -0,0 +1,43 @@
/**
* 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 {queryTargetsWithoutXcodeDependency} from '../iOSContainerUtility';
test('uses idbcompanion command for queryTargetsWithoutXcodeDependency', async () => {
const mockedExec = jest.fn((_) =>
Promise.resolve({
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
stderr: '{ "msg": "mocked stderr"}',
}),
);
await queryTargetsWithoutXcodeDependency(
'idbCompanionPath',
true,
(_) => Promise.resolve(true),
mockedExec,
);
expect(mockedExec).toBeCalledWith('idbCompanionPath --list 1 --only device');
});
test('do not call idbcompanion if the path does not exist', async () => {
const mockedExec = jest.fn((_) =>
Promise.resolve({
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
stderr: '{"msg": "mocked stderr"}',
}),
);
await queryTargetsWithoutXcodeDependency(
'idbCompanionPath',
true,
(_) => Promise.resolve(false),
mockedExec,
);
expect(mockedExec).toHaveBeenCalledTimes(0);
});

View File

@@ -0,0 +1,93 @@
/**
* 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 {parseXcodeFromCoreSimPath} from '../iOSDeviceManager';
import {getLogger} from 'flipper-common';
import {IOSBridge} from '../IOSBridge';
import {FlipperServerImpl} from '../../../FlipperServerImpl';
const standardCoresimulatorLog =
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Applications/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
const nonStandardCoresimulatorLog =
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
const nonStandardSpecialCharacterAphanumericCoresimulatorLog =
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
test('test parseXcodeFromCoreSimPath from non standard locations', () => {
const match = parseXcodeFromCoreSimPath(nonStandardCoresimulatorLog);
expect(match && match.length > 0).toBeTruthy();
expect(
// @ts-ignore the null and non zero lenght check for match is already done above
match[0],
).toEqual('/Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer');
});
test('test parseXcodeFromCoreSimPath from non standard alphanumeric special character locations', () => {
const match = parseXcodeFromCoreSimPath(
nonStandardSpecialCharacterAphanumericCoresimulatorLog,
);
expect(match && match.length > 0).toBeTruthy();
expect(
// @ts-ignore the null and non zero lenght check for match is already done above
match[0],
).toEqual(
'/Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer',
);
});
test('test parseXcodeFromCoreSimPath from standard locations', () => {
const match = parseXcodeFromCoreSimPath(standardCoresimulatorLog);
expect(match && match.length > 0).toBeTruthy();
expect(
// @ts-ignore the null and non zero lenght check for match is already done above
match[0],
).toEqual('/Applications/Xcode_12.4.0_fb.app/Contents/Developer');
});
test('test getAllPromisesForQueryingDevices when xcode detected', () => {
const flipperServer = new FlipperServerImpl(getLogger());
flipperServer.ios.iosBridge = {} as IOSBridge;
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
true,
false,
);
expect(promises.length).toEqual(2);
});
test('test getAllPromisesForQueryingDevices when xcode is not detected', () => {
const flipperServer = new FlipperServerImpl(getLogger());
flipperServer.ios.iosBridge = {} as IOSBridge;
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
false,
true,
);
expect(promises.length).toEqual(1);
});
test('test getAllPromisesForQueryingDevices when xcode and idb are both unavailable', () => {
const flipperServer = new FlipperServerImpl(getLogger());
flipperServer.ios.iosBridge = {} as IOSBridge;
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
false,
false,
);
expect(promises.length).toEqual(0);
});
test('test getAllPromisesForQueryingDevices when both idb and xcode are available', () => {
const flipperServer = new FlipperServerImpl(getLogger());
flipperServer.ios.iosBridge = {} as IOSBridge;
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
true,
true,
);
expect(promises.length).toEqual(2);
});

View File

@@ -0,0 +1,299 @@
/**
* 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 {Mutex} from 'async-mutex';
import {exec as unsafeExec, Output} from 'promisify-child-process';
import {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';
import path from 'path';
const exec = promisify(child_process.exec);
// Use debug to get helpful logs when idb fails
const idbLogLevel = 'DEBUG';
const operationPrefix = 'iosContainerUtility';
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;
};
function isAvailable(idbPath: string): Promise<boolean> {
if (!idbPath) {
return Promise.resolve(false);
}
return promises
.access(idbPath, constants.X_OK)
.then((_) => true)
.catch((_) => false);
}
function safeExec(
command: string,
): Promise<{stdout: string; stderr: string} | Output> {
return mutex
.acquire()
.then((release) => unsafeExec(command).finally(release));
}
export async function queryTargetsWithoutXcodeDependency(
idbCompanionPath: string,
isPhysicalDeviceEnabled: boolean,
isAvailableFunc: (idbPath: string) => Promise<boolean>,
safeExecFunc: (
command: string,
) => Promise<{stdout: string; stderr: string} | Output>,
): Promise<Array<DeviceTarget>> {
if (await isAvailableFunc(idbCompanionPath)) {
return safeExecFunc(`${idbCompanionPath} --list 1 --only device`)
.then(({stdout}) => parseIdbTargets(stdout!.toString()))
.then((devices) => {
if (devices.length > 0 && !isPhysicalDeviceEnabled) {
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
console.warn(
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
);
}
return devices;
})
.catch((e: Error) => {
console.warn(
'Failed to query idb_companion --list 1 --only device for physical targets:',
e,
);
return [];
});
} else {
console.warn(
`Unable to locate idb_companion in ${idbCompanionPath}. Try running sudo yum install -y fb-idb`,
);
return [];
}
}
function parseIdbTargets(lines: string): Array<DeviceTarget> {
return lines
.trim()
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter(({state}: IdbTarget) => state.toLocaleLowerCase() === 'booted')
.map<IdbTarget>(({type, target_type, ...rest}: IdbTarget) => ({
type: (type || target_type) === 'simulator' ? 'emulator' : 'physical',
...rest,
}))
.map<DeviceTarget>((target: IdbTarget) => ({
udid: target.udid,
type: target.type as DeviceType,
name: target.name,
}));
}
export async function idbListTargets(
idbPath: string,
safeExecFunc: (
command: string,
) => Promise<{stdout: string; stderr: string} | Output> = safeExec,
): Promise<Array<DeviceTarget>> {
return safeExecFunc(`${idbPath} list-targets --json`)
.then(({stdout}) =>
// See above.
parseIdbTargets(stdout!.toString()),
)
.catch((e: Error) => {
console.warn('Failed to query idb for targets:', e);
return [];
});
}
async function targets(
idbPath: string,
isPhysicalDeviceEnabled: boolean,
): Promise<Array<DeviceTarget>> {
if (process.platform !== 'darwin') {
return [];
}
const isXcodeInstalled = await isXcodeDetected();
if (!isXcodeInstalled) {
if (!isPhysicalDeviceEnabled) {
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
console.warn(
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
);
}
const idbCompanionPath = path.dirname(idbPath) + '/idb_companion';
return queryTargetsWithoutXcodeDependency(
idbCompanionPath,
isPhysicalDeviceEnabled,
isAvailable,
safeExec,
);
}
// 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.
// TODO: Move idb availability check up.
if (await memoize(isAvailable)(idbPath)) {
return await idbListTargets(idbPath);
} else {
return safeExec('xcrun xctrace list devices')
.then(({stdout}) =>
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};
}),
)
.catch((e) => {
console.warn('Failed to query for devices using xctrace:', e);
return [];
});
}
}
async function push(
udid: string,
src: string,
bundleId: string,
dst: string,
idbPath: string,
): Promise<void> {
await memoize(checkIdbIsInstalled)(idbPath);
return wrapWithErrorMessage(
reportPlatformFailures(
safeExec(
`${idbPath} --log ${idbLogLevel} file push --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
)
.then(() => {
return;
})
.catch((e) => handleMissingIdb(e, idbPath)),
`${operationPrefix}:push`,
),
);
}
async function pull(
udid: string,
src: string,
bundleId: string,
dst: string,
idbPath: string,
): Promise<void> {
await memoize(checkIdbIsInstalled)(idbPath);
return wrapWithErrorMessage(
reportPlatformFailures(
safeExec(
`${idbPath} --log ${idbLogLevel} file pull --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
)
.then(() => {
return;
})
.catch((e) => handleMissingIdb(e, idbPath))
.catch((e) => handleMissingPermissions(e)),
`${operationPrefix}:pull`,
),
);
}
export async function checkIdbIsInstalled(idbPath: string): Promise<void> {
const isInstalled = await isAvailable(idbPath);
if (!isInstalled) {
throw new Error(
`idb is required to use iOS devices. Install it with instructions from https://github.com/facebook/idb and set the installation path in Flipper settings.`,
);
}
}
// The fb-internal idb binary is a shim that downloads the proper one on first run. It requires sudo to do so.
// If we detect this, Tell the user how to fix it.
function handleMissingIdb(e: Error, idbPath: string): void {
if (
e.message &&
e.message.includes('sudo: no tty present and no askpass program specified')
) {
console.warn(e);
throw new Error(
`idb doesn't appear to be installed. Run "${idbPath} list-targets" to fix this.`,
);
}
throw e;
}
function handleMissingPermissions(e: Error): void {
if (
e.message &&
e.message.includes('Command failed') &&
e.message.includes('file pull') &&
e.message.includes('sonar/app.csr')
) {
console.warn(e);
throw new Error(
'Cannot connect to iOS application. idb_certificate_pull_failed' +
'Idb lacks permissions to exchange certificates. Did you install a source build ([FB] or enable certificate exchange)? ' +
e,
);
}
throw e;
}
function wrapWithErrorMessage<T>(p: Promise<T>): Promise<T> {
return p.catch((e: Error) => {
console.warn(e);
// Give the user instructions. Don't embed the error because it's unique per invocation so won't be deduped.
throw new Error(
"A problem with idb has ocurred. Please run `sudo rm -rf /tmp/idb*` and `sudo yum install -y fb-idb` to update it, if that doesn't fix it, post in Flipper Support.",
);
});
}
async function isXcodeDetected(): Promise<boolean> {
return exec('xcode-select -p')
.then(({stdout}) => {
return fs.pathExists(stdout.trim());
})
.catch((_) => false);
}
export default {
isAvailable,
targets,
push,
pull,
isXcodeDetected,
};

View File

@@ -0,0 +1,307 @@
/**
* 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 {ChildProcess} from 'child_process';
import type {IOSDeviceParams} from 'flipper-common';
import path from 'path';
import childProcess from 'child_process';
import {exec, execFile} from 'promisify-child-process';
import iosUtil from './iOSContainerUtility';
import IOSDevice from './IOSDevice';
import {
ERR_NO_IDB_OR_XCODE_AVAILABLE,
IOSBridge,
makeIOSBridge,
} from './IOSBridge';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {notNull} from '../../utils/typeUtils';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
type iOSSimulatorDevice = {
state: 'Booted' | 'Shutdown' | 'Shutting Down';
availability?: string;
isAvailable?: 'YES' | 'NO' | true | false;
name: string;
udid: string;
};
function isAvailable(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
);
}
export class IOSDeviceManager {
private portForwarders: Array<ChildProcess> = [];
private portforwardingClient = path.join(
getFlipperServerConfig().staticPath,
'PortForwardingMacApp.app',
'Contents',
'MacOS',
'PortForwardingMacApp',
);
iosBridge: IOSBridge | undefined;
private xcodeVersionMismatchFound = false;
public xcodeCommandLineToolsDetected = false;
constructor(private flipperServer: FlipperServerImpl) {
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
this.portForwarders.forEach((process) => process.kill());
});
}
}
private forwardPort(port: number, multiplexChannelPort: number) {
const child = childProcess.execFile(
this.portforwardingClient,
[`-portForward=${port}`, `-multiplexChannelPort=${multiplexChannelPort}`],
(err, stdout, stderr) => {
// This happens on app reloads and doesn't need to be treated as an error.
console.warn(
'Port forwarding app failed to start',
err,
stdout,
stderr,
);
},
);
console.log('Port forwarding app started', childProcess);
child.addListener('error', (err) =>
console.warn('Port forwarding app error', err),
);
child.addListener('exit', (code) =>
console.log(`Port forwarding app exited with code ${code}`),
);
return child;
}
private startDevicePortForwarders(): void {
if (this.portForwarders.length > 0) {
// Only ever start them once.
return;
}
// start port forwarding server for real device connections
// TODO: ports should be picked up from flipperServer.config?
this.portForwarders = [
this.forwardPort(8089, 8079),
this.forwardPort(8088, 8078),
];
}
getAllPromisesForQueryingDevices(
isXcodeDetected: boolean,
isIdbAvailable: boolean,
): Array<Promise<any>> {
const config = getFlipperServerConfig();
return [
isIdbAvailable
? getActiveDevices(config.idbPath, config.enablePhysicalIOS).then(
(devices: IOSDeviceParams[]) => {
this.processDevices(devices);
},
)
: null,
!isIdbAvailable && isXcodeDetected
? this.getSimulators(true).then((devices) =>
this.processDevices(devices),
)
: null,
isXcodeDetected ? this.checkXcodeVersionMismatch() : null,
].filter(notNull);
}
private async queryDevices(): Promise<any> {
const config = getFlipperServerConfig();
const isXcodeInstalled = await iosUtil.isXcodeDetected();
const isIdbAvailable = await iosUtil.isAvailable(config.idbPath);
return Promise.all(
this.getAllPromisesForQueryingDevices(isXcodeInstalled, isIdbAvailable),
);
}
private processDevices(activeDevices: IOSDeviceParams[]) {
if (!this.iosBridge) {
throw new Error('iOS bridge not yet initialized');
}
const currentDeviceIDs = new Set(
this.flipperServer
.getDevices()
.filter((device) => device.info.os === 'iOS')
.map((device) => device.serial),
);
for (const activeDevice of activeDevices) {
const {udid, type, name} = activeDevice;
if (currentDeviceIDs.has(udid)) {
currentDeviceIDs.delete(udid);
} else {
console.info(`[conn] detected new iOS device ${udid}`, activeDevice);
const iOSDevice = new IOSDevice(
this.flipperServer,
this.iosBridge,
udid,
type,
name,
);
this.flipperServer.registerDevice(iOSDevice);
}
}
currentDeviceIDs.forEach((id) => {
console.info(`[conn] Could no longer find ${id}, removing...`);
this.flipperServer.unregisterDevice(id);
});
}
public async watchIOSDevices() {
// TODO: pull this condition up
if (!getFlipperServerConfig().enableIOS) {
return;
}
try {
const isDetected = await iosUtil.isXcodeDetected();
this.xcodeCommandLineToolsDetected = isDetected;
if (getFlipperServerConfig().enablePhysicalIOS) {
this.startDevicePortForwarders();
}
try {
// Awaiting the promise here to trigger immediate error handling.
this.iosBridge = await makeIOSBridge(
getFlipperServerConfig().idbPath,
isDetected,
);
this.queryDevicesForever();
} catch (err) {
// This case is expected if both Xcode and idb are missing.
if (err.message === ERR_NO_IDB_OR_XCODE_AVAILABLE) {
console.warn(
'Failed to init iOS device. You may want to disable iOS support in the settings.',
err,
);
} else {
console.error('Failed to initialize iOS dispatcher:', err);
}
}
} catch (err) {
console.error('Error while querying iOS devices:', err);
}
}
getSimulators(bootedOnly: boolean): Promise<Array<IOSDeviceParams>> {
return execFile('xcrun', [
'simctl',
...getDeviceSetPath(),
'list',
'devices',
'--json',
])
.then(({stdout}) => JSON.parse(stdout!.toString()).devices)
.then((simulatorDevices: Array<iOSSimulatorDevice>) => {
const simulators = Object.values(simulatorDevices).flat();
return simulators
.filter(
(simulator) =>
(!bootedOnly || simulator.state === 'Booted') &&
isAvailable(simulator),
)
.map((simulator) => {
return {
...simulator,
type: 'emulator',
} as IOSDeviceParams;
});
})
.catch((e: Error) => {
console.warn('Failed to query simulators:', e);
if (e.message.includes('Xcode license agreements')) {
this.flipperServer.emit('notification', {
type: 'error',
title: 'Xcode license requires approval',
description:
'The Xcode license agreement has changed. You need to either open Xcode and agree to the terms or run `sudo xcodebuild -license` in a Terminal to allow simulators to work with Flipper.',
});
}
return Promise.resolve([]);
});
}
private queryDevicesForever() {
return this.queryDevices()
.then(() => {
// It's important to schedule the next check AFTER the current one has completed
// to avoid simultaneous queries which can cause multiple user input prompts.
setTimeout(() => this.queryDevicesForever(), 3000);
})
.catch((err) => {
console.warn('Failed to continuously query devices:', err);
});
}
async checkXcodeVersionMismatch() {
if (this.xcodeVersionMismatchFound) {
return;
}
try {
let {stdout: xcodeCLIVersion} = await exec('xcode-select -p');
xcodeCLIVersion = xcodeCLIVersion!.toString().trim();
const {stdout} = await exec('ps aux | grep CoreSimulator');
for (const line of stdout!.toString().split('\n')) {
const match = parseXcodeFromCoreSimPath(line);
const runningVersion =
match && match.length > 0 ? match[0].trim() : null;
if (runningVersion && runningVersion !== xcodeCLIVersion) {
const errorMessage = `Xcode version mismatch: Simulator is running from "${runningVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${runningVersion}" can fix this. For example: "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"`;
this.flipperServer.emit('notification', {
type: 'error',
title: 'Xcode version mismatch',
description: '' + errorMessage,
});
this.xcodeVersionMismatchFound = true;
break;
}
}
} catch (e) {
console.error('Failed to determine Xcode version:', e);
}
}
}
function getDeviceSetPath() {
return process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
}
export async function launchSimulator(udid: string): Promise<any> {
await execFile('xcrun', ['simctl', ...getDeviceSetPath(), 'boot', udid]);
await execFile('open', ['-a', 'simulator']);
}
function getActiveDevices(
idbPath: string,
isPhysicalDeviceEnabled: boolean,
): Promise<Array<IOSDeviceParams>> {
return iosUtil.targets(idbPath, isPhysicalDeviceEnabled).catch((e) => {
console.error('Failed to get active iOS devices:', e.message);
return [];
});
}
export function parseXcodeFromCoreSimPath(
line: string,
): RegExpMatchArray | null {
return line.match(/\/[\/\w@)(\-\+]*\/Xcode[^/]*\.app\/Contents\/Developer/);
}