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 ) { return ( simulator . availability === '(available)' || simulator . isAvailable === 'YES' || simulator . isAvailable === true ) ; } export class IOSDeviceManager { private portForwarders : Array < ChildProcess > = [ ] ; private portforwardingClient = path . join ( getFlipperServerConfig ( ) . paths . staticPath , 'PortForwardingMacApp.app' , 'Contents' , 'MacOS' , 'PortForwardingMacApp' ) ; iosBridge : IOSBridge | undefined ; private xcodeVersionMismatchFound = false ; public xcodeCommandLineToolsDetected = false ; constructor ( private flipperServer ) { } private forwardPort ( port , multiplexChannelPort ) { const child = childProcess . execFile ( this . portforwardingClient , [ `-portForward=${ port }` , `-multiplexChannelPort=${ multiplexChannelPort }` ] , ( err , stdout , stderr ) => { if ( err ) { console . warn ( '[conn] Port forwarding app failed to start' , err , stdout , stderr ) ; } } ) ; console . info ( `[conn] Port forwarding app started (portForward: ${ port }, multiplexChannelPort: ${ multiplexChannelPort })` ) ; child . addListener ( 'error' , ( err ) => console . warn ( '[conn] Port forwarding app error' , err ) ) ; child . addListener ( 'exit' , ( code ) => { if ( code != 0 ) { console . warn ( `[conn] Port forwarding app exited with code ${ code }` ) ; } else { console . log ( `[conn] Port forwarding app exited gracefully` ) ; } } ) ; return child ; } private startDevicePortForwarders ( ) { if ( this . portForwarders . length > 0 ) { return ; } this . portForwarders = [ this . forwardPort ( 8089 , 8079 ) , this . forwardPort ( 8088 , 8078 ) , ] ; } getAllPromisesForQueryingDevices ( isXcodeDetected , isIdbAvailable ) { const config = getFlipperServerConfig ( ) . settings ; return [ isIdbAvailable ? getActiveDevices ( config . idbPath , config . enablePhysicalIOS ) . then ( ( devices ) => { this . processDevices ( devices ) ; } ) : null , ! isIdbAvailable && isXcodeDetected ? this . getSimulators ( true ) . then ( ( devices ) => this . processDevices ( devices ) ) : null , isXcodeDetected ? this . checkXcodeVersionMismatch ( ) : null , ] . filter ( notNull ) ; } private async queryDevices ( ) { const config = getFlipperServerConfig ( ) . settings ; const isXcodeInstalled = await iosUtil . isXcodeDetected ( ) ; const isIdbAvailable = await iosUtil . isAvailable ( config . idbPath ) ; console . debug ( `[conn] queryDevices. isXcodeInstalled ${ isXcodeInstalled }, isIdbAvailable ${ isIdbAvailable }` ) ; return Promise . all ( this . getAllPromisesForQueryingDevices ( isXcodeInstalled , isIdbAvailable ) ) ; } private processDevices ( activeDevices ) { console . debug ( '[conn] processDevices' , activeDevices ) ; 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 ) ) ; console . debug ( '[conn] processDevices -> currentDeviceIDs' , currentDeviceIDs ) ; 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 ( ) { const settings = getFlipperServerConfig ( ) . settings ; if ( ! settings . enableIOS ) { return ; } try { const isDetected = await iosUtil . isXcodeDetected ( ) ; this . xcodeCommandLineToolsDetected = isDetected ; if ( settings . enablePhysicalIOS ) { this . startDevicePortForwarders ( ) ; } try { this . iosBridge = await makeIOSBridge ( settings . idbPath , isDetected ) ; this . queryDevicesForever ( ) ; } catch ( err ) { 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 ) { return execFile ( 'xcrun' , [ 'simctl' , ... getDeviceSetPath ( ) , 'list' , 'devices' , '--json' , ] ) . then ( ( { stdout } ) => JSON . parse ( stdout ! . toString ( ) ) . devices ) . then ( ( simulatorDevices ) => { 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 ) => { 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 ( ( ) => { 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 ) { await execFile ( 'xcrun' , [ 'simctl' , ... getDeviceSetPath ( ) , 'boot' , udid ] ) ; await execFile ( 'open' , [ '-a' , 'simulator' ] ) ; } function getActiveDevices ( idbPath , isPhysicalDeviceEnabled ) { return iosUtil . targets ( idbPath , isPhysicalDeviceEnabled ) . catch ( ( e ) => { console . error ( 'Failed to get active iOS devices:' , e . message ) ; return [ ] ; } ) ; } export function parseXcodeFromCoreSimPath ( line ) { return line . match ( /\/[\/\w@)(\-\+]*\/Xcode[^/]*\.app\/Contents\/Developer/ ) ; }