Move devices to server folder

Summary:
This is the first of many diffs that extracts the connection, device, client detection out of the flipper core, to create a reusable flipper-server library that can be used in e.g. flipper-dump.

To keep diffs a little smaller, the current connection logic is first moved to the `server/` directory, and decoupled manually from the rest of the core, before moving it over to a separate package.

This first diffs moves the `comms/`, `devices/` and certificate utilities to the `server` directory.

Further untangling will follow in next diffs

Reviewed By: timur-valiev

Differential Revision: D30246551

fbshipit-source-id: c84259bfb1239119b3267a51b015e30c3c080866
This commit is contained in:
Michel Weststrate
2021-08-12 05:42:32 -07:00
committed by Facebook GitHub Bot
parent 5e350add4f
commit 5e8c968222
86 changed files with 273 additions and 172 deletions

View File

@@ -1,291 +0,0 @@
/**
* 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 adb, {Client as ADBClient} from 'adbkit';
import {Priority, Reader} from 'adbkit-logcat';
import {createWriteStream} from 'fs';
import type {LogLevel, DeviceType} from 'flipper-plugin';
import which from 'which';
import {spawn} from 'child_process';
import {dirname, join} from 'path';
import {DeviceSpec} from 'flipper-plugin-lib';
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
export default class AndroidDevice extends BaseDevice {
adb: ADBClient;
abiList: Array<string> = [];
sdkVersion: string | undefined = undefined;
pidAppMapping: {[key: number]: string} = {};
private recordingProcess?: Promise<string>;
reader?: Reader;
constructor(
serial: string,
deviceType: DeviceType,
title: string,
adb: ADBClient,
abiList: Array<string>,
sdkVersion: string,
specs: DeviceSpec[] = [],
) {
super(serial, deviceType, title, 'Android', specs);
this.adb = adb;
this.icon = 'mobile';
this.abiList = abiList;
this.sdkVersion = sdkVersion;
}
startLogging() {
this.adb
.openLogcat(this.serial, {clear: true})
.then((reader) => {
this.reader = 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,
});
})
.on('end', () => {
if (this.reader) {
// logs didn't stop gracefully
setTimeout(() => {
if (this.connected.get()) {
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.executeShell(['logcat', '-c']);
}
navigateToLocation(location: string) {
const shellCommand = `am start ${encodeURI(location)}`;
this.adb.shell(this.serial, shellCommand);
}
async screenshot(): Promise<Buffer> {
if (this.isArchived) {
return Buffer.from([]);
}
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> {
if (this.isArchived) {
return false;
}
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 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.executeShell(
`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((resolve, reject) => {
this.adb.pull(this.serial, recordingLocation).then((stream) => {
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;
}
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));
}

View File

@@ -1,50 +0,0 @@
/**
* 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 type {DeviceType} from 'flipper-plugin';
import {OS, DeviceShell} from './BaseDevice';
import {SupportFormRequestDetailsState} from '../reducers/supportForm';
export default class ArchivedDevice extends BaseDevice {
isArchived = true;
constructor(options: {
serial: string;
deviceType: DeviceType;
title: string;
os: OS;
screenshotHandle?: string | null;
source?: string;
supportRequestDetails?: SupportFormRequestDetailsState;
}) {
super(options.serial, options.deviceType, options.title, options.os);
this.icon = 'box';
this.connected.set(false);
this.source = options.source || '';
this.supportRequestDetails = options.supportRequestDetails;
this.archivedScreenshotHandle = options.screenshotHandle ?? null;
}
archivedScreenshotHandle: string | null;
displayTitle(): string {
return `${this.title} ${this.source ? '(Imported)' : '(Offline)'}`;
}
supportRequestDetails?: SupportFormRequestDetailsState;
spawnShell(): DeviceShell | undefined | null {
return null;
}
getArchivedScreenshotHandle(): string | null {
return this.archivedScreenshotHandle;
}
}

View File

@@ -1,284 +0,0 @@
/**
* 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 {
DeviceLogEntry,
_SandyDevicePluginInstance,
_SandyPluginDefinition,
DeviceType,
DeviceLogListener,
Idler,
createState,
getFlipperLib,
} from 'flipper-plugin';
import {PluginDefinition, DevicePluginMap} from '../plugin';
import {DeviceSpec, OS as PluginOS, PluginDetails} from 'flipper-plugin-lib';
export type DeviceShell = {
stdout: stream.Readable;
stderr: stream.Readable;
stdin: stream.Writable;
};
export type OS = PluginOS | 'Windows' | 'MacOS' | 'JSWebApp';
export type DeviceExport = {
os: OS;
title: string;
deviceType: DeviceType;
serial: string;
pluginStates: Record<string, any>;
};
export default class BaseDevice {
isArchived = false;
hasDevicePlugins = false; // true if there are device plugins for this device (not necessarily enabled)
constructor(
serial: string,
deviceType: DeviceType,
title: string,
os: OS,
specs: DeviceSpec[] = [],
) {
this.serial = serial;
this.title = title;
this.deviceType = deviceType;
this.os = os;
this.specs = specs;
}
// 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;
// additional device specs used for plugin compatibility checks
specs: DeviceSpec[];
// possible src of icon to display next to the device title
icon: string | null | undefined;
logListeners: Map<Symbol, DeviceLogListener> = new Map();
readonly connected = createState(true);
// if imported, stores the original source location
source = '';
sandyPluginStates: Map<string, _SandyDevicePluginInstance> = new Map<
string,
_SandyDevicePluginInstance
>();
supportsOS(os: OS) {
return os.toLowerCase() === this.os.toLowerCase();
}
displayTitle(): string {
return this.connected.get() ? this.title : `${this.title} (Offline)`;
}
async exportState(
idler: Idler,
onStatusMessage: (msg: string) => void,
selectedPlugins: string[],
): Promise<Record<string, any>> {
const pluginStates: Record<string, any> = {};
for (const instance of this.sandyPluginStates.values()) {
if (
selectedPlugins.includes(instance.definition.id) &&
instance.isPersistable()
) {
pluginStates[instance.definition.id] = await instance.exportState(
idler,
onStatusMessage,
);
}
}
return pluginStates;
}
toJSON() {
return {
os: this.os,
title: this.title,
deviceType: this.deviceType,
serial: this.serial,
};
}
startLogging() {
// to be subclassed
}
stopLogging() {
// to be subclassed
}
addLogListener(callback: DeviceLogListener): Symbol {
if (this.logListeners.size === 0) {
this.startLogging();
}
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._notifyLogListeners(entry);
}
removeLogListener(id: Symbol) {
this.logListeners.delete(id);
if (this.logListeners.size === 0) {
this.stopLogging();
}
}
navigateToLocation(_location: string) {
throw new Error('unimplemented');
}
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;
}
supportsPlugin(plugin: PluginDefinition | PluginDetails) {
let pluginDetails: PluginDetails;
if (plugin instanceof _SandyPluginDefinition) {
pluginDetails = plugin.details;
if (!pluginDetails.pluginType && !pluginDetails.supportedDevices) {
// TODO T84453692: this branch is to support plugins defined with the legacy approach. Need to remove this branch after some transition period when
// all the plugins will be migrated to the new approach with static compatibility metadata in package.json.
if (plugin instanceof _SandyPluginDefinition) {
return (
plugin.isDevicePlugin &&
(plugin.asDevicePluginModule().supportsDevice?.(this as any) ??
false)
);
} else {
return (plugin as any).supportsDevice(this);
}
}
} else {
pluginDetails = plugin;
}
return (
pluginDetails.pluginType === 'device' &&
(!pluginDetails.supportedDevices ||
pluginDetails.supportedDevices?.some(
(d) =>
(!d.os || d.os === this.os) &&
(!d.type || d.type === this.deviceType) &&
(d.archived === undefined || d.archived === this.isArchived) &&
(!d.specs || d.specs.every((spec) => this.specs.includes(spec))),
))
);
}
loadDevicePlugins(
devicePlugins: DevicePluginMap,
enabledDevicePlugins: Set<string>,
pluginStates?: Record<string, any>,
) {
if (!devicePlugins) {
return;
}
const plugins = Array.from(devicePlugins.values()).filter((p) =>
enabledDevicePlugins?.has(p.id),
);
for (const plugin of plugins) {
this.loadDevicePlugin(plugin, pluginStates?.[plugin.id]);
}
}
loadDevicePlugin(plugin: PluginDefinition, initialState?: any) {
if (!this.supportsPlugin(plugin)) {
return;
}
this.hasDevicePlugins = true;
if (plugin instanceof _SandyPluginDefinition) {
this.sandyPluginStates.set(
plugin.id,
new _SandyDevicePluginInstance(
getFlipperLib(),
plugin,
this,
// break circular dep, one of those days again...
require('../utils/pluginUtils').getPluginKey(
undefined,
{serial: this.serial},
plugin.id,
),
initialState,
),
);
}
}
unloadDevicePlugin(pluginId: string) {
const instance = this.sandyPluginStates.get(pluginId);
if (instance) {
instance.destroy();
this.sandyPluginStates.delete(pluginId);
}
}
disconnect() {
this.logListeners.clear();
this.stopLogging();
this.connected.set(false);
}
destroy() {
this.disconnect();
this.sandyPluginStates.forEach((instance) => {
instance.destroy();
});
this.sandyPluginStates.clear();
}
}

View File

@@ -1,19 +0,0 @@
/**
* 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, {OS} from './BaseDevice';
/**
* 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 BaseDevice {
constructor(serial: string, title: string, os: OS) {
super(serial, 'dummy', title, os);
}
}

View File

@@ -1,281 +0,0 @@
/**
* 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 type {LogLevel, DeviceLogEntry, DeviceType} from 'flipper-plugin';
import child_process, {ChildProcess} from 'child_process';
import BaseDevice from './BaseDevice';
import JSONStream from 'JSONStream';
import {Transform} from 'stream';
import {exec} from 'promisify-child-process';
import {default as promiseTimeout} from '../utils/promiseTimeout';
import {
ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB,
IOSBridge,
} from '../utils/IOSBridge';
import split2 from 'split2';
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 BaseDevice {
log?: child_process.ChildProcessWithoutNullStreams;
buffer: string;
private recordingProcess?: ChildProcess;
private recordingLocation?: string;
private iOSBridge: IOSBridge;
constructor(
iOSBridge: IOSBridge,
serial: string,
deviceType: DeviceType,
title: string,
) {
super(serial, deviceType, title, 'iOS');
this.icon = 'mobile';
this.buffer = '';
this.iOSBridge = iOSBridge;
}
async screenshot(): Promise<Buffer> {
if (!this.connected.get()) {
return Buffer.from([]);
}
return await this.iOSBridge.screenshot(this.serial);
}
navigateToLocation(location: string) {
const command = `xcrun simctl openurl ${this.serial} "${location}"`;
exec(command);
}
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.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.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): LogLevel {
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: LogLevel = 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.deviceType === 'emulator' && this.connected.get();
}
async startScreenCapture(destination: string) {
this.recordingProcess = exec(
`xcrun simctl io ${this.serial} 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.warn('Failed to terminate iOS screen recording:', e);
return null;
});
return output;
}
return null;
}
disconnect() {
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();
}
}

View File

@@ -1,19 +0,0 @@
/**
* 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.webContentsId = webContentsId;
}
}

View File

@@ -1,31 +0,0 @@
/**
* 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';
export default class KaiOSDevice extends AndroidDevice {
constructor(
serial: string,
deviceType: DeviceType,
title: string,
adb: ADBClient,
abiList: Array<string>,
sdkVersion: string,
) {
super(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;
}
}

View File

@@ -1,19 +0,0 @@
/**
* 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');
this.icon = 'app-apple';
}
teardown() {}
}

View File

@@ -1,200 +0,0 @@
/**
* 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 {LogLevel} from 'flipper-plugin';
import BaseDevice from './BaseDevice';
import {EventEmitter} from 'events';
import util from 'util';
// 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');
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: 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),
});
}
}
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);
}
}
}

View File

@@ -1,19 +0,0 @@
/**
* 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');
this.icon = 'app-microsoft-windows';
}
teardown() {}
}

View File

@@ -1,313 +0,0 @@
/**
* 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 * as DeviceTestPluginModule from '../../test-utils/DeviceTestPlugin';
import {TestUtils, _SandyPluginDefinition} from 'flipper-plugin';
import ArchivedDevice from '../ArchivedDevice';
import DummyDevice from '../DummyDevice';
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
const physicalDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'physicalDevicePlugin',
name: 'flipper-plugin-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'physical',
archived: false,
},
{
os: 'Android',
type: 'physical',
},
],
});
const physicalDevicePlugin = new _SandyPluginDefinition(
physicalDevicePluginDetails,
DeviceTestPluginModule,
);
const iosPhysicalDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'iosPhysicalDevicePlugin',
name: 'flipper-plugin-ios-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'physical',
},
],
});
const iosPhysicalDevicePlugin = new _SandyPluginDefinition(
iosPhysicalDevicePluginDetails,
DeviceTestPluginModule,
);
const iosEmulatorlDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'iosEmulatorDevicePlugin',
name: 'flipper-plugin-ios-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'emulator',
},
],
});
const iosEmulatorDevicePlugin = new _SandyPluginDefinition(
iosEmulatorlDevicePluginDetails,
DeviceTestPluginModule,
);
const androiKaiosPhysicalDevicePluginDetails =
TestUtils.createMockPluginDetails({
id: 'androidPhysicalDevicePlugin',
name: 'flipper-plugin-android-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
type: 'physical',
specs: ['KaiOS'],
},
],
});
const androidKaiosPhysicalDevicePlugin = new _SandyPluginDefinition(
androiKaiosPhysicalDevicePluginDetails,
DeviceTestPluginModule,
);
const androidEmulatorlDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'androidEmulatorDevicePlugin',
name: 'flipper-plugin-android-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
type: 'emulator',
},
],
});
const androidEmulatorDevicePlugin = new _SandyPluginDefinition(
androidEmulatorlDevicePluginDetails,
DeviceTestPluginModule,
);
const androidOnlyDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'androidEmulatorDevicePlugin',
name: 'flipper-plugin-android-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
},
],
});
const androidOnlyDevicePlugin = new _SandyPluginDefinition(
androidOnlyDevicePluginDetails,
DeviceTestPluginModule,
);
test('ios physical device compatibility', () => {
const device = new BaseDevice('serial', 'physical', 'test device', 'iOS');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('archived device compatibility', () => {
const device = new ArchivedDevice({
serial: 'serial',
deviceType: 'physical',
title: 'test device',
os: 'iOS',
screenshotHandle: null,
});
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('android emulator device compatibility', () => {
const device = new BaseDevice('serial', 'emulator', 'test device', 'Android');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeTruthy();
});
test('android KaiOS device compatibility', () => {
const device = new BaseDevice(
'serial',
'physical',
'test device',
'Android',
['KaiOS'],
);
expect(device.supportsPlugin(physicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('android dummy device compatibility', () => {
const device = new DummyDevice('serial', 'test device', 'Android');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidOnlyDevicePlugin)).toBeTruthy();
});
test('log listeners are resumed and suspended automatically - 1', async () => {
const message = {
date: new Date(),
message: 'test',
pid: 0,
tid: 1,
type: 'info',
tag: 'tag',
} as const;
const device = new BaseDevice('serial', 'physical', 'test device', 'Android');
device.startLogging = jest.fn();
device.stopLogging = jest.fn();
const DevicePlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client) {
const entries: any[] = [];
let disposer: any;
function start() {
disposer = client.device.onLogEntry((entry) => {
entries.push(entry);
});
}
function stop() {
disposer?.();
}
start();
return {start, stop, entries};
},
});
await createMockFlipperWithPlugin(DevicePlugin, {
device,
});
const instance = device.sandyPluginStates.get(DevicePlugin.id);
expect(instance).toBeDefined();
const entries = instance?.instanceApi.entries as any[];
// logging set up, messages arrive
expect(device.startLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(1);
// stop, messages don't arrive
instance?.instanceApi.stop();
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(1);
// resume, messsages arrive again
instance?.instanceApi.start();
expect(device.startLogging).toBeCalledTimes(2);
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(2);
// device disconnects, loggers are disposed
device.disconnect();
expect(device.stopLogging).toBeCalledTimes(2);
});
test('log listeners are resumed and suspended automatically - 2', async () => {
const message = {
date: new Date(),
message: 'test',
pid: 0,
tid: 1,
type: 'info',
tag: 'tag',
} as const;
const device = new BaseDevice('serial', 'physical', 'test device', 'Android');
device.startLogging = jest.fn();
device.stopLogging = jest.fn();
const entries: any[] = [];
const DevicePlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client) {
client.device.onLogEntry((entry) => {
entries.push(entry);
});
return {};
},
});
const Plugin = TestUtils.createTestPlugin(
{
plugin(client) {
client.device.onLogEntry((entry) => {
entries.push(entry);
});
return {};
},
},
{
id: 'AnotherPlugin',
},
);
const flipper = await createMockFlipperWithPlugin(DevicePlugin, {
device,
additionalPlugins: [Plugin],
});
const instance = device.sandyPluginStates.get(DevicePlugin.id);
expect(instance).toBeDefined();
// logging set up, messages arrives in both
expect(device.startLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(2);
// disable one plugin
flipper.togglePlugin(Plugin.id);
expect(device.stopLogging).toBeCalledTimes(0);
device.addLogEntry(message);
expect(entries.length).toBe(3);
// disable the other plugin
flipper.togglePlugin(DevicePlugin.id);
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(3);
// re-enable plugn
flipper.togglePlugin(Plugin.id);
expect(device.startLogging).toBeCalledTimes(2);
device.addLogEntry(message);
expect(entries.length).toBe(4);
});