/** * 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 {Store} from '../reducers/index'; import {setXcodeDetected} from '../reducers/application'; import {Logger} from '../fb-interfaces/Logger'; import type {DeviceType} from 'flipper-plugin'; import {promisify} from 'util'; import path from 'path'; import child_process from 'child_process'; const execFile = child_process.execFile; import iosUtil from '../utils/iOSContainerUtility'; import IOSDevice from '../devices/IOSDevice'; import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice'; import {addErrorNotification} from '../reducers/notifications'; import {getStaticPath} from '../utils/pathUtils'; type iOSSimulatorDevice = { state: 'Booted' | 'Shutdown' | 'Shutting Down'; availability?: string; isAvailable?: 'YES' | 'NO' | true | false; name: string; udid: string; }; export type IOSDeviceParams = { udid: string; type: DeviceType; name: string; deviceTypeIdentifier?: string; state?: string; }; const exec = promisify(child_process.exec); let portForwarders: Array = []; 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 ); } const portforwardingClient = path.join( getStaticPath(), 'PortForwardingMacApp.app/Contents/MacOS/PortForwardingMacApp', ); function forwardPort(port: number, multiplexChannelPort: number) { const childProcess = execFile( portforwardingClient, [`-portForward=${port}`, `-multiplexChannelPort=${multiplexChannelPort}`], (err, stdout, stderr) => { console.error('Port forwarding app failed to start', err, stdout, stderr); }, ); console.log('Port forwarding app started', childProcess); childProcess.addListener('error', (err) => console.error('Port forwarding app error', err), ); childProcess.addListener('exit', (code) => console.log(`Port forwarding app exited with code ${code}`), ); return childProcess; } function startDevicePortForwarders(): void { if (portForwarders.length > 0) { // Only ever start them once. return; } // start port forwarding server for real device connections portForwarders = [forwardPort(8089, 8079), forwardPort(8088, 8078)]; } if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { portForwarders.forEach((process) => process.kill()); }); } async function queryDevices(store: Store, logger: Logger): Promise { return Promise.all([ checkXcodeVersionMismatch(store), getSimulators(store, true).then((devices) => { processDevices(store, logger, devices, 'emulator'); }), getActiveDevices(store.getState().settingsState.idbPath).then( (devices: IOSDeviceParams[]) => { processDevices(store, logger, devices, 'physical'); }, ), ]); } function processDevices( store: Store, logger: Logger, activeDevices: IOSDeviceParams[], type: 'physical' | 'emulator', ) { const {connections} = store.getState(); const currentDeviceIDs: Set = new Set( connections.devices .filter( (device) => device instanceof IOSDevice && device.deviceType === type && device.connected.get(), ) .map((device) => device.serial), ); for (const {udid, type, name} of activeDevices) { if (currentDeviceIDs.has(udid)) { currentDeviceIDs.delete(udid); } else { logger.track('usage', 'register-device', { os: 'iOS', type: type, name: name, serial: udid, }); const iOSDevice = new IOSDevice(udid, type, name); iOSDevice.loadDevicePlugins( store.getState().plugins.devicePlugins, store.getState().connections.enabledDevicePlugins, ); store.dispatch({ type: 'REGISTER_DEVICE', payload: iOSDevice, }); registerDeviceCallbackOnPlugins( store, store.getState().plugins.devicePlugins, store.getState().plugins.clientPlugins, iOSDevice, ); } } currentDeviceIDs.forEach((id) => { const device = store .getState() .connections.devices.find((device) => device.serial === id); device?.disconnect(); }); } function getDeviceSetPath() { return process.env.DEVICE_SET_PATH ? ['--set', process.env.DEVICE_SET_PATH] : []; } export function getSimulators( store: Store, bootedOnly: boolean, ): Promise> { return promisify(execFile)( 'xcrun', ['simctl', ...getDeviceSetPath(), 'list', 'devices', '--json'], { encoding: 'utf8', }, ) .then(({stdout}) => JSON.parse(stdout).devices) .then((simulatorDevices: Array) => { 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')) { store.dispatch( addErrorNotification( 'Xcode license requires approval', '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([]); }); } export async function launchSimulator(udid: string): Promise { await promisify(execFile)( 'xcrun', ['simctl', ...getDeviceSetPath(), 'boot', udid], {encoding: 'utf8'}, ); await promisify(execFile)('open', ['-a', 'simulator']); } function getActiveDevices(idbPath: string): Promise> { return iosUtil.targets(idbPath).catch((e) => { console.error('Failed to get active iOS devices:', e.message); return []; }); } function queryDevicesForever(store: Store, logger: Logger) { return queryDevices(store, logger) .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); }) .catch((err) => { console.warn('Failed to continuously query devices:', err); }); } export function parseXcodeFromCoreSimPath( line: string, ): RegExpMatchArray | null { return line.match(/\/[\/\w@)(\-\+]*\/Xcode[^/]*\.app\/Contents\/Developer/); } let xcodeVersionMismatchFound = false; async function checkXcodeVersionMismatch(store: Store) { if (xcodeVersionMismatchFound) { return; } try { let {stdout: xcodeCLIVersion} = await exec('xcode-select -p'); xcodeCLIVersion = xcodeCLIVersion.trim(); const {stdout} = await exec('ps aux | grep CoreSimulator'); for (const line of stdout.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"`; store.dispatch( addErrorNotification('Xcode version mismatch', errorMessage), ); xcodeVersionMismatchFound = true; break; } } } catch (e) { console.error('Failed to determine Xcode version:', e); } } async function isXcodeDetected(): Promise { return exec('xcode-select -p') .then((_) => true) .catch((_) => false); } export async function getActiveDevicesAndSimulators( store: Store, ): Promise> { const activeDevices: Array> = await Promise.all([ getSimulators(store, true), getActiveDevices(store.getState().settingsState.idbPath), ]); const allDevices = activeDevices[0].concat(activeDevices[1]); return allDevices.map((device) => { const {udid, type, name} = device; return new IOSDevice(udid, type, name); }); } export default (store: Store, logger: Logger) => { if (!store.getState().settingsState.enableIOS) { return; } isXcodeDetected().then((isDetected) => { store.dispatch(setXcodeDetected(isDetected)); if (isDetected) { if (store.getState().settingsState.enablePhysicalIOS) { startDevicePortForwarders(); } return queryDevicesForever(store, logger); } }); };