Files
flipper/desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Lorenzo Blasa 03c0874f30 Query devices should be async
Summary: ^

Reviewed By: aigoncharov

Differential Revision: D48781102

fbshipit-source-id: c987be90c7bca7dbab40a89b389ee86ef2230393
2023-08-30 04:24:05 -07:00

276 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, {IdbConfig} from './iOSContainerUtility';
import IOSDevice from './IOSDevice';
import {
ERR_NO_IDB_OR_XCODE_AVAILABLE,
IOSBridge,
makeIOSBridge,
SimctlBridge,
} from './IOSBridge';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
import iOSCertificateProvider from './iOSCertificateProvider';
import exitHook from 'exit-hook';
export class IOSDeviceManager {
private portForwarders: Array<ChildProcess> = [];
private portforwardingClient = path.join(
getFlipperServerConfig().paths.staticPath,
'PortForwardingMacApp.app',
'Contents',
'MacOS',
'PortForwardingMacApp',
);
simctlBridge: SimctlBridge = new SimctlBridge();
readonly certificateProvider: iOSCertificateProvider;
constructor(
private readonly flipperServer: FlipperServerImpl,
private readonly idbConfig: IdbConfig,
) {
this.certificateProvider = new iOSCertificateProvider(this.idbConfig);
}
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`);
}
});
exitHook(() => {
child.kill('SIGKILL');
});
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),
this.forwardPort(9089, 9079),
this.forwardPort(9088, 9078),
];
}
async queryDevices(bridge: IOSBridge): Promise<any> {
const devices = await bridge.getActiveDevices(true);
return this.processDevices(bridge, devices);
}
private processDevices(bridge: IOSBridge, activeDevices: IOSDeviceParams[]) {
const currentDeviceIDs = new Set(
this.flipperServer
.getDevices()
.filter((device) => device.info.os === 'iOS')
.filter((device) => device.info.deviceType !== 'dummy')
.map((device) => device.serial),
);
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,
bridge,
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() {
try {
const isDetected = await iosUtil.isXcodeDetected();
if (this.idbConfig.enablePhysicalIOS) {
this.startDevicePortForwarders();
}
try {
// Check for version mismatch now for immediate error handling.
await this.checkXcodeVersionMismatch();
// Awaiting the promise here to trigger immediate error handling.
const bridge = await makeIOSBridge(
this.idbConfig.idbPath,
isDetected,
this.idbConfig.enablePhysicalIOS,
);
await this.queryDevicesForever(bridge);
} 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);
}
}
async getSimulators(bootedOnly: boolean): Promise<Array<IOSDeviceParams>> {
try {
return await this.simctlBridge.getActiveDevices(bootedOnly);
} 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 [];
}
}
private async queryDevicesForever(bridge: IOSBridge) {
try {
await this.queryDevices(bridge);
// 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(bridge), 3000);
} catch (err) {
console.warn('Failed to continuously query devices:', err);
}
}
async checkXcodeVersionMismatch() {
try {
const [{stdout: xcodeSelectStdout}, {stdout: simulatorProcessStdout}] =
await Promise.all([
exec('xcode-select -p'),
exec(
"pgrep Simulator | xargs ps -o command | grep -v grep | grep Simulator.app | awk '{print $1}'",
),
]);
const xcodeCLIVersion = xcodeSelectStdout!.toString().trim();
const runningSimulatorApplications = simulatorProcessStdout!
.toString()
.split('\n')
.filter((application) => application.length > 0);
const errorMessage = checkXcodeVersionMismatch(
runningSimulatorApplications,
xcodeCLIVersion,
);
if (errorMessage === undefined) {
return;
}
this.flipperServer.emit('notification', {
type: 'error',
title: 'Xcode version mismatch',
description: errorMessage,
});
} catch (e) {
// This is not an error. It depends on the user's local setup that we cannot influence.
console.warn('Failed to determine Xcode version:', e);
}
}
async idbKill() {
if (!this.idbConfig.idbPath || this.idbConfig.idbPath.length === 0) {
return;
}
const cmd = `${this.idbConfig.idbPath} kill`;
await exec(cmd);
}
}
function confirmSimulatorAppMatchesThatOfXcodeSelect(
runningSimulatorApps: Array<string>,
xcodeCLIVersion: string,
): string | undefined {
for (const runningSimulatorApp of runningSimulatorApps) {
if (!runningSimulatorApp) {
continue;
}
if (runningSimulatorApp.startsWith(xcodeCLIVersion)) {
continue;
}
return (
runningSimulatorApp.split('/Contents/Developer')[0] +
'/Contents/Developer'
);
}
return undefined;
}
export function checkXcodeVersionMismatch(
runningSimulatorApps: Array<string>,
xcodeCLIVersion: string,
): string | undefined {
if (runningSimulatorApps.length === 0) {
return undefined;
}
if (xcodeCLIVersion == '/Library/Developer/CommandLineTools') {
return `A Simulator is running and "xcode-select" has not been used, please run "xcode-select" for the Xcode that is running the simulator at ${runningSimulatorApps}`;
}
const mismatchedVersion = confirmSimulatorAppMatchesThatOfXcodeSelect(
runningSimulatorApps,
xcodeCLIVersion,
);
if (mismatchedVersion === undefined) {
return;
}
return `Xcode version mismatch: Simulator is running from "${mismatchedVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${xcodeCLIVersion}" can fix this. For example: "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"`;
}