Yarn workspaces
Summary: 1) moved "sonar/desktop/src" to "sonar/desktop/app/src", so "app" is now a separate package containing the core Flipper app code 2) Configured yarn workspaces with the root in "sonar/desktop": app, static, pkg, doctor, headless-tests. Plugins are not included for now, I plan to do this later. Reviewed By: jknoxville Differential Revision: D20535782 fbshipit-source-id: 600b2301960f37c7d72166e0d04eba462bec9fc1
This commit is contained in:
committed by
Facebook GitHub Bot
parent
676d7bbd24
commit
863f89351e
195
desktop/app/src/devices/AndroidDevice.tsx
Normal file
195
desktop/app/src/devices/AndroidDevice.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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 BaseDevice, {DeviceType, LogLevel} from './BaseDevice';
|
||||
import adb, {Client as ADBClient} from 'adbkit';
|
||||
import {Priority} from 'adbkit-logcat';
|
||||
import ArchivedDevice from './ArchivedDevice';
|
||||
import {createWriteStream} from 'fs';
|
||||
|
||||
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
|
||||
|
||||
export default class AndroidDevice extends BaseDevice {
|
||||
constructor(
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
adb: ADBClient,
|
||||
) {
|
||||
super(serial, deviceType, title, 'Android');
|
||||
this.adb = adb;
|
||||
this.icon = 'icons/android.svg';
|
||||
this.adb.openLogcat(this.serial).then(reader => {
|
||||
reader.on('entry', entry => {
|
||||
let type: LogLevel = '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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
adb: ADBClient;
|
||||
pidAppMapping: {[key: number]: string} = {};
|
||||
private recordingProcess?: Promise<string>;
|
||||
|
||||
supportedColumns(): Array<string> {
|
||||
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
|
||||
}
|
||||
|
||||
reverse(ports: [number, number]): Promise<void> {
|
||||
return Promise.all(
|
||||
ports.map(port =>
|
||||
this.adb.reverse(this.serial, `tcp:${port}`, `tcp:${port}`),
|
||||
),
|
||||
).then(() => {
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
clearLogs(): Promise<void> {
|
||||
this.logEntries = [];
|
||||
return this.executeShell(['logcat', '-c']);
|
||||
}
|
||||
|
||||
archive(): ArchivedDevice {
|
||||
return new ArchivedDevice({
|
||||
serial: this.serial,
|
||||
deviceType: this.deviceType,
|
||||
title: this.title,
|
||||
os: this.os,
|
||||
logEntries: [...this.logEntries],
|
||||
screenshotHandle: null,
|
||||
});
|
||||
}
|
||||
|
||||
navigateToLocation(location: string) {
|
||||
const shellCommand = `am start ${encodeURI(location)}`;
|
||||
this.adb.shell(this.serial, shellCommand);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async screenCaptureAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.executeShell(
|
||||
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
|
||||
);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeShell(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 isValidFile(filePath: string): Promise<boolean> {
|
||||
const fileSize = await this.adb
|
||||
.shell(this.serial, `du "${filePath}"`)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) =>
|
||||
output
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\t'),
|
||||
)
|
||||
.then(x => Number(x[0]));
|
||||
|
||||
// 4 is what an empty file (touch file) already takes up, so it's
|
||||
// definitely not a valid video file.
|
||||
return fileSize > 4;
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
await this.executeShell(
|
||||
`mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`,
|
||||
);
|
||||
const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`;
|
||||
this.recordingProcess = this.adb
|
||||
.shell(this.serial, `screenrecord --bugreport "${recordingLocation}"`)
|
||||
.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((resolve, reject) => {
|
||||
this.adb.pull(this.serial, recordingLocation).then(stream => {
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
stream.pipe(createWriteStream(destination));
|
||||
});
|
||||
}),
|
||||
)
|
||||
.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, `pgrep 'screenrecord' -L 2`);
|
||||
const destination = await recordingProcess;
|
||||
this.recordingProcess = undefined;
|
||||
return destination;
|
||||
}
|
||||
}
|
||||
73
desktop/app/src/devices/ArchivedDevice.tsx
Normal file
73
desktop/app/src/devices/ArchivedDevice.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 BaseDevice from './BaseDevice';
|
||||
import {DeviceType, OS, DeviceShell, DeviceLogEntry} from './BaseDevice';
|
||||
import {SupportFormRequestDetailsState} from '../reducers/supportForm';
|
||||
|
||||
function normalizeArchivedDeviceType(deviceType: DeviceType): DeviceType {
|
||||
let archivedDeviceType = deviceType;
|
||||
if (archivedDeviceType === 'emulator') {
|
||||
archivedDeviceType = 'archivedEmulator';
|
||||
} else if (archivedDeviceType === 'physical') {
|
||||
archivedDeviceType = 'archivedPhysical';
|
||||
}
|
||||
return archivedDeviceType;
|
||||
}
|
||||
|
||||
export default class ArchivedDevice extends BaseDevice {
|
||||
constructor(options: {
|
||||
serial: string;
|
||||
deviceType: DeviceType;
|
||||
title: string;
|
||||
os: OS;
|
||||
logEntries: Array<DeviceLogEntry>;
|
||||
screenshotHandle: string | null;
|
||||
source?: string;
|
||||
supportRequestDetails?: SupportFormRequestDetailsState;
|
||||
}) {
|
||||
super(
|
||||
options.serial,
|
||||
normalizeArchivedDeviceType(options.deviceType),
|
||||
options.title,
|
||||
options.os,
|
||||
);
|
||||
this.logs = options.logEntries;
|
||||
this.source = options.source || '';
|
||||
this.supportRequestDetails = options.supportRequestDetails;
|
||||
this.archivedScreenshotHandle = options.screenshotHandle;
|
||||
}
|
||||
|
||||
logs: Array<DeviceLogEntry>;
|
||||
archivedScreenshotHandle: string | null;
|
||||
isArchived = true;
|
||||
|
||||
displayTitle(): string {
|
||||
return `${this.title} ${this.source ? '(Imported)' : '(Offline)'}`;
|
||||
}
|
||||
|
||||
supportRequestDetails?: SupportFormRequestDetailsState;
|
||||
|
||||
getLogs() {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
clearLogs(): Promise<void> {
|
||||
this.logs = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
spawnShell(): DeviceShell | undefined | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getArchivedScreenshotHandle(): string | null {
|
||||
return this.archivedScreenshotHandle;
|
||||
}
|
||||
}
|
||||
183
desktop/app/src/devices/BaseDevice.tsx
Normal file
183
desktop/app/src/devices/BaseDevice.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 stream from 'stream';
|
||||
import {FlipperDevicePlugin} from 'flipper';
|
||||
import {sortPluginsByName} from '../utils/pluginUtils';
|
||||
|
||||
export type LogLevel =
|
||||
| 'unknown'
|
||||
| 'verbose'
|
||||
| 'debug'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'error'
|
||||
| 'fatal';
|
||||
|
||||
export type DeviceLogEntry = {
|
||||
readonly date: Date;
|
||||
readonly pid: number;
|
||||
readonly tid: number;
|
||||
readonly app?: string;
|
||||
readonly type: LogLevel;
|
||||
readonly tag: string;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
export type DeviceShell = {
|
||||
stdout: stream.Readable;
|
||||
stderr: stream.Readable;
|
||||
stdin: stream.Writable;
|
||||
};
|
||||
|
||||
export type DeviceLogListener = (entry: DeviceLogEntry) => void;
|
||||
|
||||
export type DeviceType =
|
||||
| 'emulator'
|
||||
| 'physical'
|
||||
| 'archivedEmulator'
|
||||
| 'archivedPhysical';
|
||||
|
||||
export type DeviceExport = {
|
||||
os: OS;
|
||||
title: string;
|
||||
deviceType: DeviceType;
|
||||
serial: string;
|
||||
logs: Array<DeviceLogEntry>;
|
||||
};
|
||||
|
||||
export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS' | 'JSWebApp' | 'Metro';
|
||||
|
||||
export default class BaseDevice {
|
||||
constructor(serial: string, deviceType: DeviceType, title: string, os: OS) {
|
||||
this.serial = serial;
|
||||
this.title = title;
|
||||
this.deviceType = deviceType;
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
// operating system of this device
|
||||
os: OS;
|
||||
|
||||
// human readable name for this device
|
||||
title: string;
|
||||
|
||||
// type of this device
|
||||
deviceType: DeviceType;
|
||||
|
||||
// serial number for this device
|
||||
serial: string;
|
||||
|
||||
// possible src of icon to display next to the device title
|
||||
icon: string | null | undefined;
|
||||
|
||||
logListeners: Map<Symbol, DeviceLogListener> = new Map();
|
||||
logEntries: Array<DeviceLogEntry> = [];
|
||||
isArchived: boolean = false;
|
||||
// if imported, stores the original source location
|
||||
source = '';
|
||||
|
||||
// sorted list of supported device plugins
|
||||
devicePlugins!: string[];
|
||||
|
||||
supportsOS(os: OS) {
|
||||
return os.toLowerCase() === this.os.toLowerCase();
|
||||
}
|
||||
|
||||
displayTitle(): string {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
toJSON(): DeviceExport {
|
||||
return {
|
||||
os: this.os,
|
||||
title: this.title,
|
||||
deviceType: this.deviceType,
|
||||
serial: this.serial,
|
||||
logs: this.getLogs(),
|
||||
};
|
||||
}
|
||||
|
||||
teardown() {}
|
||||
|
||||
supportedColumns(): Array<string> {
|
||||
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
|
||||
}
|
||||
|
||||
addLogListener(callback: DeviceLogListener): Symbol {
|
||||
const id = Symbol();
|
||||
this.logListeners.set(id, callback);
|
||||
return id;
|
||||
}
|
||||
|
||||
_notifyLogListeners(entry: DeviceLogEntry) {
|
||||
if (this.logListeners.size > 0) {
|
||||
this.logListeners.forEach(listener => {
|
||||
// prevent breaking other listeners, if one listener doesn't work.
|
||||
try {
|
||||
listener(entry);
|
||||
} catch (e) {
|
||||
console.error(`Log listener exception:`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addLogEntry(entry: DeviceLogEntry) {
|
||||
this.logEntries.push(entry);
|
||||
this._notifyLogListeners(entry);
|
||||
}
|
||||
|
||||
getLogs() {
|
||||
return this.logEntries;
|
||||
}
|
||||
|
||||
clearLogs(): Promise<void> {
|
||||
// Only for device types that allow clearing.
|
||||
this.logEntries = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
removeLogListener(id: Symbol) {
|
||||
this.logListeners.delete(id);
|
||||
}
|
||||
|
||||
navigateToLocation(_location: string) {
|
||||
throw new Error('unimplemented');
|
||||
}
|
||||
|
||||
archive(): any | null | undefined {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
loadDevicePlugins(devicePlugins?: Map<string, typeof FlipperDevicePlugin>) {
|
||||
this.devicePlugins = Array.from(devicePlugins ? devicePlugins.values() : [])
|
||||
.filter(plugin => plugin.supportsDevice(this))
|
||||
.sort(sortPluginsByName)
|
||||
.map(plugin => plugin.id);
|
||||
}
|
||||
}
|
||||
249
desktop/app/src/devices/IOSDevice.tsx
Normal file
249
desktop/app/src/devices/IOSDevice.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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, LogLevel, DeviceLogEntry} from './BaseDevice';
|
||||
import child_process, {ChildProcess} from 'child_process';
|
||||
import BaseDevice from './BaseDevice';
|
||||
import JSONStream from 'JSONStream';
|
||||
import {Transform} from 'stream';
|
||||
import electron from 'electron';
|
||||
import fs from 'fs';
|
||||
import {v1 as uuid} from 'uuid';
|
||||
import path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import {exec} from 'child_process';
|
||||
import {default as promiseTimeout} from '../utils/promiseTimeout';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export default class IOSDevice extends BaseDevice {
|
||||
log: any;
|
||||
buffer: string;
|
||||
private recordingProcess?: ChildProcess;
|
||||
private recordingLocation?: string;
|
||||
|
||||
constructor(serial: string, deviceType: DeviceType, title: string) {
|
||||
super(serial, deviceType, title, 'iOS');
|
||||
this.icon = 'icons/ios.svg';
|
||||
this.buffer = '';
|
||||
this.log = this.startLogListener();
|
||||
}
|
||||
|
||||
screenshot(): Promise<Buffer> {
|
||||
const tmpImageName = uuid() + '.png';
|
||||
const tmpDirectory = (electron.app || electron.remote.app).getPath('temp');
|
||||
const tmpFilePath = path.join(tmpDirectory, tmpImageName);
|
||||
const command = `xcrun simctl io booted screenshot ${tmpFilePath}`;
|
||||
return promisify(exec)(command)
|
||||
.then(() => promisify(fs.readFile)(tmpFilePath))
|
||||
.then(buffer => {
|
||||
return promisify(fs.unlink)(tmpFilePath).then(() => buffer);
|
||||
});
|
||||
}
|
||||
|
||||
navigateToLocation(location: string) {
|
||||
const command = `xcrun simctl openurl booted "${location}"`;
|
||||
exec(command);
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if (this.log) {
|
||||
this.log.kill();
|
||||
}
|
||||
}
|
||||
|
||||
supportedColumns(): Array<string> {
|
||||
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
|
||||
}
|
||||
|
||||
startLogListener(retries: number = 3) {
|
||||
if (this.deviceType === 'physical') {
|
||||
return;
|
||||
}
|
||||
if (retries === 0) {
|
||||
console.error('Attaching iOS log listener continuously failed.');
|
||||
return;
|
||||
}
|
||||
if (!this.log) {
|
||||
const deviceSetPath = process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
|
||||
this.log = child_process.spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
...deviceSetPath,
|
||||
'spawn',
|
||||
'booted',
|
||||
'log',
|
||||
'stream',
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--info',
|
||||
'--debug',
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
this.log.on('error', (err: Error) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
this.log.stderr.on('data', (data: Buffer) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
this.log.on('exit', () => {
|
||||
this.log = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this.log.stdout
|
||||
.pipe(new StripLogPrefix())
|
||||
.pipe(JSONStream.parse('*'))
|
||||
.on('data', (data: RawLogEntry) => {
|
||||
const entry = IOSDevice.parseLogEntry(data);
|
||||
this.addLogEntry(entry);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Could not parse iOS log stream.', e);
|
||||
// restart log stream
|
||||
this.log.kill();
|
||||
this.log = null;
|
||||
this.startLogListener(retries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
static parseLogEntry(entry: RawLogEntry): DeviceLogEntry {
|
||||
const LOG_MAPPING: Map<IOSLogLevel, LogLevel> = new Map([
|
||||
['Default' as IOSLogLevel, 'debug' as LogLevel],
|
||||
['Info' as IOSLogLevel, 'info' as LogLevel],
|
||||
['Debug' as IOSLogLevel, 'debug' as LogLevel],
|
||||
['Error' as IOSLogLevel, 'error' as LogLevel],
|
||||
['Fault' as IOSLogLevel, 'fatal' as LogLevel],
|
||||
]);
|
||||
let type: LogLevel = LOG_MAPPING.get(entry.messageType) || 'unknown';
|
||||
|
||||
// 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.deviceType === 'emulator';
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
this.recordingProcess = exec(
|
||||
`xcrun simctl io booted recordVideo --codec=h264 --force ${destination}`,
|
||||
);
|
||||
this.recordingLocation = destination;
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string | null> {
|
||||
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 | null = await promiseTimeout<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.error(_e);
|
||||
return null;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
20
desktop/app/src/devices/JSDevice.tsx
Normal file
20
desktop/app/src/devices/JSDevice.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 BaseDevice from './BaseDevice';
|
||||
|
||||
export default class JSDevice extends BaseDevice {
|
||||
webContentsId: number;
|
||||
|
||||
constructor(serial: string, title: string, webContentsId: number) {
|
||||
super(serial, 'emulator', title, 'JSWebApp');
|
||||
this.devicePlugins = [];
|
||||
this.webContentsId = webContentsId;
|
||||
}
|
||||
}
|
||||
18
desktop/app/src/devices/KaiOSDevice.tsx
Normal file
18
desktop/app/src/devices/KaiOSDevice.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export default class KaiOSDevice extends AndroidDevice {
|
||||
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;
|
||||
}
|
||||
}
|
||||
22
desktop/app/src/devices/MacDevice.tsx
Normal file
22
desktop/app/src/devices/MacDevice.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 BaseDevice from './BaseDevice';
|
||||
|
||||
export default class MacDevice extends BaseDevice {
|
||||
constructor() {
|
||||
super('', 'physical', 'Mac', 'MacOS');
|
||||
}
|
||||
|
||||
teardown() {}
|
||||
|
||||
supportedColumns(): Array<string> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
211
desktop/app/src/devices/MetroDevice.tsx
Normal file
211
desktop/app/src/devices/MetroDevice.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 BaseDevice, {LogLevel} from './BaseDevice';
|
||||
import ArchivedDevice from './ArchivedDevice';
|
||||
import {EventEmitter} from 'events';
|
||||
|
||||
// From xplat/js/metro/packages/metro/src/lib/reporting.js
|
||||
export type BundleDetails = {
|
||||
entryFile: string;
|
||||
platform?: string;
|
||||
dev: boolean;
|
||||
minify: boolean;
|
||||
bundleType: string;
|
||||
};
|
||||
|
||||
// From xplat/js/metro/packages/metro/src/lib/reporting.js
|
||||
export type GlobalCacheDisabledReason = 'too_many_errors' | 'too_many_misses';
|
||||
|
||||
/**
|
||||
* A tagged union of all the actions that may happen and we may want to
|
||||
* report to the tool user.
|
||||
*
|
||||
* Based on xplat/js/metro/packages/metro/src/lib/TerminalReporter.js
|
||||
*/
|
||||
export type MetroReportableEvent =
|
||||
| {
|
||||
port: number;
|
||||
projectRoots: ReadonlyArray<string>;
|
||||
type: 'initialize_started';
|
||||
}
|
||||
| {type: 'initialize_done'}
|
||||
| {
|
||||
type: 'initialize_failed';
|
||||
port: number;
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
buildID: string;
|
||||
type: 'bundle_build_done';
|
||||
}
|
||||
| {
|
||||
buildID: string;
|
||||
type: 'bundle_build_failed';
|
||||
}
|
||||
| {
|
||||
buildID: string;
|
||||
bundleDetails: BundleDetails;
|
||||
type: 'bundle_build_started';
|
||||
}
|
||||
| {
|
||||
error: Error;
|
||||
type: 'bundling_error';
|
||||
}
|
||||
| {type: 'dep_graph_loading'}
|
||||
| {type: 'dep_graph_loaded'}
|
||||
| {
|
||||
buildID: string;
|
||||
type: 'bundle_transform_progressed';
|
||||
transformedFileCount: number;
|
||||
totalFileCount: number;
|
||||
}
|
||||
| {
|
||||
type: 'global_cache_error';
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
type: 'global_cache_disabled';
|
||||
reason: GlobalCacheDisabledReason;
|
||||
}
|
||||
| {type: 'transform_cache_reset'}
|
||||
| {
|
||||
type: 'worker_stdout_chunk';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'worker_stderr_chunk';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'hmr_client_error';
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
type: 'client_log';
|
||||
level:
|
||||
| 'trace'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'log'
|
||||
| 'group'
|
||||
| 'groupCollapsed'
|
||||
| 'groupEnd'
|
||||
| 'debug';
|
||||
data: Array<any>;
|
||||
};
|
||||
|
||||
const metroLogLevelMapping: {[key: string]: LogLevel} = {
|
||||
trace: 'verbose',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
log: 'info',
|
||||
group: 'info',
|
||||
groupCollapsed: 'info',
|
||||
groupEnd: 'info',
|
||||
debug: 'debug',
|
||||
};
|
||||
|
||||
function getLoglevelFromMessageType(
|
||||
type: MetroReportableEvent['type'],
|
||||
): LogLevel | 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 BaseDevice {
|
||||
ws?: WebSocket;
|
||||
metroEventEmitter = new EventEmitter();
|
||||
|
||||
constructor(serial: string, ws: WebSocket | undefined) {
|
||||
super(serial, 'emulator', 'React Native', 'Metro');
|
||||
this.devicePlugins = [];
|
||||
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: LogLevel = metroLogLevelMapping[message.level] || 'unknown';
|
||||
this.addLogEntry({
|
||||
date: new Date(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
type,
|
||||
tag: message.type,
|
||||
message: message.data
|
||||
.map(v =>
|
||||
v && typeof v === 'object' ? JSON.stringify(v, null, 2) : v,
|
||||
)
|
||||
.join(' '),
|
||||
});
|
||||
} 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
this.metroEventEmitter.emit('event', message);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
archive() {
|
||||
return new ArchivedDevice({
|
||||
serial: this.serial,
|
||||
deviceType: this.deviceType,
|
||||
title: this.title,
|
||||
os: this.os,
|
||||
logEntries: [...this.logEntries],
|
||||
screenshotHandle: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
desktop/app/src/devices/WindowsDevice.tsx
Normal file
22
desktop/app/src/devices/WindowsDevice.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 BaseDevice from './BaseDevice';
|
||||
|
||||
export default class WindowsDevice extends BaseDevice {
|
||||
constructor() {
|
||||
super('', 'physical', 'Windows', 'Windows');
|
||||
}
|
||||
|
||||
teardown() {}
|
||||
|
||||
supportedColumns(): Array<string> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user