diff --git a/desktop/app/src/devices/IOSDevice.tsx b/desktop/app/src/devices/IOSDevice.tsx index 144730d79..78f52f12d 100644 --- a/desktop/app/src/devices/IOSDevice.tsx +++ b/desktop/app/src/devices/IOSDevice.tsx @@ -19,6 +19,7 @@ import path from 'path'; import {promisify} from 'util'; import {exec} from 'child_process'; import {default as promiseTimeout} from '../utils/promiseTimeout'; +import {IOSBridge} from '../utils/IOSBridge'; type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault'; @@ -45,11 +46,16 @@ export default class IOSDevice extends BaseDevice { private recordingProcess?: ChildProcess; private recordingLocation?: string; - constructor(serial: string, deviceType: DeviceType, title: string) { + constructor( + iOSBridge: IOSBridge, + serial: string, + deviceType: DeviceType, + title: string, + ) { super(serial, deviceType, title, 'iOS'); this.icon = 'mobile'; this.buffer = ''; - this.startLogListener(); + this.startLogListener(iOSBridge); } async screenshot(): Promise { @@ -81,47 +87,13 @@ export default class IOSDevice extends BaseDevice { } } - startLogListener(retries: number = 3) { + startLogListener(iOSBridge: IOSBridge, retries: number = 3) { if (retries === 0) { console.warn('Attaching iOS log listener continuously failed.'); return; } if (!this.log) { - const deviceSetPath = process.env.DEVICE_SET_PATH - ? ['--set', process.env.DEVICE_SET_PATH] - : []; - - const extraArgs = [ - '--style', - 'json', - '--predicate', - 'senderImagePath contains "Containers"', - '--debug', - '--info', - ]; - - if (this.deviceType === 'physical') { - this.log = child_process.spawn( - 'idb', - ['log', '--udid', this.serial, '--', ...extraArgs], - {}, - ); - } else { - this.log = child_process.spawn( - 'xcrun', - [ - 'simctl', - ...deviceSetPath, - 'spawn', - this.serial, - 'log', - 'stream', - ...extraArgs, - ], - {}, - ); - } - + this.log = iOSBridge.startLogListener(this.serial); this.log.on('error', (err: Error) => { console.error('iOS log tailer error', err); }); @@ -133,22 +105,22 @@ export default class IOSDevice extends BaseDevice { this.log.on('exit', () => { this.log = undefined; }); - } - try { - this.log.stdout - .pipe(new StripLogPrefix()) - .pipe(JSONStream.parse('*')) - .on('data', (data: RawLogEntry) => { - const entry = IOSDevice.parseLogEntry(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(retries - 1); + try { + this.log.stdout + .pipe(new StripLogPrefix()) + .pipe(JSONStream.parse('*')) + .on('data', (data: RawLogEntry) => { + const entry = IOSDevice.parseLogEntry(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); + } } } diff --git a/desktop/app/src/dispatcher/iOSDevice.tsx b/desktop/app/src/dispatcher/iOSDevice.tsx index daef59aa6..8aacc588a 100644 --- a/desktop/app/src/dispatcher/iOSDevice.tsx +++ b/desktop/app/src/dispatcher/iOSDevice.tsx @@ -22,6 +22,7 @@ import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice'; import {addErrorNotification} from '../reducers/notifications'; import {getStaticPath} from '../utils/pathUtils'; import {destroyDevice} from '../reducers/connections'; +import {IOSBridge, makeIOSBridge} from '../utils/IOSBridge'; type iOSSimulatorDevice = { state: 'Booted' | 'Shutdown' | 'Shutting Down'; @@ -92,15 +93,19 @@ if (typeof window !== 'undefined') { }); } -async function queryDevices(store: Store, logger: Logger): Promise { +async function queryDevices( + store: Store, + logger: Logger, + iosBridge: IOSBridge, +): Promise { return Promise.all([ checkXcodeVersionMismatch(store), getSimulators(store, true).then((devices) => { - processDevices(store, logger, devices, 'emulator'); + processDevices(store, logger, iosBridge, devices, 'emulator'); }), getActiveDevices(store.getState().settingsState.idbPath).then( (devices: IOSDeviceParams[]) => { - processDevices(store, logger, devices, 'physical'); + processDevices(store, logger, iosBridge, devices, 'physical'); }, ), ]); @@ -109,6 +114,7 @@ async function queryDevices(store: Store, logger: Logger): Promise { function processDevices( store: Store, logger: Logger, + iosBridge: IOSBridge, activeDevices: IOSDeviceParams[], type: 'physical' | 'emulator', ) { @@ -136,7 +142,7 @@ function processDevices( name: name, serial: udid, }); - const iOSDevice = new IOSDevice(udid, type, name); + const iOSDevice = new IOSDevice(iosBridge, udid, type, name); iOSDevice.loadDevicePlugins( store.getState().plugins.devicePlugins, store.getState().connections.enabledDevicePlugins, @@ -225,12 +231,16 @@ function getActiveDevices(idbPath: string): Promise> { }); } -function queryDevicesForever(store: Store, logger: Logger) { - return queryDevices(store, logger) +function queryDevicesForever( + store: Store, + logger: Logger, + iosBridge: IOSBridge, +) { + return queryDevices(store, logger, iosBridge) .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(() => queryDevicesForever(store, logger), 3000); + setTimeout(() => queryDevicesForever(store, logger, iosBridge), 3000); }) .catch((err) => { console.warn('Failed to continuously query devices:', err); @@ -285,7 +295,9 @@ export default (store: Store, logger: Logger) => { if (store.getState().settingsState.enablePhysicalIOS) { startDevicePortForwarders(); } - return queryDevicesForever(store, logger); + return makeIOSBridge( + store.getState().settingsState.idbPath, + ).then((iosBridge) => queryDevicesForever(store, logger, iosBridge)); } }); }; diff --git a/desktop/app/src/utils/IOSBridge.tsx b/desktop/app/src/utils/IOSBridge.tsx new file mode 100644 index 000000000..940625c24 --- /dev/null +++ b/desktop/app/src/utils/IOSBridge.tsx @@ -0,0 +1,78 @@ +/** + * 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'; +import child_process from 'child_process'; + +export interface IOSBridge { + startLogListener: ( + udid: string, + ) => child_process.ChildProcessWithoutNullStreams; +} + +async function isAvailable(idbPath: string): Promise { + if (!idbPath) { + return false; + } + return fs.promises + .access(idbPath, fs.constants.X_OK) + .then((_) => true) + .catch((_) => false); +} + +const LOG_EXTRA_ARGS = [ + '--style', + 'json', + '--predicate', + 'senderImagePath contains "Containers"', + '--debug', + '--info', +]; + +function idbStartLogListener( + idbPath: string, + udid: string, +): child_process.ChildProcessWithoutNullStreams { + return child_process.spawn( + idbPath, + ['log', '--udid', udid, '--', ...LOG_EXTRA_ARGS], + {}, + ); +} + +function xcrunStartLogListener(udid: string) { + const deviceSetPath = process.env.DEVICE_SET_PATH + ? ['--set', process.env.DEVICE_SET_PATH] + : []; + return child_process.spawn( + 'xcrun', + [ + 'simctl', + ...deviceSetPath, + 'spawn', + udid, + 'log', + 'stream', + ...LOG_EXTRA_ARGS, + ], + {}, + ); +} + +export async function makeIOSBridge(idbPath: string): Promise { + if (await isAvailable(idbPath)) { + return { + startLogListener: idbStartLogListener.bind(null, idbPath), + }; + } + + return { + startLogListener: xcrunStartLogListener, + }; +} diff --git a/desktop/app/src/utils/__tests__/IOSBridge.node.tsx b/desktop/app/src/utils/__tests__/IOSBridge.node.tsx new file mode 100644 index 000000000..1cb516a01 --- /dev/null +++ b/desktop/app/src/utils/__tests__/IOSBridge.node.tsx @@ -0,0 +1,60 @@ +/** + * 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 {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'); + + expect(spawn).toHaveBeenCalledWith( + 'xcrun', + [ + 'simctl', + 'spawn', + 'deadbeef', + 'log', + 'stream', + '--style', + 'json', + '--predicate', + 'senderImagePath contains "Containers"', + '--debug', + '--info', + ], + {}, + ); +}); + +test('uses idb when present', async () => { + const ib = await makeIOSBridge('/usr/local/bin/idb'); + ib.startLogListener('deadbeef'); + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/idb', + [ + 'log', + '--udid', + 'deadbeef', + '--', + '--style', + 'json', + '--predicate', + 'senderImagePath contains "Containers"', + '--debug', + '--info', + ], + {}, + ); +});