Detect Physical iOS device without Xcode

Summary:
This diff adds the support of detecting physical device in Flipper even if the xcode is not installed and there is no cli tool installed.

See the demo.

Reviewed By: timur-valiev

Differential Revision: D26816588

fbshipit-source-id: 5f052998fcbe5c51385222d16df0e1855177b552
This commit is contained in:
Pritesh Nandgaonkar
2021-03-05 11:34:34 -08:00
committed by Facebook GitHub Bot
parent 11879c127b
commit a2d559c8c0
8 changed files with 238 additions and 39 deletions

View File

@@ -92,8 +92,10 @@ export default class IOSDevice extends BaseDevice {
console.warn('Attaching iOS log listener continuously failed.');
return;
}
if (!this.log) {
this.log = iOSBridge.startLogListener(this.serial);
const logListener = iOSBridge.startLogListener;
if (!this.log && logListener) {
this.log = logListener(this.serial);
this.log.on('error', (err: Error) => {
console.error('iOS log tailer error', err);
});

View File

@@ -7,7 +7,18 @@
* @format
*/
import {parseXcodeFromCoreSimPath} from '../iOSDevice';
import {
parseXcodeFromCoreSimPath,
getAllPromisesForQueryingDevices,
} from '../iOSDevice';
import configureStore from 'redux-mock-store';
import reducers, {State} from '../../reducers/index';
import {getInstance} from '../../fb-stubs/Logger';
const mockStore = configureStore<State, {}>([])(
reducers(undefined, {type: 'INIT'}),
);
const logger = getInstance();
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';
@@ -48,3 +59,23 @@ test('test parseXcodeFromCoreSimPath from standard locations', () => {
match[0],
).toEqual('/Applications/Xcode_12.4.0_fb.app/Contents/Developer');
});
test('test getAllPromisesForQueryingDevices when xcode detected', () => {
const promises = getAllPromisesForQueryingDevices(
mockStore,
logger,
{},
true,
);
expect(promises.length).toEqual(3);
});
test('test getAllPromisesForQueryingDevices when xcode is not detected', () => {
const promises = getAllPromisesForQueryingDevices(
mockStore,
logger,
{},
false,
);
expect(promises.length).toEqual(1);
});

View File

@@ -93,22 +93,47 @@ if (typeof window !== 'undefined') {
});
}
export function getAllPromisesForQueryingDevices(
store: Store,
logger: Logger,
iosBridge: IOSBridge,
isXcodeDetected: boolean,
): Array<Promise<any>> {
const promArray = [
getActiveDevices(
store.getState().settingsState.idbPath,
store.getState().settingsState.enablePhysicalIOS,
).then((devices: IOSDeviceParams[]) => {
processDevices(store, logger, iosBridge, devices, 'physical');
}),
];
if (isXcodeDetected) {
promArray.push(
...[
checkXcodeVersionMismatch(store),
getSimulators(store, true).then((devices) => {
processDevices(store, logger, iosBridge, devices, 'emulator');
}),
],
);
}
return promArray;
}
async function queryDevices(
store: Store,
logger: Logger,
iosBridge: IOSBridge,
): Promise<any> {
return Promise.all([
checkXcodeVersionMismatch(store),
getSimulators(store, true).then((devices) => {
processDevices(store, logger, iosBridge, devices, 'emulator');
}),
getActiveDevices(store.getState().settingsState.idbPath).then(
(devices: IOSDeviceParams[]) => {
processDevices(store, logger, iosBridge, devices, 'physical');
},
const isXcodeInstalled = await iosUtil.isXcodeDetected();
return Promise.all(
getAllPromisesForQueryingDevices(
store,
logger,
iosBridge,
isXcodeInstalled,
),
]);
);
}
function processDevices(
@@ -224,8 +249,11 @@ export async function launchSimulator(udid: string): Promise<any> {
await promisify(execFile)('open', ['-a', 'simulator']);
}
function getActiveDevices(idbPath: string): Promise<Array<IOSDeviceParams>> {
return iosUtil.targets(idbPath).catch((e) => {
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 [];
});
@@ -279,25 +307,19 @@ async function checkXcodeVersionMismatch(store: Store) {
console.error('Failed to determine Xcode version:', e);
}
}
async function isXcodeDetected(): Promise<boolean> {
return exec('xcode-select -p')
.then((_) => true)
.catch((_) => false);
}
export default (store: Store, logger: Logger) => {
if (!store.getState().settingsState.enableIOS) {
return;
}
isXcodeDetected().then((isDetected) => {
iosUtil.isXcodeDetected().then((isDetected) => {
store.dispatch(setXcodeDetected(isDetected));
if (isDetected) {
if (store.getState().settingsState.enablePhysicalIOS) {
startDevicePortForwarders();
}
return makeIOSBridge(
store.getState().settingsState.idbPath,
).then((iosBridge) => queryDevicesForever(store, logger, iosBridge));
if (store.getState().settingsState.enablePhysicalIOS) {
startDevicePortForwarders();
}
return makeIOSBridge(
store.getState().settingsState.idbPath,
isDetected,
).then((iosBridge) => queryDevicesForever(store, logger, iosBridge));
});
};

View File

@@ -425,7 +425,10 @@ export default class CertificateProvider {
return Promise.resolve(matches[1]);
}
return iosUtil
.targets(this.store.getState().settingsState.idbPath)
.targets(
this.store.getState().settingsState.idbPath,
this.store.getState().settingsState.enablePhysicalIOS,
)
.then((targets) => {
if (targets.length === 0) {
throw new Error('No iOS devices found');

View File

@@ -11,7 +11,7 @@ import fs from 'fs';
import child_process from 'child_process';
export interface IOSBridge {
startLogListener: (
startLogListener?: (
udid: string,
) => child_process.ChildProcessWithoutNullStreams;
}
@@ -35,7 +35,7 @@ const LOG_EXTRA_ARGS = [
'--info',
];
function idbStartLogListener(
export function idbStartLogListener(
idbPath: string,
udid: string,
): child_process.ChildProcessWithoutNullStreams {
@@ -46,7 +46,7 @@ function idbStartLogListener(
);
}
function xcrunStartLogListener(udid: string) {
export function xcrunStartLogListener(udid: string) {
const deviceSetPath = process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
@@ -67,8 +67,14 @@ function xcrunStartLogListener(udid: string) {
export async function makeIOSBridge(
idbPath: string,
isXcodeDetected: boolean,
isAvailableFn: (idbPath: string) => Promise<boolean> = isAvailable,
): Promise<IOSBridge> {
if (!isXcodeDetected) {
// iOS Physical Device can still get detected without Xcode. In this case there is no way to setup log listener yet.
// This will not be the case, idb team is working on making idb log work without XCode atleast for physical device.
return {};
}
if (await isAvailableFn(idbPath)) {
return {
startLogListener: idbStartLogListener.bind(null, idbPath),

View File

@@ -14,9 +14,11 @@ import {mocked} from 'ts-jest/utils';
jest.mock('child_process');
const spawn = mocked(childProcess.spawn);
test('uses xcrun with no idb', async () => {
const ib = await makeIOSBridge('');
ib.startLogListener('deadbeef');
test('uses xcrun with no idb when xcode is detected', async () => {
const ib = await makeIOSBridge('', true);
expect(ib.startLogListener).toBeDefined();
ib.startLogListener!('deadbeef');
expect(spawn).toHaveBeenCalledWith(
'xcrun',
@@ -37,9 +39,11 @@ test('uses xcrun with no idb', async () => {
);
});
test('uses idb when present', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', async (_) => true);
ib.startLogListener('deadbeef');
test('uses idb when present and xcode detected', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
expect(ib.startLogListener).toBeDefined();
ib.startLogListener!('deadbeef');
expect(spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
@@ -58,3 +62,8 @@ test('uses idb when present', async () => {
{},
);
});
test('uses no log listener when xcode is not detected', async () => {
const ib = await makeIOSBridge('', false);
expect(ib.startLogListener).toBeUndefined();
});

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

@@ -14,6 +14,11 @@ import {reportPlatformFailures} from './metrics';
import {promises, constants} from 'fs';
import memoize from 'lodash.memoize';
import {notNull} from './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';
@@ -54,10 +59,79 @@ function safeExec(
.then((release) => unsafeExec(command).finally(release));
}
async function targets(idbPath: string): Promise<Array<DeviceTarget>> {
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}) =>
// It is safe to assume this to be non-null as it only turns null
// if the output redirection is misconfigured:
// https://stackoverflow.com/questions/27786228/node-child-process-spawn-stdout-returning-as-null
stdout!
.toString()
.trim()
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter(({type}: IdbTarget) => type !== 'simulator')
.map<DeviceTarget>((target: IdbTarget) => {
return {udid: target.udid, type: 'physical', name: target.name};
}),
)
.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 [];
}
}
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.
@@ -185,9 +259,18 @@ function wrapWithErrorMessage<T>(p: Promise<T>): Promise<T> {
});
}
async function isXcodeDetected(): Promise<boolean> {
return exec('xcode-select -p')
.then(({stdout}) => {
return fs.pathExists(stdout);
})
.catch((_) => false);
}
export default {
isAvailable,
targets,
push,
pull,
isXcodeDetected,
};