From a2d559c8c0fcef2b6a89a28bd18a7f426130b814 Mon Sep 17 00:00:00 2001 From: Pritesh Nandgaonkar Date: Fri, 5 Mar 2021 11:34:34 -0800 Subject: [PATCH] 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 --- desktop/app/src/devices/IOSDevice.tsx | 6 +- .../dispatcher/__tests__/iOSDevice.node.tsx | 33 ++++++- desktop/app/src/dispatcher/iOSDevice.tsx | 72 ++++++++++------ desktop/app/src/utils/CertificateProvider.tsx | 5 +- desktop/app/src/utils/IOSBridge.tsx | 12 ++- .../src/utils/__tests__/IOSBridge.node.tsx | 21 +++-- .../__tests__/iOSContainerUtility.node.tsx | 43 ++++++++++ desktop/app/src/utils/iOSContainerUtility.tsx | 85 ++++++++++++++++++- 8 files changed, 238 insertions(+), 39 deletions(-) create mode 100644 desktop/app/src/utils/__tests__/iOSContainerUtility.node.tsx diff --git a/desktop/app/src/devices/IOSDevice.tsx b/desktop/app/src/devices/IOSDevice.tsx index 78f52f12d..5a49217de 100644 --- a/desktop/app/src/devices/IOSDevice.tsx +++ b/desktop/app/src/devices/IOSDevice.tsx @@ -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); }); diff --git a/desktop/app/src/dispatcher/__tests__/iOSDevice.node.tsx b/desktop/app/src/dispatcher/__tests__/iOSDevice.node.tsx index f6e955c34..2e825213d 100644 --- a/desktop/app/src/dispatcher/__tests__/iOSDevice.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/iOSDevice.node.tsx @@ -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([])( + 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); +}); diff --git a/desktop/app/src/dispatcher/iOSDevice.tsx b/desktop/app/src/dispatcher/iOSDevice.tsx index 8aacc588a..fdc0e2fa0 100644 --- a/desktop/app/src/dispatcher/iOSDevice.tsx +++ b/desktop/app/src/dispatcher/iOSDevice.tsx @@ -93,22 +93,47 @@ if (typeof window !== 'undefined') { }); } +export function getAllPromisesForQueryingDevices( + store: Store, + logger: Logger, + iosBridge: IOSBridge, + isXcodeDetected: boolean, +): Array> { + 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 { - 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 { await promisify(execFile)('open', ['-a', 'simulator']); } -function getActiveDevices(idbPath: string): Promise> { - return iosUtil.targets(idbPath).catch((e) => { +function getActiveDevices( + idbPath: string, + isPhysicalDeviceEnabled: boolean, +): Promise> { + 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 { - 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)); }); }; diff --git a/desktop/app/src/utils/CertificateProvider.tsx b/desktop/app/src/utils/CertificateProvider.tsx index 535254fea..91b226774 100644 --- a/desktop/app/src/utils/CertificateProvider.tsx +++ b/desktop/app/src/utils/CertificateProvider.tsx @@ -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'); diff --git a/desktop/app/src/utils/IOSBridge.tsx b/desktop/app/src/utils/IOSBridge.tsx index 9b9f417d7..8c760a3d6 100644 --- a/desktop/app/src/utils/IOSBridge.tsx +++ b/desktop/app/src/utils/IOSBridge.tsx @@ -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 = isAvailable, ): Promise { + 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), diff --git a/desktop/app/src/utils/__tests__/IOSBridge.node.tsx b/desktop/app/src/utils/__tests__/IOSBridge.node.tsx index 6ec2420b6..072abca52 100644 --- a/desktop/app/src/utils/__tests__/IOSBridge.node.tsx +++ b/desktop/app/src/utils/__tests__/IOSBridge.node.tsx @@ -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(); +}); diff --git a/desktop/app/src/utils/__tests__/iOSContainerUtility.node.tsx b/desktop/app/src/utils/__tests__/iOSContainerUtility.node.tsx new file mode 100644 index 000000000..1f38b7c30 --- /dev/null +++ b/desktop/app/src/utils/__tests__/iOSContainerUtility.node.tsx @@ -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); +}); diff --git a/desktop/app/src/utils/iOSContainerUtility.tsx b/desktop/app/src/utils/iOSContainerUtility.tsx index 88e55bd4c..3930a8f15 100644 --- a/desktop/app/src/utils/iOSContainerUtility.tsx +++ b/desktop/app/src/utils/iOSContainerUtility.tsx @@ -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> { +export async function queryTargetsWithoutXcodeDependency( + idbCompanionPath: string, + isPhysicalDeviceEnabled: boolean, + isAvailableFunc: (idbPath: string) => Promise, + safeExecFunc: ( + command: string, + ) => Promise<{stdout: string; stderr: string} | Output>, +): Promise> { + 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((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> { 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(p: Promise): Promise { }); } +async function isXcodeDetected(): Promise { + return exec('xcode-select -p') + .then(({stdout}) => { + return fs.pathExists(stdout); + }) + .catch((_) => false); +} + export default { isAvailable, targets, push, pull, + isXcodeDetected, };