/** * 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; export type DeviceExport = { os: DeviceOS; title: string; deviceType: DeviceType; serial: string; pluginStates: Record; }; 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 = new Map(); crashListeners: Map = 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 = 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> { const pluginStates: Record = {}; 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 { 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 { return this.flipperServer.exec( 'device-start-screencapture', this.serial, destination, ); } async stopScreenCapture(): Promise { return this.flipperServer.exec('device-stop-screencapture', this.serial); } async executeShell(command: string): Promise { return this.flipperServer.exec('device-shell-exec', this.serial, command); } async sendMetroCommand(command: string): Promise { return this.flipperServer.exec('metro-command', this.serial, command); } async forwardPort(local: string, remote: string): Promise { 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, pluginStates?: Record, ) { 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(); } }