Move app/server to flipper-server-core
Summary: moved `app/src/server` to `flipper-server-core/src` and fixed any fallout from that (aka integration points I missed on the preparing diffs). Reviewed By: passy Differential Revision: D31541378 fbshipit-source-id: 8a7e0169ebefa515781f6e5e0f7b926415d4b7e9
This commit is contained in:
committed by
Facebook GitHub Bot
parent
3e7a6b1b4b
commit
d88b28330a
31
desktop/flipper-server-core/src/devices/DummyDevice.tsx
Normal file
31
desktop/flipper-server-core/src/devices/DummyDevice.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 {DeviceOS} from 'flipper-common';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {ServerDevice} from './ServerDevice';
|
||||
|
||||
/**
|
||||
* Use this device when you do not have the actual uuid of the device. For example, it is currently used in the case when, we do certificate exchange through WWW mode. In this mode we do not know the device id of the app and we generate a fake one.
|
||||
*/
|
||||
export default class DummyDevice extends ServerDevice {
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
title: string,
|
||||
os: DeviceOS,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType: 'dummy',
|
||||
title,
|
||||
os,
|
||||
});
|
||||
}
|
||||
}
|
||||
86
desktop/flipper-server-core/src/devices/ServerDevice.tsx
Normal file
86
desktop/flipper-server-core/src/devices/ServerDevice.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 {DeviceDescription, DeviceLogEntry} from 'flipper-common';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
|
||||
export abstract class ServerDevice {
|
||||
readonly info: DeviceDescription;
|
||||
readonly flipperServer: FlipperServerImpl;
|
||||
connected = true;
|
||||
|
||||
constructor(flipperServer: FlipperServerImpl, info: DeviceDescription) {
|
||||
this.flipperServer = flipperServer;
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
get serial(): string {
|
||||
return this.info.serial;
|
||||
}
|
||||
|
||||
addLogEntry(entry: DeviceLogEntry) {
|
||||
this.flipperServer.emit('device-log', {
|
||||
serial: this.serial,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The device might have no active connection
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
// to be subclassed
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
// to be subclassed
|
||||
}
|
||||
|
||||
async screenshotAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
screenshot(): Promise<Buffer> {
|
||||
return Promise.reject(
|
||||
new Error('No screenshot support for current device'),
|
||||
);
|
||||
}
|
||||
|
||||
async screenCaptureAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async startScreenCapture(_destination: string): Promise<void> {
|
||||
throw new Error('startScreenCapture not implemented on BaseDevice ');
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
throw new Error('stopScreenCapture not implemented on BaseDevice ');
|
||||
}
|
||||
|
||||
async executeShell(_command: string): Promise<string> {
|
||||
throw new Error('executeShell not implemented on BaseDevice');
|
||||
}
|
||||
|
||||
async forwardPort(_local: string, _remote: string): Promise<boolean> {
|
||||
throw new Error('forwardPort not implemented on BaseDevice');
|
||||
}
|
||||
|
||||
async clearLogs(): Promise<void> {
|
||||
// no-op on most devices
|
||||
}
|
||||
|
||||
async navigateToLocation(_location: string) {
|
||||
throw new Error('navigateLocation not implemented on BaseDevice');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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 adb, {Client as ADBClient, PullTransfer} from 'adbkit';
|
||||
import {Priority, Reader} from 'adbkit-logcat';
|
||||
import {createWriteStream} from 'fs';
|
||||
import type {DeviceLogLevel, DeviceType} from 'flipper-common';
|
||||
import which from 'which';
|
||||
import {spawn} from 'child_process';
|
||||
import {dirname, join} from 'path';
|
||||
import {DeviceSpec} from 'flipper-plugin-lib';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
|
||||
|
||||
export default class AndroidDevice extends ServerDevice {
|
||||
adb: ADBClient;
|
||||
pidAppMapping: {[key: number]: string} = {};
|
||||
private recordingProcess?: Promise<string>;
|
||||
reader?: Reader;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
adb: ADBClient,
|
||||
abiList: Array<string>,
|
||||
sdkVersion: string,
|
||||
specs: DeviceSpec[] = [],
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType,
|
||||
title,
|
||||
os: 'Android',
|
||||
icon: 'mobile',
|
||||
specs,
|
||||
abiList,
|
||||
sdkVersion,
|
||||
});
|
||||
this.adb = adb;
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
this.adb
|
||||
.openLogcat(this.serial, {clear: true})
|
||||
.then((reader) => {
|
||||
this.reader = reader;
|
||||
reader
|
||||
.on('entry', (entry) => {
|
||||
let type: DeviceLogLevel = 'unknown';
|
||||
if (entry.priority === Priority.VERBOSE) {
|
||||
type = 'verbose';
|
||||
}
|
||||
if (entry.priority === Priority.DEBUG) {
|
||||
type = 'debug';
|
||||
}
|
||||
if (entry.priority === Priority.INFO) {
|
||||
type = 'info';
|
||||
}
|
||||
if (entry.priority === Priority.WARN) {
|
||||
type = 'warn';
|
||||
}
|
||||
if (entry.priority === Priority.ERROR) {
|
||||
type = 'error';
|
||||
}
|
||||
if (entry.priority === Priority.FATAL) {
|
||||
type = 'fatal';
|
||||
}
|
||||
|
||||
this.addLogEntry({
|
||||
tag: entry.tag,
|
||||
pid: entry.pid,
|
||||
tid: entry.tid,
|
||||
message: entry.message,
|
||||
date: entry.date,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.on('end', () => {
|
||||
if (this.reader) {
|
||||
// logs didn't stop gracefully
|
||||
setTimeout(() => {
|
||||
if (this.connected) {
|
||||
console.warn(
|
||||
`Log stream broken: ${this.serial} - restarting`,
|
||||
);
|
||||
this.startLogging();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
.on('error', (e) => {
|
||||
console.warn('Failed to read from adb logcat: ', e);
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to open log stream: ', e);
|
||||
});
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
this.reader?.end();
|
||||
this.reader = undefined;
|
||||
}
|
||||
|
||||
reverse(ports: number[]): Promise<void> {
|
||||
return Promise.all(
|
||||
ports.map((port) =>
|
||||
this.adb.reverse(this.serial, `tcp:${port}`, `tcp:${port}`),
|
||||
),
|
||||
).then(() => {
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
clearLogs(): Promise<void> {
|
||||
return this.executeShellOrDie(['logcat', '-c']);
|
||||
}
|
||||
|
||||
async navigateToLocation(location: string) {
|
||||
const shellCommand = `am start ${encodeURI(location)}`;
|
||||
this.adb.shell(this.serial, shellCommand);
|
||||
}
|
||||
|
||||
async screenshot(): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.adb
|
||||
.screencap(this.serial)
|
||||
.then((stream) => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
stream
|
||||
.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
.once('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
})
|
||||
.once('error', reject);
|
||||
})
|
||||
.catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async screenCaptureAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.executeShellOrDie(
|
||||
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
|
||||
);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async executeShell(command: string): Promise<string> {
|
||||
return await this.adb
|
||||
.shell(this.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim());
|
||||
}
|
||||
|
||||
private async executeShellOrDie(command: string | string[]): Promise<void> {
|
||||
const output = await this.adb
|
||||
.shell(this.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim());
|
||||
if (output) {
|
||||
throw new Error(output);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSdkVersion(): Promise<number> {
|
||||
return await this.adb
|
||||
.shell(this.serial, 'getprop ro.build.version.sdk')
|
||||
.then(adb.util.readAll)
|
||||
.then((output) => Number(output.toString().trim()));
|
||||
}
|
||||
|
||||
private async isValidFile(filePath: string): Promise<boolean> {
|
||||
const sdkVersion = await this.getSdkVersion();
|
||||
const fileSize = await this.adb
|
||||
.shell(this.serial, `ls -l "${filePath}"`)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim().split(' '))
|
||||
.then((x) => x.filter(Boolean))
|
||||
.then((x) => (sdkVersion > 23 ? Number(x[4]) : Number(x[3])));
|
||||
|
||||
return fileSize > 0;
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
await this.executeShellOrDie(
|
||||
`mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`,
|
||||
);
|
||||
const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`;
|
||||
let newSize: string | undefined;
|
||||
try {
|
||||
const sizeString = (
|
||||
await adb.util.readAll(await this.adb.shell(this.serial, 'wm size'))
|
||||
).toString();
|
||||
const size = sizeString.split(' ').slice(-1).pop()?.split('x');
|
||||
if (size && size.length === 2) {
|
||||
const width = parseInt(size[0], 10);
|
||||
const height = parseInt(size[1], 10);
|
||||
if (width > height) {
|
||||
newSize = '1280x720';
|
||||
} else {
|
||||
newSize = '720x1280';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while getting device size', err);
|
||||
}
|
||||
const sizeArg = newSize ? `--size ${newSize}` : '';
|
||||
const cmd = `screenrecord ${sizeArg} "${recordingLocation}"`;
|
||||
this.recordingProcess = this.adb
|
||||
.shell(this.serial, cmd)
|
||||
.then(adb.util.readAll)
|
||||
.then(async (output) => {
|
||||
const isValid = await this.isValidFile(recordingLocation);
|
||||
if (!isValid) {
|
||||
const outputMessage = output.toString().trim();
|
||||
throw new Error(
|
||||
'Recording was not properly started: \n' + outputMessage,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(_) =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
const stream: PullTransfer = await this.adb.pull(
|
||||
this.serial,
|
||||
recordingLocation,
|
||||
);
|
||||
stream.on('end', resolve as () => void);
|
||||
stream.on('error', reject);
|
||||
stream.pipe(createWriteStream(destination, {autoClose: true}), {
|
||||
end: true,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then((_) => destination);
|
||||
|
||||
return this.recordingProcess.then((_) => {});
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
const {recordingProcess} = this;
|
||||
if (!recordingProcess) {
|
||||
return Promise.reject(new Error('Recording was not properly started'));
|
||||
}
|
||||
await this.adb.shell(this.serial, `pkill -l2 screenrecord`);
|
||||
const destination = await recordingProcess;
|
||||
this.recordingProcess = undefined;
|
||||
return destination;
|
||||
}
|
||||
|
||||
async forwardPort(local: string, remote: string): Promise<boolean> {
|
||||
return this.adb.forward(this.serial, local, remote);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.recordingProcess) {
|
||||
this.stopScreenCapture();
|
||||
}
|
||||
super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchEmulator(name: string, coldBoot: boolean = false) {
|
||||
// On Linux, you must run the emulator from the directory it's in because
|
||||
// reasons ...
|
||||
return which('emulator')
|
||||
.catch(() =>
|
||||
join(
|
||||
process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '',
|
||||
'tools',
|
||||
'emulator',
|
||||
),
|
||||
)
|
||||
.then((emulatorPath) => {
|
||||
if (emulatorPath) {
|
||||
const child = spawn(
|
||||
emulatorPath,
|
||||
[`@${name}`, ...(coldBoot ? ['-no-snapshot-load'] : [])],
|
||||
{
|
||||
detached: true,
|
||||
cwd: dirname(emulatorPath),
|
||||
},
|
||||
);
|
||||
child.stderr.on('data', (data) => {
|
||||
console.warn(`Android emulator stderr: ${data}`);
|
||||
});
|
||||
child.on('error', (e) => console.warn('Android emulator error:', e));
|
||||
} else {
|
||||
throw new Error('Could not get emulator path');
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error('Android emulator startup failed:', e));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 {DeviceType} from 'flipper-plugin-lib';
|
||||
import AndroidDevice from './AndroidDevice';
|
||||
import {Client as ADBClient} from 'adbkit';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
export default class KaiOSDevice extends AndroidDevice {
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
adb: ADBClient,
|
||||
abiList: Array<string>,
|
||||
sdkVersion: string,
|
||||
) {
|
||||
super(flipperServer, serial, deviceType, title, adb, abiList, sdkVersion, [
|
||||
'KaiOS',
|
||||
]);
|
||||
}
|
||||
|
||||
async screenCaptureAvailable() {
|
||||
// The default way of capturing screenshots through adb does not seem to work
|
||||
// There is a way of getting a screenshot through KaiOS dev tools though
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 adbConfig from '../adbConfig';
|
||||
|
||||
test('get host and port from ADB_SERVER_SOCKET', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp:127.0.0.1:5037';
|
||||
const {port, host} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
expect(host).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
test('get IPv6 address from ADB_SERVER_SOCKET', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp::::1:5037';
|
||||
const {host} = adbConfig();
|
||||
expect(host).toBe(':::1');
|
||||
});
|
||||
|
||||
test('get port from ANDROID_ADB_SERVER_PORT', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = '1337';
|
||||
process.env.ADB_SERVER_SOCKET = undefined;
|
||||
const {port} = adbConfig();
|
||||
expect(port).toBe(1337);
|
||||
});
|
||||
|
||||
test('prefer ADB_SERVER_SOCKET over ANDROID_ADB_SERVER_PORT', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = '1337';
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp:127.0.0.1:5037';
|
||||
const {port} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
});
|
||||
|
||||
test('have defaults', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = undefined;
|
||||
const {port, host} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
expect(host).toBe('localhost');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 {reportPlatformFailures} from 'flipper-common';
|
||||
import {execFile} from 'promisify-child-process';
|
||||
import adbConfig from './adbConfig';
|
||||
import adbkit, {Client} from 'adbkit';
|
||||
import path from 'path';
|
||||
|
||||
let instance: Promise<Client>;
|
||||
|
||||
type Config = {
|
||||
androidHome: string;
|
||||
};
|
||||
|
||||
export function getAdbClient(config: Config): Promise<Client> {
|
||||
if (!instance) {
|
||||
instance = reportPlatformFailures(createClient(config), 'createADBClient');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* Adbkit will attempt to start the adb server if it's not already running,
|
||||
however, it sometimes fails with ENOENT errors. So instead, we start it
|
||||
manually before requesting a client. */
|
||||
async function createClient(config: Config): Promise<Client> {
|
||||
const androidHome = config.androidHome;
|
||||
const adbPath = path.resolve(androidHome, 'platform-tools', 'adb');
|
||||
return reportPlatformFailures<Client>(
|
||||
execFile(adbPath, ['start-server']).then(() =>
|
||||
adbkit.createClient(adbConfig()),
|
||||
),
|
||||
'createADBClient.shell',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 {parseEnvironmentVariableAsNumber} from '../../utils/environmentVariables';
|
||||
|
||||
export default () => {
|
||||
let port = parseEnvironmentVariableAsNumber(
|
||||
'ANDROID_ADB_SERVER_PORT',
|
||||
5037,
|
||||
) as number;
|
||||
|
||||
let host = 'localhost';
|
||||
|
||||
const socket = (process.env.ADB_SERVER_SOCKET || '').trim();
|
||||
if (socket && socket.length > 0) {
|
||||
const match = socket.match(/^(tcp:)(\S+):(\d+)/);
|
||||
if (match && match.length === 4) {
|
||||
host = match[2];
|
||||
port = parseInt(match[3], 10);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
host,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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 {UnsupportedError} from 'flipper-common';
|
||||
import adbkit, {Client} from 'adbkit';
|
||||
|
||||
const allowedAppNameRegex = /^[\w.-]+$/;
|
||||
const appNotApplicationRegex = /not an application/;
|
||||
const appNotDebuggableRegex = /debuggable/;
|
||||
const operationNotPermittedRegex = /not permitted/;
|
||||
const logTag = 'androidContainerUtility';
|
||||
|
||||
export type AppName = string;
|
||||
export type Command = string;
|
||||
export type FilePath = string;
|
||||
export type FileContent = string;
|
||||
|
||||
export async function push(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
filepath: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
validateAppName(app);
|
||||
validateFilePath(filepath);
|
||||
validateFileContent(contents);
|
||||
return await _push(client, deviceId, app, filepath, contents);
|
||||
}
|
||||
|
||||
export async function pull(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
validateAppName(app);
|
||||
validateFilePath(path);
|
||||
return await _pull(client, deviceId, app, path);
|
||||
}
|
||||
|
||||
function validateAppName(app: string): void {
|
||||
if (!app.match(allowedAppNameRegex)) {
|
||||
throw new Error(`Disallowed run-as user: ${app}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFilePath(filePath: string): void {
|
||||
if (filePath.match(/[']/)) {
|
||||
throw new Error(`Disallowed escaping filepath: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFileContent(content: string): void {
|
||||
if (content.match(/["]/)) {
|
||||
throw new Error(`Disallowed escaping file content: ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
enum RunAsErrorCode {
|
||||
NotAnApp = 1,
|
||||
NotDebuggable = 2,
|
||||
}
|
||||
|
||||
class RunAsError extends Error {
|
||||
code: RunAsErrorCode;
|
||||
|
||||
constructor(code: RunAsErrorCode, message?: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
function _push(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: AppName,
|
||||
filename: FilePath,
|
||||
contents: FileContent,
|
||||
): Promise<void> {
|
||||
console.debug(`Deploying ${filename} to ${deviceId}:${app}`, logTag);
|
||||
// TODO: this is sensitive to escaping issues, can we leverage client.push instead?
|
||||
// https://www.npmjs.com/package/adbkit#pushing-a-file-to-all-connected-devices
|
||||
const command = `echo "${contents}" > '${filename}' && chmod 644 '${filename}'`;
|
||||
return executeCommandAsApp(client, deviceId, app, command)
|
||||
.then((_) => undefined)
|
||||
.catch((error) => {
|
||||
if (error instanceof RunAsError) {
|
||||
// Fall back to running the command directly. This will work if adb is running as root.
|
||||
executeCommandWithSu(client, deviceId, app, command, error);
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function _pull(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: AppName,
|
||||
path: FilePath,
|
||||
): Promise<string> {
|
||||
const command = `cat '${path}'`;
|
||||
return executeCommandAsApp(client, deviceId, app, command).catch((error) => {
|
||||
if (error instanceof RunAsError) {
|
||||
// Fall back to running the command directly. This will work if adb is running as root.
|
||||
return executeCommandWithSu(client, deviceId, app, command, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Keep this method private since it relies on pre-validated arguments
|
||||
function executeCommandAsApp(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
): Promise<string> {
|
||||
return _executeCommandWithRunner(
|
||||
client,
|
||||
deviceId,
|
||||
app,
|
||||
command,
|
||||
`run-as '${app}'`,
|
||||
);
|
||||
}
|
||||
|
||||
async function executeCommandWithSu(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
originalErrorToThrow: RunAsError,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return _executeCommandWithRunner(client, deviceId, app, command, 'su');
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
throw originalErrorToThrow;
|
||||
}
|
||||
}
|
||||
|
||||
function _executeCommandWithRunner(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
runner: string,
|
||||
): Promise<string> {
|
||||
return client
|
||||
.shell(deviceId, `echo '${command}' | ${runner}`)
|
||||
.then(adbkit.util.readAll)
|
||||
.then((buffer) => buffer.toString())
|
||||
.then((output) => {
|
||||
if (output.match(appNotApplicationRegex)) {
|
||||
throw new RunAsError(
|
||||
RunAsErrorCode.NotAnApp,
|
||||
`Android package ${app} is not an application. To use it with Flipper, either run adb as root or add an <application> tag to AndroidManifest.xml`,
|
||||
);
|
||||
}
|
||||
if (output.match(appNotDebuggableRegex)) {
|
||||
throw new RunAsError(
|
||||
RunAsErrorCode.NotDebuggable,
|
||||
`Android app ${app} is not debuggable. To use it with Flipper, add android:debuggable="true" to the application section of AndroidManifest.xml`,
|
||||
);
|
||||
}
|
||||
if (output.toLowerCase().match(operationNotPermittedRegex)) {
|
||||
throw new UnsupportedError(
|
||||
`Your android device (${deviceId}) does not support the adb shell run-as command. We're tracking this at https://github.com/facebook/flipper/issues/92`,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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 AndroidDevice from './AndroidDevice';
|
||||
import KaiOSDevice from './KaiOSDevice';
|
||||
import child_process from 'child_process';
|
||||
import {getAdbClient} from './adbClient';
|
||||
import which from 'which';
|
||||
import {promisify} from 'util';
|
||||
import {Client as ADBClient, Device} from 'adbkit';
|
||||
import {join} from 'path';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {notNull} from '../../utils/typeUtils';
|
||||
import {
|
||||
getServerPortsConfig,
|
||||
getFlipperServerConfig,
|
||||
} from '../../FlipperServerConfig';
|
||||
|
||||
export class AndroidDeviceManager {
|
||||
// cache emulator path
|
||||
private emulatorPath: string | undefined;
|
||||
|
||||
constructor(public flipperServer: FlipperServerImpl) {}
|
||||
|
||||
private createDevice(
|
||||
adbClient: ADBClient,
|
||||
device: Device,
|
||||
): Promise<AndroidDevice | undefined> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const type =
|
||||
device.type !== 'device' || device.id.startsWith('emulator')
|
||||
? 'emulator'
|
||||
: 'physical';
|
||||
|
||||
try {
|
||||
const props = await adbClient.getProperties(device.id);
|
||||
try {
|
||||
let name = props['ro.product.model'];
|
||||
const abiString = props['ro.product.cpu.abilist'] || '';
|
||||
const sdkVersion = props['ro.build.version.sdk'] || '';
|
||||
const abiList = abiString.length > 0 ? abiString.split(',') : [];
|
||||
if (type === 'emulator') {
|
||||
name = (await this.getRunningEmulatorName(device.id)) || name;
|
||||
}
|
||||
const isKaiOSDevice = Object.keys(props).some(
|
||||
(name) => name.startsWith('kaios') || name.startsWith('ro.kaios'),
|
||||
);
|
||||
const androidLikeDevice = new (
|
||||
isKaiOSDevice ? KaiOSDevice : AndroidDevice
|
||||
)(
|
||||
this.flipperServer,
|
||||
device.id,
|
||||
type,
|
||||
name,
|
||||
adbClient,
|
||||
abiList,
|
||||
sdkVersion,
|
||||
);
|
||||
const ports = getServerPortsConfig();
|
||||
if (ports.serverPorts) {
|
||||
await androidLikeDevice
|
||||
.reverse([ports.serverPorts.secure, ports.serverPorts.insecure])
|
||||
// We may not be able to establish a reverse connection, e.g. for old Android SDKs.
|
||||
// This is *generally* fine, because we hard-code the ports on the SDK side.
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
`Failed to reverse-proxy ports on device ${androidLikeDevice.serial}: ${e}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (type === 'physical') {
|
||||
// forward port for React DevTools, which is fixed on React Native
|
||||
await androidLikeDevice.reverse([8097]).catch((e) => {
|
||||
console.warn(
|
||||
`Failed to reverse-proxy React DevTools port 8097 on ${androidLikeDevice.serial}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
resolve(androidLikeDevice);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
e.message &&
|
||||
e.message === `Failure: 'device still connecting'`
|
||||
) {
|
||||
console.debug('Device still connecting: ' + device.id);
|
||||
} else {
|
||||
const isAuthorizationError = (e?.message as string)?.includes(
|
||||
'device unauthorized',
|
||||
);
|
||||
if (!isAuthorizationError) {
|
||||
console.error('Failed to connect to android device', e);
|
||||
}
|
||||
this.flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Could not connect to ' + device.id,
|
||||
description: isAuthorizationError
|
||||
? 'Make sure to authorize debugging on the phone'
|
||||
: 'Failed to setup connection: ' + e,
|
||||
});
|
||||
}
|
||||
resolve(undefined); // not ready yet, we will find it in the next tick
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getEmulatorPath(): Promise<string> {
|
||||
if (this.emulatorPath) {
|
||||
return this.emulatorPath;
|
||||
}
|
||||
// TODO: this doesn't respect the currently configured android_home in settings!
|
||||
try {
|
||||
this.emulatorPath = (await promisify(which)('emulator')) as string;
|
||||
} catch (_e) {
|
||||
this.emulatorPath = join(
|
||||
process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '',
|
||||
'tools',
|
||||
'emulator',
|
||||
);
|
||||
}
|
||||
return this.emulatorPath;
|
||||
}
|
||||
|
||||
async getAndroidEmulators(): Promise<string[]> {
|
||||
const emulatorPath = await this.getEmulatorPath();
|
||||
return new Promise<string[]>((resolve) => {
|
||||
child_process.execFile(
|
||||
emulatorPath as string,
|
||||
['-list-avds'],
|
||||
(error: Error | null, data: string | null) => {
|
||||
if (error != null || data == null) {
|
||||
console.warn('List AVD failed: ', error);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const devices = data
|
||||
.split('\n')
|
||||
.filter(notNull)
|
||||
.filter((l) => l !== '');
|
||||
resolve(devices);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async getRunningEmulatorName(
|
||||
id: string,
|
||||
): Promise<string | null | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = id.replace('emulator-', '');
|
||||
// The GNU version of netcat doesn't terminate after 1s when
|
||||
// specifying `-w 1`, so we kill it after a timeout. Because
|
||||
// of that, even in case of an error, there may still be
|
||||
// relevant data for us to parse.
|
||||
child_process.exec(
|
||||
`echo "avd name" | nc -w 1 localhost ${port}`,
|
||||
{timeout: 1000, encoding: 'utf-8'},
|
||||
(error: Error | null | undefined, data) => {
|
||||
if (data != null && typeof data === 'string') {
|
||||
const match = data.trim().match(/(.*)\r\nOK$/);
|
||||
resolve(match != null && match.length > 0 ? match[1] : null);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async watchAndroidDevices() {
|
||||
try {
|
||||
const client = await getAdbClient(getFlipperServerConfig());
|
||||
client
|
||||
.trackDevices()
|
||||
.then((tracker) => {
|
||||
tracker.on('error', (err) => {
|
||||
if (err.message === 'Connection closed') {
|
||||
console.warn('adb server was shutdown');
|
||||
this.flipperServer
|
||||
.getDevices()
|
||||
.filter((d) => d instanceof AndroidDevice)
|
||||
.forEach((d) => {
|
||||
this.flipperServer.unregisterDevice(d.serial);
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.watchAndroidDevices();
|
||||
}, 500);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('add', async (device) => {
|
||||
if (device.type !== 'offline') {
|
||||
this.registerDevice(client, device);
|
||||
} else {
|
||||
console.warn(
|
||||
`[conn] Found device ${device.id}, but it has status offline. If this concerns an emulator and the problem persists, try these solutins: https://stackoverflow.com/a/21330228/1983583, https://stackoverflow.com/a/56053223/1983583`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('change', async (device) => {
|
||||
if (device.type === 'offline') {
|
||||
this.flipperServer.unregisterDevice(device.id);
|
||||
} else {
|
||||
this.registerDevice(client, device);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('remove', (device) => {
|
||||
this.flipperServer.unregisterDevice(device.id);
|
||||
});
|
||||
})
|
||||
.catch((err: {code: string}) => {
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
console.warn('adb server not running');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to watch for android devices: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerDevice(adbClient: ADBClient, deviceData: Device) {
|
||||
const androidDevice = await this.createDevice(adbClient, deviceData);
|
||||
if (!androidDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flipperServer.registerDevice(androidDevice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
export default class MacDevice extends ServerDevice {
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super(flipperServer, {
|
||||
serial: '',
|
||||
deviceType: 'physical',
|
||||
title: 'Mac',
|
||||
os: 'MacOS',
|
||||
icon: 'app-apple',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
export default class WindowsDevice extends ServerDevice {
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super(flipperServer, {
|
||||
serial: '',
|
||||
deviceType: 'physical',
|
||||
title: 'Windows',
|
||||
os: 'Windows',
|
||||
icon: 'app-microsoft-windows',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 MacDevice from './MacDevice';
|
||||
import WindowsDevice from './WindowsDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
export default (flipperServer: FlipperServerImpl) => {
|
||||
let device;
|
||||
if (process.platform === 'darwin') {
|
||||
device = new MacDevice(flipperServer);
|
||||
} else if (process.platform === 'win32') {
|
||||
device = new WindowsDevice(flipperServer);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
flipperServer.registerDevice(device);
|
||||
};
|
||||
184
desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx
Normal file
184
desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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-extra';
|
||||
import child_process from 'child_process';
|
||||
import {DeviceType} from 'flipper-plugin-lib';
|
||||
import {v1 as uuid} from 'uuid';
|
||||
import path from 'path';
|
||||
import {exec} from 'promisify-child-process';
|
||||
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
||||
|
||||
export const ERR_NO_IDB_OR_XCODE_AVAILABLE =
|
||||
'Neither Xcode nor idb available. Cannot provide iOS device functionality.';
|
||||
|
||||
export const ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB =
|
||||
'Cannot provide logs from a physical device without idb.';
|
||||
|
||||
export interface IOSBridge {
|
||||
startLogListener: (
|
||||
udid: string,
|
||||
deviceType: DeviceType,
|
||||
) => child_process.ChildProcessWithoutNullStreams;
|
||||
screenshot: (serial: string) => Promise<Buffer>;
|
||||
navigate: (serial: string, location: string) => Promise<void>;
|
||||
recordVideo: (
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
) => child_process.ChildProcess;
|
||||
}
|
||||
|
||||
async function isAvailable(idbPath: string): Promise<boolean> {
|
||||
if (!idbPath) {
|
||||
return false;
|
||||
}
|
||||
return fs.promises
|
||||
.access(idbPath, fs.constants.X_OK)
|
||||
.then((_) => true)
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
function getLogExtraArgs(deviceType: DeviceType) {
|
||||
if (deviceType === 'physical') {
|
||||
return [
|
||||
// idb has a --json option, but that doesn't actually work for physical
|
||||
// devices!
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function idbStartLogListener(
|
||||
idbPath: string,
|
||||
udid: string,
|
||||
deviceType: DeviceType,
|
||||
): child_process.ChildProcessWithoutNullStreams {
|
||||
return child_process.spawn(
|
||||
idbPath,
|
||||
['log', '--udid', udid, '--', ...getLogExtraArgs(deviceType)],
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function xcrunStartLogListener(udid: string, deviceType: DeviceType) {
|
||||
if (deviceType === 'physical') {
|
||||
throw new Error(ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB);
|
||||
}
|
||||
const deviceSetPath = process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
return child_process.spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
...deviceSetPath,
|
||||
'spawn',
|
||||
udid,
|
||||
'log',
|
||||
'stream',
|
||||
...getLogExtraArgs(deviceType),
|
||||
],
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function makeTempScreenshotFilePath() {
|
||||
const imageName = uuid() + '.png';
|
||||
return path.join(getFlipperServerConfig().tmpPath, imageName);
|
||||
}
|
||||
|
||||
async function runScreenshotCommand(
|
||||
command: string,
|
||||
imagePath: string,
|
||||
): Promise<Buffer> {
|
||||
await exec(command);
|
||||
const buffer = await fs.readFile(imagePath);
|
||||
await fs.unlink(imagePath);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function xcrunScreenshot(serial: string): Promise<Buffer> {
|
||||
const imagePath = makeTempScreenshotFilePath();
|
||||
const command = `xcrun simctl io ${serial} screenshot ${imagePath}`;
|
||||
return runScreenshotCommand(command, imagePath);
|
||||
}
|
||||
|
||||
export async function idbScreenshot(serial: string): Promise<Buffer> {
|
||||
const imagePath = makeTempScreenshotFilePath();
|
||||
const command = `idb screenshot --udid ${serial} ${imagePath}`;
|
||||
return runScreenshotCommand(command, imagePath);
|
||||
}
|
||||
|
||||
export async function xcrunNavigate(
|
||||
serial: string,
|
||||
location: string,
|
||||
): Promise<void> {
|
||||
exec(`xcrun simctl io ${serial} launch url "${location}"`);
|
||||
}
|
||||
|
||||
export async function idbNavigate(
|
||||
serial: string,
|
||||
location: string,
|
||||
): Promise<void> {
|
||||
exec(`idb open --udid ${serial} "${location}"`);
|
||||
}
|
||||
|
||||
export function xcrunRecordVideo(
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
): child_process.ChildProcess {
|
||||
console.log(`Starting screen record via xcrun to ${outputFile}.`);
|
||||
return exec(
|
||||
`xcrun simctl io ${serial} recordVideo --codec=h264 --force ${outputFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function idbRecordVideo(
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
): child_process.ChildProcess {
|
||||
console.log(`Starting screen record via idb to ${outputFile}.`);
|
||||
return exec(`idb record-video --udid ${serial} ${outputFile}`);
|
||||
}
|
||||
|
||||
export async function makeIOSBridge(
|
||||
idbPath: string,
|
||||
isXcodeDetected: boolean,
|
||||
isAvailableFn: (idbPath: string) => Promise<boolean> = isAvailable,
|
||||
): Promise<IOSBridge> {
|
||||
// prefer idb
|
||||
if (await isAvailableFn(idbPath)) {
|
||||
return {
|
||||
startLogListener: idbStartLogListener.bind(null, idbPath),
|
||||
screenshot: idbScreenshot,
|
||||
navigate: idbNavigate,
|
||||
recordVideo: idbRecordVideo,
|
||||
};
|
||||
}
|
||||
|
||||
// no idb, if it's a simulator and xcode is available, we can use xcrun
|
||||
if (isXcodeDetected) {
|
||||
return {
|
||||
startLogListener: xcrunStartLogListener,
|
||||
screenshot: xcrunScreenshot,
|
||||
navigate: xcrunNavigate,
|
||||
recordVideo: xcrunRecordVideo,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(ERR_NO_IDB_OR_XCODE_AVAILABLE);
|
||||
}
|
||||
296
desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx
Normal file
296
desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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 {
|
||||
DeviceLogLevel,
|
||||
DeviceLogEntry,
|
||||
DeviceType,
|
||||
timeout,
|
||||
} from 'flipper-common';
|
||||
import child_process, {ChildProcess} from 'child_process';
|
||||
import JSONStream from 'JSONStream';
|
||||
import {Transform} from 'stream';
|
||||
import {ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB, IOSBridge} from './IOSBridge';
|
||||
import split2 from 'split2';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
|
||||
|
||||
type RawLogEntry = {
|
||||
eventMessage: string;
|
||||
machTimestamp: number;
|
||||
messageType: IOSLogLevel;
|
||||
processID: number;
|
||||
processImagePath: string;
|
||||
processImageUUID: string;
|
||||
processUniqueID: number;
|
||||
senderImagePath: string;
|
||||
senderImageUUID: string;
|
||||
senderProgramCounter: number;
|
||||
threadID: number;
|
||||
timestamp: string;
|
||||
timezoneName: string;
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
// https://regex101.com/r/rrl03T/1
|
||||
// Mar 25 17:06:38 iPhone symptomsd(SymptomEvaluator)[125] <Notice>: Stuff
|
||||
const logRegex = /(^.{15}) ([^ ]+?) ([^\[]+?)\[(\d+?)\] <(\w+?)>: (.*)$/s;
|
||||
|
||||
export default class IOSDevice extends ServerDevice {
|
||||
log?: child_process.ChildProcessWithoutNullStreams;
|
||||
buffer: string;
|
||||
private recordingProcess?: ChildProcess;
|
||||
private recordingLocation?: string;
|
||||
private iOSBridge: IOSBridge;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
iOSBridge: IOSBridge,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType,
|
||||
title,
|
||||
os: 'iOS',
|
||||
icon: 'mobile',
|
||||
});
|
||||
this.buffer = '';
|
||||
this.iOSBridge = iOSBridge;
|
||||
}
|
||||
|
||||
async screenshot(): Promise<Buffer> {
|
||||
if (!this.connected) {
|
||||
return Buffer.from([]);
|
||||
}
|
||||
return await this.iOSBridge.screenshot(this.serial);
|
||||
}
|
||||
|
||||
async navigateToLocation(location: string) {
|
||||
return this.iOSBridge.navigate(this.serial, location).catch((err) => {
|
||||
console.warn(`Failed to navigate to location ${location}:`, err);
|
||||
return err;
|
||||
});
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
this.startLogListener(this.iOSBridge);
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
this.log?.kill();
|
||||
}
|
||||
|
||||
startLogListener(iOSBridge: IOSBridge, retries: number = 3) {
|
||||
if (retries === 0) {
|
||||
console.warn('Attaching iOS log listener continuously failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.log) {
|
||||
try {
|
||||
this.log = iOSBridge.startLogListener(
|
||||
this.serial,
|
||||
this.info.deviceType,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.message === ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB) {
|
||||
console.warn(e);
|
||||
} else {
|
||||
console.error('Failed to initialise device logs:', e);
|
||||
this.startLogListener(iOSBridge, retries - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.log.on('error', (err: Error) => {
|
||||
console.error('iOS log tailer error', err);
|
||||
});
|
||||
|
||||
this.log.stderr.on('data', (data: Buffer) => {
|
||||
console.warn('iOS log tailer stderr: ', data.toString());
|
||||
});
|
||||
|
||||
this.log.on('exit', () => {
|
||||
this.log = undefined;
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.info.deviceType === 'physical') {
|
||||
this.log.stdout.pipe(split2('\0')).on('data', (line: string) => {
|
||||
const parsed = IOSDevice.parseLogLine(line);
|
||||
if (parsed) {
|
||||
this.addLogEntry(parsed);
|
||||
} else {
|
||||
console.warn('Failed to parse iOS log line: ', line);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log.stdout
|
||||
.pipe(new StripLogPrefix())
|
||||
.pipe(JSONStream.parse('*'))
|
||||
.on('data', (data: RawLogEntry) => {
|
||||
const entry = IOSDevice.parseJsonLogEntry(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getLogLevel(level: string): DeviceLogLevel {
|
||||
switch (level) {
|
||||
case 'Default':
|
||||
return 'debug';
|
||||
case 'Info':
|
||||
return 'info';
|
||||
case 'Debug':
|
||||
return 'debug';
|
||||
case 'Error':
|
||||
return 'error';
|
||||
case 'Notice':
|
||||
return 'verbose';
|
||||
case 'Fault':
|
||||
return 'fatal';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static parseLogLine(line: string): DeviceLogEntry | undefined {
|
||||
const matches = line.match(logRegex);
|
||||
if (matches) {
|
||||
return {
|
||||
date: new Date(Date.parse(matches[1])),
|
||||
tag: matches[3],
|
||||
tid: 0,
|
||||
pid: parseInt(matches[4], 10),
|
||||
type: IOSDevice.getLogLevel(matches[5]),
|
||||
message: matches[6],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static parseJsonLogEntry(entry: RawLogEntry): DeviceLogEntry {
|
||||
let type: DeviceLogLevel = IOSDevice.getLogLevel(entry.messageType);
|
||||
|
||||
// when Apple log levels are not used, log messages can be prefixed with
|
||||
// their loglevel.
|
||||
if (entry.eventMessage.startsWith('[debug]')) {
|
||||
type = 'debug';
|
||||
} else if (entry.eventMessage.startsWith('[info]')) {
|
||||
type = 'info';
|
||||
} else if (entry.eventMessage.startsWith('[warn]')) {
|
||||
type = 'warn';
|
||||
} else if (entry.eventMessage.startsWith('[error]')) {
|
||||
type = 'error';
|
||||
}
|
||||
// remove type from mesage
|
||||
entry.eventMessage = entry.eventMessage.replace(
|
||||
/^\[(debug|info|warn|error)\]/,
|
||||
'',
|
||||
);
|
||||
|
||||
const tag = entry.processImagePath.split('/').pop() || '';
|
||||
|
||||
return {
|
||||
date: new Date(entry.timestamp),
|
||||
pid: entry.processID,
|
||||
tid: entry.threadID,
|
||||
tag,
|
||||
message: entry.eventMessage,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
async screenCaptureAvailable() {
|
||||
return this.info.deviceType === 'emulator' && this.connected;
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
this.recordingProcess = this.iOSBridge.recordVideo(
|
||||
this.serial,
|
||||
destination,
|
||||
);
|
||||
this.recordingLocation = destination;
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
if (this.recordingProcess && this.recordingLocation) {
|
||||
const prom = new Promise<void>((resolve, _reject) => {
|
||||
this.recordingProcess!.on(
|
||||
'exit',
|
||||
async (_code: number | null, _signal: NodeJS.Signals | null) => {
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
this.recordingProcess!.kill('SIGINT');
|
||||
});
|
||||
|
||||
const output: string = await timeout<void>(
|
||||
5000,
|
||||
prom,
|
||||
'Timed out to stop a screen capture.',
|
||||
)
|
||||
.then(() => {
|
||||
const {recordingLocation} = this;
|
||||
this.recordingLocation = undefined;
|
||||
return recordingLocation!;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.recordingLocation = undefined;
|
||||
console.warn('Failed to terminate iOS screen recording:', e);
|
||||
throw e;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
throw new Error('No recording in progress');
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.recordingProcess && this.recordingLocation) {
|
||||
this.stopScreenCapture();
|
||||
}
|
||||
super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Used to strip the initial output of the logging utility where it prints out settings.
|
||||
// We know the log stream is json so it starts with an open brace.
|
||||
class StripLogPrefix extends Transform {
|
||||
passedPrefix = false;
|
||||
|
||||
_transform(
|
||||
data: any,
|
||||
_encoding: string,
|
||||
callback: (err?: Error, data?: any) => void,
|
||||
) {
|
||||
if (this.passedPrefix) {
|
||||
this.push(data);
|
||||
} else {
|
||||
const dataString = data.toString();
|
||||
const index = dataString.indexOf('[');
|
||||
if (index >= 0) {
|
||||
this.push(dataString.substring(index));
|
||||
this.passedPrefix = true;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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 * as promisifyChildProcess from 'promisify-child-process';
|
||||
|
||||
jest.mock('child_process');
|
||||
jest.mock('promisify-child-process');
|
||||
|
||||
test('uses xcrun with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'emulator');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'deadbeef',
|
||||
'log',
|
||||
'stream',
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb when present and xcode detected', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'emulator');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'/usr/local/bin/idb',
|
||||
[
|
||||
'log',
|
||||
'--udid',
|
||||
'deadbeef',
|
||||
'--',
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb when present and xcode detected and physical device connected', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'physical');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'/usr/local/bin/idb',
|
||||
[
|
||||
'log',
|
||||
'--udid',
|
||||
'deadbeef',
|
||||
'--',
|
||||
// no further args; not supported by idb atm
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("without idb physical devices can't log", async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
expect(ib.startLogListener).toBeDefined(); // since we have xcode
|
||||
});
|
||||
|
||||
test('throws if no iOS support', async () => {
|
||||
await expect(makeIOSBridge('', false)).rejects.toThrow(
|
||||
'Neither Xcode nor idb available. Cannot provide iOS device functionality.',
|
||||
);
|
||||
});
|
||||
|
||||
test.unix(
|
||||
'uses xcrun to take screenshots with no idb when xcode is detected',
|
||||
async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.screenshot('deadbeef');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef screenshot /temp/00000000-0000-0000-0000-000000000000.png',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.unix('uses idb to take screenshots when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.screenshot('deadbeef');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb screenshot --udid deadbeef /temp/00000000-0000-0000-0000-000000000000.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses xcrun to navigate with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.navigate('deadbeef', 'fb://dummy');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef launch url "fb://dummy"',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb to navigate when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.navigate('deadbeef', 'fb://dummy');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb open --udid deadbeef "fb://dummy"',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses xcrun to record with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.recordVideo('deadbeef', '/tmp/video.mp4');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef recordVideo --codec=h264 --force /tmp/video.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb to record when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.recordVideo('deadbeef', '/tmo/video.mp4');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb record-video --udid deadbeef /tmo/video.mp4',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 {queryTargetsWithoutXcodeDependency} from '../iOSContainerUtility';
|
||||
|
||||
test('uses idbcompanion command for queryTargetsWithoutXcodeDependency', async () => {
|
||||
const mockedExec = jest.fn((_) =>
|
||||
Promise.resolve({
|
||||
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
|
||||
stderr: '{ "msg": "mocked stderr"}',
|
||||
}),
|
||||
);
|
||||
await queryTargetsWithoutXcodeDependency(
|
||||
'idbCompanionPath',
|
||||
true,
|
||||
(_) => Promise.resolve(true),
|
||||
mockedExec,
|
||||
);
|
||||
|
||||
expect(mockedExec).toBeCalledWith('idbCompanionPath --list 1 --only device');
|
||||
});
|
||||
|
||||
test('do not call idbcompanion if the path does not exist', async () => {
|
||||
const mockedExec = jest.fn((_) =>
|
||||
Promise.resolve({
|
||||
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
|
||||
stderr: '{"msg": "mocked stderr"}',
|
||||
}),
|
||||
);
|
||||
await queryTargetsWithoutXcodeDependency(
|
||||
'idbCompanionPath',
|
||||
true,
|
||||
(_) => Promise.resolve(false),
|
||||
mockedExec,
|
||||
);
|
||||
expect(mockedExec).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 {parseXcodeFromCoreSimPath} from '../iOSDeviceManager';
|
||||
import {getLogger} from 'flipper-common';
|
||||
import {IOSBridge} from '../IOSBridge';
|
||||
import {FlipperServerImpl} from '../../../FlipperServerImpl';
|
||||
|
||||
const standardCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Applications/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
const nonStandardCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
const nonStandardSpecialCharacterAphanumericCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from non standard locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(nonStandardCoresimulatorLog);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual('/Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer');
|
||||
});
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from non standard alphanumeric special character locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(
|
||||
nonStandardSpecialCharacterAphanumericCoresimulatorLog,
|
||||
);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual(
|
||||
'/Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer',
|
||||
);
|
||||
});
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from standard locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(standardCoresimulatorLog);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual('/Applications/Xcode_12.4.0_fb.app/Contents/Developer');
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode detected', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(promises.length).toEqual(2);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode is not detected', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(promises.length).toEqual(1);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode and idb are both unavailable', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(promises.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when both idb and xcode are available', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(promises.length).toEqual(2);
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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 {Mutex} from 'async-mutex';
|
||||
import {exec as unsafeExec, Output} from 'promisify-child-process';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {promises, constants} from 'fs';
|
||||
import memoize from 'lodash.memoize';
|
||||
import {notNull} from '../../utils/typeUtils';
|
||||
import {promisify} from 'util';
|
||||
import child_process from 'child_process';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Use debug to get helpful logs when idb fails
|
||||
const idbLogLevel = 'DEBUG';
|
||||
const operationPrefix = 'iosContainerUtility';
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
type IdbTarget = {
|
||||
name: string;
|
||||
udid: string;
|
||||
state: 'Booted' | 'Shutdown';
|
||||
type: string | DeviceType;
|
||||
target_type?: string | DeviceType;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
};
|
||||
|
||||
export type DeviceType = 'physical' | 'emulator';
|
||||
|
||||
export type DeviceTarget = {
|
||||
udid: string;
|
||||
type: DeviceType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function isAvailable(idbPath: string): Promise<boolean> {
|
||||
if (!idbPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return promises
|
||||
.access(idbPath, constants.X_OK)
|
||||
.then((_) => true)
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
function safeExec(
|
||||
command: string,
|
||||
): Promise<{stdout: string; stderr: string} | Output> {
|
||||
return mutex
|
||||
.acquire()
|
||||
.then((release) => unsafeExec(command).finally(release));
|
||||
}
|
||||
|
||||
export async function queryTargetsWithoutXcodeDependency(
|
||||
idbCompanionPath: string,
|
||||
isPhysicalDeviceEnabled: boolean,
|
||||
isAvailableFunc: (idbPath: string) => Promise<boolean>,
|
||||
safeExecFunc: (
|
||||
command: string,
|
||||
) => Promise<{stdout: string; stderr: string} | Output>,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
if (await isAvailableFunc(idbCompanionPath)) {
|
||||
return safeExecFunc(`${idbCompanionPath} --list 1 --only device`)
|
||||
.then(({stdout}) => parseIdbTargets(stdout!.toString()))
|
||||
.then((devices) => {
|
||||
if (devices.length > 0 && !isPhysicalDeviceEnabled) {
|
||||
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
|
||||
console.warn(
|
||||
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
|
||||
);
|
||||
}
|
||||
return devices;
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.warn(
|
||||
'Failed to query idb_companion --list 1 --only device for physical targets:',
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Unable to locate idb_companion in ${idbCompanionPath}. Try running sudo yum install -y fb-idb`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseIdbTargets(lines: string): Array<DeviceTarget> {
|
||||
return lines
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter(({state}: IdbTarget) => state.toLocaleLowerCase() === 'booted')
|
||||
.map<IdbTarget>(({type, target_type, ...rest}: IdbTarget) => ({
|
||||
type: (type || target_type) === 'simulator' ? 'emulator' : 'physical',
|
||||
...rest,
|
||||
}))
|
||||
.map<DeviceTarget>((target: IdbTarget) => ({
|
||||
udid: target.udid,
|
||||
type: target.type as DeviceType,
|
||||
name: target.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function idbListTargets(
|
||||
idbPath: string,
|
||||
safeExecFunc: (
|
||||
command: string,
|
||||
) => Promise<{stdout: string; stderr: string} | Output> = safeExec,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
return safeExecFunc(`${idbPath} list-targets --json`)
|
||||
.then(({stdout}) =>
|
||||
// See above.
|
||||
parseIdbTargets(stdout!.toString()),
|
||||
)
|
||||
.catch((e: Error) => {
|
||||
console.warn('Failed to query idb for targets:', e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async function targets(
|
||||
idbPath: string,
|
||||
isPhysicalDeviceEnabled: boolean,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return [];
|
||||
}
|
||||
const isXcodeInstalled = await isXcodeDetected();
|
||||
if (!isXcodeInstalled) {
|
||||
if (!isPhysicalDeviceEnabled) {
|
||||
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
|
||||
console.warn(
|
||||
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
|
||||
);
|
||||
}
|
||||
const idbCompanionPath = path.dirname(idbPath) + '/idb_companion';
|
||||
return queryTargetsWithoutXcodeDependency(
|
||||
idbCompanionPath,
|
||||
isPhysicalDeviceEnabled,
|
||||
isAvailable,
|
||||
safeExec,
|
||||
);
|
||||
}
|
||||
|
||||
// Not all users have idb installed because you can still use
|
||||
// Flipper with Simulators without it.
|
||||
// But idb is MUCH more CPU efficient than xcrun, so
|
||||
// when installed, use it. This still holds true
|
||||
// with the move from instruments to xcrun.
|
||||
// TODO: Move idb availability check up.
|
||||
if (await memoize(isAvailable)(idbPath)) {
|
||||
return await idbListTargets(idbPath);
|
||||
} else {
|
||||
return safeExec('xcrun xctrace list devices')
|
||||
.then(({stdout}) =>
|
||||
stdout!
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => /(.+) \([^(]+\) \[(.*)\]( \(Simulator\))?/.exec(line))
|
||||
.filter(notNull)
|
||||
.filter(([_match, _name, _udid, isSim]) => !isSim)
|
||||
.map<DeviceTarget>(([_match, name, udid]) => {
|
||||
return {udid, type: 'physical', name};
|
||||
}),
|
||||
)
|
||||
.catch((e) => {
|
||||
console.warn('Failed to query for devices using xctrace:', e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function push(
|
||||
udid: string,
|
||||
src: string,
|
||||
bundleId: string,
|
||||
dst: string,
|
||||
idbPath: string,
|
||||
): Promise<void> {
|
||||
await memoize(checkIdbIsInstalled)(idbPath);
|
||||
return wrapWithErrorMessage(
|
||||
reportPlatformFailures(
|
||||
safeExec(
|
||||
`${idbPath} --log ${idbLogLevel} file push --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
|
||||
)
|
||||
.then(() => {
|
||||
return;
|
||||
})
|
||||
.catch((e) => handleMissingIdb(e, idbPath)),
|
||||
`${operationPrefix}:push`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function pull(
|
||||
udid: string,
|
||||
src: string,
|
||||
bundleId: string,
|
||||
dst: string,
|
||||
idbPath: string,
|
||||
): Promise<void> {
|
||||
await memoize(checkIdbIsInstalled)(idbPath);
|
||||
return wrapWithErrorMessage(
|
||||
reportPlatformFailures(
|
||||
safeExec(
|
||||
`${idbPath} --log ${idbLogLevel} file pull --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
|
||||
)
|
||||
.then(() => {
|
||||
return;
|
||||
})
|
||||
.catch((e) => handleMissingIdb(e, idbPath))
|
||||
.catch((e) => handleMissingPermissions(e)),
|
||||
`${operationPrefix}:pull`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkIdbIsInstalled(idbPath: string): Promise<void> {
|
||||
const isInstalled = await isAvailable(idbPath);
|
||||
if (!isInstalled) {
|
||||
throw new Error(
|
||||
`idb is required to use iOS devices. Install it with instructions from https://github.com/facebook/idb and set the installation path in Flipper settings.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The fb-internal idb binary is a shim that downloads the proper one on first run. It requires sudo to do so.
|
||||
// If we detect this, Tell the user how to fix it.
|
||||
function handleMissingIdb(e: Error, idbPath: string): void {
|
||||
if (
|
||||
e.message &&
|
||||
e.message.includes('sudo: no tty present and no askpass program specified')
|
||||
) {
|
||||
console.warn(e);
|
||||
throw new Error(
|
||||
`idb doesn't appear to be installed. Run "${idbPath} list-targets" to fix this.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
function handleMissingPermissions(e: Error): void {
|
||||
if (
|
||||
e.message &&
|
||||
e.message.includes('Command failed') &&
|
||||
e.message.includes('file pull') &&
|
||||
e.message.includes('sonar/app.csr')
|
||||
) {
|
||||
console.warn(e);
|
||||
throw new Error(
|
||||
'Cannot connect to iOS application. idb_certificate_pull_failed' +
|
||||
'Idb lacks permissions to exchange certificates. Did you install a source build ([FB] or enable certificate exchange)? ' +
|
||||
e,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
function wrapWithErrorMessage<T>(p: Promise<T>): Promise<T> {
|
||||
return p.catch((e: Error) => {
|
||||
console.warn(e);
|
||||
// Give the user instructions. Don't embed the error because it's unique per invocation so won't be deduped.
|
||||
throw new Error(
|
||||
"A problem with idb has ocurred. Please run `sudo rm -rf /tmp/idb*` and `sudo yum install -y fb-idb` to update it, if that doesn't fix it, post in Flipper Support.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function isXcodeDetected(): Promise<boolean> {
|
||||
return exec('xcode-select -p')
|
||||
.then(({stdout}) => {
|
||||
return fs.pathExists(stdout.trim());
|
||||
})
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
export default {
|
||||
isAvailable,
|
||||
targets,
|
||||
push,
|
||||
pull,
|
||||
isXcodeDetected,
|
||||
};
|
||||
307
desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Normal file
307
desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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 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: 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
|
||||
);
|
||||
}
|
||||
|
||||
export class IOSDeviceManager {
|
||||
private portForwarders: Array<ChildProcess> = [];
|
||||
|
||||
private portforwardingClient = path.join(
|
||||
getFlipperServerConfig().staticPath,
|
||||
'PortForwardingMacApp.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'PortForwardingMacApp',
|
||||
);
|
||||
iosBridge: IOSBridge | undefined;
|
||||
private xcodeVersionMismatchFound = false;
|
||||
public xcodeCommandLineToolsDetected = false;
|
||||
|
||||
constructor(private flipperServer: FlipperServerImpl) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.portForwarders.forEach((process) => process.kill());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private forwardPort(port: number, multiplexChannelPort: number) {
|
||||
const child = childProcess.execFile(
|
||||
this.portforwardingClient,
|
||||
[`-portForward=${port}`, `-multiplexChannelPort=${multiplexChannelPort}`],
|
||||
(err, stdout, stderr) => {
|
||||
// This happens on app reloads and doesn't need to be treated as an error.
|
||||
console.warn(
|
||||
'Port forwarding app failed to start',
|
||||
err,
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
},
|
||||
);
|
||||
console.log('Port forwarding app started', childProcess);
|
||||
child.addListener('error', (err) =>
|
||||
console.warn('Port forwarding app error', err),
|
||||
);
|
||||
child.addListener('exit', (code) =>
|
||||
console.log(`Port forwarding app exited with code ${code}`),
|
||||
);
|
||||
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>> {
|
||||
const config = getFlipperServerConfig();
|
||||
return [
|
||||
isIdbAvailable
|
||||
? getActiveDevices(config.idbPath, config.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> {
|
||||
const config = getFlipperServerConfig();
|
||||
const isXcodeInstalled = await iosUtil.isXcodeDetected();
|
||||
const isIdbAvailable = await iosUtil.isAvailable(config.idbPath);
|
||||
return Promise.all(
|
||||
this.getAllPromisesForQueryingDevices(isXcodeInstalled, isIdbAvailable),
|
||||
);
|
||||
}
|
||||
|
||||
private processDevices(activeDevices: IOSDeviceParams[]) {
|
||||
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),
|
||||
);
|
||||
|
||||
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() {
|
||||
// TODO: pull this condition up
|
||||
if (!getFlipperServerConfig().enableIOS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isDetected = await iosUtil.isXcodeDetected();
|
||||
this.xcodeCommandLineToolsDetected = isDetected;
|
||||
if (getFlipperServerConfig().enablePhysicalIOS) {
|
||||
this.startDevicePortForwarders();
|
||||
}
|
||||
try {
|
||||
// Awaiting the promise here to trigger immediate error handling.
|
||||
this.iosBridge = await makeIOSBridge(
|
||||
getFlipperServerConfig().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 execFile('xcrun', [
|
||||
'simctl',
|
||||
...getDeviceSetPath(),
|
||||
'list',
|
||||
'devices',
|
||||
'--json',
|
||||
])
|
||||
.then(({stdout}) => JSON.parse(stdout!.toString()).devices)
|
||||
.then((simulatorDevices: Array<iOSSimulatorDevice>) => {
|
||||
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')) {
|
||||
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 getDeviceSetPath() {
|
||||
return process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function launchSimulator(udid: string): Promise<any> {
|
||||
await execFile('xcrun', ['simctl', ...getDeviceSetPath(), 'boot', udid]);
|
||||
await execFile('open', ['-a', 'simulator']);
|
||||
}
|
||||
|
||||
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/);
|
||||
}
|
||||
119
desktop/flipper-server-core/src/devices/metro/MetroDevice.tsx
Normal file
119
desktop/flipper-server-core/src/devices/metro/MetroDevice.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 {DeviceLogLevel, MetroReportableEvent} from 'flipper-common';
|
||||
import util from 'util';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
const metroLogLevelMapping: {[key: string]: DeviceLogLevel} = {
|
||||
trace: 'verbose',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
log: 'info',
|
||||
group: 'info',
|
||||
groupCollapsed: 'info',
|
||||
groupEnd: 'info',
|
||||
debug: 'debug',
|
||||
};
|
||||
|
||||
function getLoglevelFromMessageType(
|
||||
type: MetroReportableEvent['type'],
|
||||
): DeviceLogLevel | null {
|
||||
switch (type) {
|
||||
case 'bundle_build_done':
|
||||
case 'bundle_build_started':
|
||||
case 'initialize_done':
|
||||
return 'debug';
|
||||
case 'bundle_build_failed':
|
||||
case 'bundling_error':
|
||||
case 'global_cache_error':
|
||||
case 'hmr_client_error':
|
||||
return 'error';
|
||||
case 'bundle_transform_progressed':
|
||||
return null; // Don't show at all
|
||||
case 'client_log':
|
||||
return null; // Handled separately
|
||||
case 'dep_graph_loaded':
|
||||
case 'dep_graph_loading':
|
||||
case 'global_cache_disabled':
|
||||
default:
|
||||
return 'verbose';
|
||||
}
|
||||
}
|
||||
|
||||
export default class MetroDevice extends ServerDevice {
|
||||
ws?: WebSocket;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
ws: WebSocket | undefined,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType: 'emulator',
|
||||
title: 'React Native',
|
||||
os: 'Metro',
|
||||
icon: 'mobile',
|
||||
});
|
||||
if (ws) {
|
||||
this.ws = ws;
|
||||
ws.onmessage = this._handleWSMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleWSMessage = ({data}: any) => {
|
||||
const message: MetroReportableEvent = JSON.parse(data);
|
||||
if (message.type === 'client_log') {
|
||||
const type: DeviceLogLevel =
|
||||
metroLogLevelMapping[message.level] || 'unknown';
|
||||
this.addLogEntry({
|
||||
date: new Date(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
type,
|
||||
tag: message.type,
|
||||
message: util.format(
|
||||
...message.data.map((v) =>
|
||||
v && typeof v === 'object' ? JSON.stringify(v, null, 2) : v,
|
||||
),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const level = getLoglevelFromMessageType(message.type);
|
||||
if (level !== null) {
|
||||
this.addLogEntry({
|
||||
date: new Date(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
type: level,
|
||||
tag: message.type,
|
||||
message: JSON.stringify(message, null, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendCommand(command: string, params?: any) {
|
||||
if (this.ws) {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
type: 'command',
|
||||
command,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.warn('Cannot send command, no connection', command);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 MetroDevice from './MetroDevice';
|
||||
import http from 'http';
|
||||
import {parseEnvironmentVariableAsNumber} from '../../utils/environmentVariables';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
const METRO_HOST = 'localhost';
|
||||
const METRO_PORT = parseEnvironmentVariableAsNumber('METRO_SERVER_PORT', 8081);
|
||||
const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`;
|
||||
const METRO_LOGS_ENDPOINT = `ws://${METRO_HOST}:${METRO_PORT}/events`;
|
||||
const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
|
||||
const QUERY_INTERVAL = 5000;
|
||||
|
||||
async function isMetroRunning(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// We use Node's http library, rather than fetch api, as the latter cannot supress network errors being shown in the devtools console
|
||||
// which generates a lot of noise
|
||||
http
|
||||
.get(METRO_URL, (resp) => {
|
||||
let data = '';
|
||||
resp
|
||||
.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
})
|
||||
.on('end', () => {
|
||||
const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));
|
||||
resolve(isMetro);
|
||||
});
|
||||
})
|
||||
.on('error', (err: any) => {
|
||||
if (err.code !== 'ECONNREFUSED' && err.code !== 'ECONNRESET') {
|
||||
console.error('Could not connect to METRO ' + err);
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerMetroDevice(
|
||||
ws: WebSocket | undefined,
|
||||
flipperServer: FlipperServerImpl,
|
||||
) {
|
||||
const metroDevice = new MetroDevice(flipperServer, METRO_URL, ws);
|
||||
flipperServer.registerDevice(metroDevice);
|
||||
}
|
||||
|
||||
export default (flipperServer: FlipperServerImpl) => {
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
let ws: WebSocket | undefined;
|
||||
let unregistered = false;
|
||||
|
||||
async function tryConnectToMetro() {
|
||||
if (ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isMetroRunning()) {
|
||||
try {
|
||||
const _ws = new WebSocket(METRO_LOGS_ENDPOINT);
|
||||
|
||||
_ws.onopen = () => {
|
||||
clearTimeout(guard);
|
||||
ws = _ws;
|
||||
registerMetroDevice(ws, flipperServer);
|
||||
};
|
||||
|
||||
_ws.onclose = _ws.onerror = function (event?: any) {
|
||||
if (event?.type === 'error') {
|
||||
console.error(
|
||||
`Failed to connect to Metro on ${METRO_LOGS_ENDPOINT}`,
|
||||
event,
|
||||
);
|
||||
}
|
||||
if (!unregistered) {
|
||||
unregistered = true;
|
||||
clearTimeout(guard);
|
||||
ws = undefined;
|
||||
flipperServer.unregisterDevice(METRO_URL);
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
const guard = setTimeout(() => {
|
||||
// Metro is running, but didn't respond to /events endpoint
|
||||
flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Failed to connect to Metro',
|
||||
description: `Flipper did find a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper. Cause: Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`,
|
||||
});
|
||||
registerMetroDevice(undefined, flipperServer);
|
||||
// Note: no scheduleNext, we won't retry until restart
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.error('Error while setting up Metro websocket connect', e);
|
||||
}
|
||||
} else {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
timeoutHandle = setTimeout(tryConnectToMetro, QUERY_INTERVAL);
|
||||
}
|
||||
|
||||
tryConnectToMetro();
|
||||
|
||||
// cleanup method
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
if (timeoutHandle) {
|
||||
clearInterval(timeoutHandle);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user