Files
flipper/desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Lawrence Lomax aeb0b5f317 Extract getSimulators to IOSBridge
Summary:
Extracts `getSimulator` interrnals to `SimctlBridge`. This allows this functionality to be used independently of things like the the flipper server.

For now this just moves the functionality, but future diffs will build on top of this.

Reviewed By: passy

Differential Revision: D33842986

fbshipit-source-id: bae26a9bd5c21c9813f8a2b10c3b3e3efc1c5929
2022-01-31 07:29:34 -08:00

266 lines
8.8 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and 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 type {IOSDeviceParams} from 'flipper-common';
import path from 'path';
import childProcess from 'child_process';
import {exec} from 'promisify-child-process';
import iosUtil from './iOSContainerUtility';
import IOSDevice from './IOSDevice';
import {
ERR_NO_IDB_OR_XCODE_AVAILABLE,
IOSBridge,
makeIOSBridge,
SimctlBridge,
} from './IOSBridge';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {notNull} from '../../utils/typeUtils';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
import {IdbConfig, setIdbConfig} from './idbConfig';
import {assertNotNull} from 'flipper-server-core/src/comms/Utilities';
export class IOSDeviceManager {
private portForwarders: Array<ChildProcess> = [];
private idbConfig?: IdbConfig;
private portforwardingClient = path.join(
getFlipperServerConfig().paths.staticPath,
'PortForwardingMacApp.app',
'Contents',
'MacOS',
'PortForwardingMacApp',
);
iosBridge: IOSBridge | undefined;
simctlBridge: SimctlBridge = new SimctlBridge();
private xcodeVersionMismatchFound = false;
public xcodeCommandLineToolsDetected = false;
constructor(private flipperServer: FlipperServerImpl) {}
private forwardPort(port: number, multiplexChannelPort: number) {
const child = childProcess.execFile(
this.portforwardingClient,
[`-portForward=${port}`, `-multiplexChannelPort=${multiplexChannelPort}`],
(err, stdout, stderr) => {
if (err) {
// This happens on app reloads and doesn't need to be treated as an error.
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(): void {
if (this.portForwarders.length > 0) {
// Only ever start them once.
return;
}
// start port forwarding server for real device connections
// TODO: ports should be picked up from flipperServer.config?
this.portForwarders = [
this.forwardPort(8089, 8079),
this.forwardPort(8088, 8078),
];
}
getAllPromisesForQueryingDevices(
isXcodeDetected: boolean,
isIdbAvailable: boolean,
): Array<Promise<any>> {
assertNotNull(this.idbConfig);
return [
isIdbAvailable
? getActiveDevices(
this.idbConfig.idbPath,
this.idbConfig.enablePhysicalIOS,
).then((devices: IOSDeviceParams[]) => {
this.processDevices(devices);
})
: null,
!isIdbAvailable && isXcodeDetected
? this.getSimulators(true).then((devices) =>
this.processDevices(devices),
)
: null,
isXcodeDetected ? this.checkXcodeVersionMismatch() : null,
].filter(notNull);
}
private async queryDevices(): Promise<any> {
assertNotNull(this.idbConfig);
const isXcodeInstalled = await iosUtil.isXcodeDetected();
const isIdbAvailable = await iosUtil.isAvailable(this.idbConfig.idbPath);
console.debug(
`[conn] queryDevices. isXcodeInstalled ${isXcodeInstalled}, isIdbAvailable ${isIdbAvailable}`,
);
return Promise.all(
this.getAllPromisesForQueryingDevices(isXcodeInstalled, isIdbAvailable),
);
}
private processDevices(activeDevices: IOSDeviceParams[]) {
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;
this.idbConfig = setIdbConfig(settings);
try {
const isDetected = await iosUtil.isXcodeDetected();
this.xcodeCommandLineToolsDetected = isDetected;
if (settings.enablePhysicalIOS) {
this.startDevicePortForwarders();
}
try {
// Awaiting the promise here to trigger immediate error handling.
this.iosBridge = await makeIOSBridge(settings.idbPath, isDetected);
this.queryDevicesForever();
} catch (err) {
// This case is expected if both Xcode and idb are missing.
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: boolean): Promise<Array<IOSDeviceParams>> {
return this.simctlBridge.getActiveDevices(bootedOnly).catch((e: Error) => {
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(() => {
// 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(() => 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 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 [];
});
}
export function parseXcodeFromCoreSimPath(
line: string,
): RegExpMatchArray | null {
return line.match(/\/[\/\w@)(\-\+]*\/Xcode[^/]*\.app\/Contents\/Developer/);
}