Reviewed By: passy Differential Revision: D36129746 fbshipit-source-id: 15e32b9482d7fe3a24567d2e6bc087095b98226e
379 lines
9.9 KiB
TypeScript
379 lines
9.9 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import {
|
|
Device,
|
|
_SandyDevicePluginInstance,
|
|
_SandyPluginDefinition,
|
|
DeviceLogListener,
|
|
Idler,
|
|
createState,
|
|
getFlipperLib,
|
|
CrashLogListener,
|
|
} from 'flipper-plugin';
|
|
import {
|
|
DeviceLogEntry,
|
|
DeviceOS,
|
|
DeviceType,
|
|
DeviceDescription,
|
|
FlipperServer,
|
|
CrashLog,
|
|
ServerAddOnControls,
|
|
} from 'flipper-common';
|
|
import {DeviceSpec, PluginDetails} from 'flipper-common';
|
|
import {getPluginKey} from '../utils/pluginKey';
|
|
import {Base64} from 'js-base64';
|
|
import {createServerAddOnControls} from '../utils/createServerAddOnControls';
|
|
|
|
type PluginDefinition = _SandyPluginDefinition;
|
|
type PluginMap = Map<string, PluginDefinition>;
|
|
|
|
export type DeviceExport = {
|
|
os: DeviceOS;
|
|
title: string;
|
|
deviceType: DeviceType;
|
|
serial: string;
|
|
pluginStates: Record<string, any>;
|
|
};
|
|
|
|
export default class BaseDevice implements Device {
|
|
description: DeviceDescription;
|
|
flipperServer: FlipperServer;
|
|
isArchived = false;
|
|
hasDevicePlugins = false; // true if there are device plugins for this device (not necessarily enabled)
|
|
private readonly serverAddOnControls: ServerAddOnControls;
|
|
|
|
constructor(flipperServer: FlipperServer, description: DeviceDescription) {
|
|
this.flipperServer = flipperServer;
|
|
this.description = description;
|
|
this.serverAddOnControls = createServerAddOnControls(this.flipperServer);
|
|
}
|
|
|
|
get isConnected(): boolean {
|
|
return this.connected.get();
|
|
}
|
|
|
|
// operating system of this device
|
|
get os() {
|
|
return this.description.os;
|
|
}
|
|
|
|
// human readable name for this device
|
|
get title(): string {
|
|
return this.description.title;
|
|
}
|
|
|
|
// type of this device
|
|
get deviceType() {
|
|
return this.description.deviceType;
|
|
}
|
|
|
|
// serial number for this device
|
|
get serial() {
|
|
return this.description.serial;
|
|
}
|
|
|
|
// additional device specs used for plugin compatibility checks
|
|
get specs(): DeviceSpec[] {
|
|
return this.description.specs ?? [];
|
|
}
|
|
|
|
// possible src of icon to display next to the device title
|
|
get icon() {
|
|
return this.description.icon;
|
|
}
|
|
|
|
logListeners: Map<Symbol, DeviceLogListener> = new Map();
|
|
|
|
crashListeners: Map<Symbol, CrashLogListener> = new Map();
|
|
|
|
readonly connected = createState(true);
|
|
|
|
// if imported, stores the original source location
|
|
source = '';
|
|
|
|
// TODO: ideally we don't want BasePlugin to know about the concept of plugins
|
|
sandyPluginStates: Map<string, _SandyDevicePluginInstance> = new Map<
|
|
string,
|
|
_SandyDevicePluginInstance
|
|
>();
|
|
|
|
supportsOS(os: DeviceOS) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
private deviceLogEventHandler = (payload: {
|
|
serial: string;
|
|
entry: DeviceLogEntry;
|
|
}) => {
|
|
if (payload.serial === this.serial && this.logListeners.size > 0) {
|
|
this.addLogEntry(payload.entry);
|
|
}
|
|
};
|
|
|
|
addLogEntry(entry: DeviceLogEntry) {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
async startLogging() {
|
|
this.flipperServer.on('device-log', this.deviceLogEventHandler);
|
|
}
|
|
|
|
stopLogging() {
|
|
this.flipperServer.off('device-log', this.deviceLogEventHandler);
|
|
}
|
|
|
|
addLogListener(callback: DeviceLogListener): Symbol {
|
|
if (this.logListeners.size === 0) {
|
|
this.startLogging();
|
|
}
|
|
const id = Symbol();
|
|
this.logListeners.set(id, callback);
|
|
return id;
|
|
}
|
|
|
|
removeLogListener(id: Symbol) {
|
|
this.logListeners.delete(id);
|
|
if (this.logListeners.size === 0) {
|
|
this.stopLogging();
|
|
}
|
|
}
|
|
|
|
private crashLogEventHandler = (payload: {
|
|
serial: string;
|
|
crash: CrashLog;
|
|
}) => {
|
|
if (payload.serial === this.serial && this.crashListeners.size > 0) {
|
|
this.addCrashEntry(payload.crash);
|
|
}
|
|
};
|
|
|
|
addCrashEntry(entry: CrashLog) {
|
|
this.crashListeners.forEach((listener) => {
|
|
// prevent breaking other listeners, if one listener doesn't work.
|
|
try {
|
|
listener(entry);
|
|
} catch (e) {
|
|
console.error(`Crash listener exception:`, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
async startCrashWatcher() {
|
|
this.flipperServer.on('device-crash', this.crashLogEventHandler);
|
|
}
|
|
|
|
stopCrashWatcher() {
|
|
this.flipperServer.off('device-crash', this.crashLogEventHandler);
|
|
}
|
|
|
|
addCrashListener(callback: CrashLogListener): Symbol {
|
|
if (this.crashListeners.size === 0) {
|
|
this.startCrashWatcher();
|
|
}
|
|
const id = Symbol();
|
|
this.crashListeners.set(id, callback);
|
|
return id;
|
|
}
|
|
|
|
removeCrashListener(id: Symbol) {
|
|
this.crashListeners.delete(id);
|
|
if (this.crashListeners.size === 0) {
|
|
this.stopCrashWatcher();
|
|
}
|
|
}
|
|
|
|
async navigateToLocation(location: string) {
|
|
return this.flipperServer.exec('device-navigate', this.serial, location);
|
|
}
|
|
|
|
async screenshot(): Promise<Uint8Array | undefined> {
|
|
if (!this.description.features.screenshotAvailable || this.isArchived) {
|
|
return;
|
|
}
|
|
return Base64.toUint8Array(
|
|
await this.flipperServer.exec('device-take-screenshot', this.serial),
|
|
);
|
|
}
|
|
|
|
async startScreenCapture(destination: string): Promise<void> {
|
|
return this.flipperServer.exec(
|
|
'device-start-screencapture',
|
|
this.serial,
|
|
destination,
|
|
);
|
|
}
|
|
|
|
async stopScreenCapture(): Promise<string | null> {
|
|
return this.flipperServer.exec('device-stop-screencapture', this.serial);
|
|
}
|
|
|
|
async executeShell(command: string): Promise<string> {
|
|
return this.flipperServer.exec('device-shell-exec', this.serial, command);
|
|
}
|
|
|
|
async sendMetroCommand(command: string): Promise<void> {
|
|
return this.flipperServer.exec('metro-command', this.serial, command);
|
|
}
|
|
|
|
async forwardPort(local: string, remote: string): Promise<boolean> {
|
|
return this.flipperServer.exec(
|
|
'device-forward-port',
|
|
this.serial,
|
|
local,
|
|
remote,
|
|
);
|
|
}
|
|
|
|
async clearLogs() {
|
|
return this.flipperServer.exec('device-clear-logs', this.serial);
|
|
}
|
|
|
|
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: PluginMap,
|
|
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) {
|
|
try {
|
|
this.sandyPluginStates.set(
|
|
plugin.id,
|
|
new _SandyDevicePluginInstance(
|
|
this.serverAddOnControls,
|
|
getFlipperLib(),
|
|
plugin,
|
|
this,
|
|
// break circular dep, one of those days again...
|
|
getPluginKey(undefined, {serial: this.serial}, plugin.id),
|
|
initialState,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
console.error(`Failed to start device plugin '${plugin.id}': `, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
unloadDevicePlugin(pluginId: string) {
|
|
const instance = this.sandyPluginStates.get(pluginId);
|
|
if (instance) {
|
|
instance.destroy();
|
|
this.sandyPluginStates.delete(pluginId);
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
this.logListeners.clear();
|
|
this.stopLogging();
|
|
this.crashListeners.clear();
|
|
this.stopCrashWatcher();
|
|
this.connected.set(false);
|
|
}
|
|
|
|
destroy() {
|
|
this.disconnect();
|
|
this.sandyPluginStates.forEach((instance) => {
|
|
instance.destroy();
|
|
});
|
|
this.sandyPluginStates.clear();
|
|
this.serverAddOnControls.unsubscribe();
|
|
}
|
|
}
|