From e218b79de22c91e178fb9ce5165d654d6c785eb1 Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Tue, 10 May 2022 05:13:24 -0700 Subject: [PATCH] Implement companion Summary: Add Flipper Server Companion implementation. It extends the list of commands handled by Flipper Server by adding commands to interact with device or app Flipper plugins. Reviewed By: mweststrate Differential Revision: D36130159 fbshipit-source-id: 2eb6ba0fbae0fa850eadb7d417aa476240994bd5 --- .../src/HeadlessClient.tsx | 5 + .../src/companion.tsx | 461 ++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 desktop/flipper-server-companion/src/companion.tsx diff --git a/desktop/flipper-server-companion/src/HeadlessClient.tsx b/desktop/flipper-server-companion/src/HeadlessClient.tsx index c42d07b37..b233ce575 100644 --- a/desktop/flipper-server-companion/src/HeadlessClient.tsx +++ b/desktop/flipper-server-companion/src/HeadlessClient.tsx @@ -29,6 +29,11 @@ export class HeadlessClient extends AbstractClient { super(id, query, conn, logger, plugins, device, flipperServer); } + isBackgroundPlugin(_pluginId: string) { + // In headless context we treat every plugin as a non-background one because we do not want to start anything automatically to preseve resources + return false; + } + // Headless client never starts plugins automaticaly to preserve server resources shouldConnectAsBackgroundPlugin() { return false; diff --git a/desktop/flipper-server-companion/src/companion.tsx b/desktop/flipper-server-companion/src/companion.tsx new file mode 100644 index 000000000..72d2197d5 --- /dev/null +++ b/desktop/flipper-server-companion/src/companion.tsx @@ -0,0 +1,461 @@ +/** + * 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 {FlipperServer, Logger} from 'flipper-common'; +import {BaseDevice} from 'flipper-frontend-core'; +import {_SandyPluginDefinition} from 'flipper-plugin'; +import {HeadlessClient} from './HeadlessClient'; + +type SerializableFnArg = + | null + | boolean + | number + | string + | {[prop: string]: SerializableFnArg | SerializableFnArg[]}; + +interface AvailablePlugin { + pluginId: string; + /** + * `active` if a plugin is connected and running (accepting messages) + * `ready` if a plugin can be started: bundled or found on a file system. + * `unavailable` if plugin is supported by a device, but it cannot be loaded by Flipper (not bundled, not found on a file system, does not support a headless mode) + */ + state: 'unavailable' | 'ready' | 'active'; +} + +export type FlipperCompanionCommands = { + 'companion-plugin-list': (clientId: string) => Promise; + /** + * Start a plugin for a client. It triggers 'onConnect' and 'onActivate' listeners for the plugin. + */ + 'companion-plugin-start': ( + clientId: string, + pluginId: string, + ) => Promise; + /** + * Stops and destroys a plugin for a client. It triggers 'onDeactivate', 'onDisconnect', and 'onDestroy' listeners for the plugin. + */ + 'companion-plugin-stop': ( + clientId: string, + pluginId: string, + ) => Promise; + /** + * Execute a command exposed via `export const API = () => ...` in a plugin. + */ + 'companion-plugin-exec': ( + clientId: string, + pluginId: string, + api: string, + params?: SerializableFnArg[], + ) => Promise; + 'companion-device-plugin-list': ( + deviceSerial: string, + ) => Promise; + /** + * Start a device plugin for a device. It triggers 'onActivate' listener for the plugin. + */ + 'companion-device-plugin-start': ( + deviceSerial: string, + pluginId: string, + ) => Promise; + /** + * Stops and destroys a device plugin for a device. It triggers 'onDeactivate' and 'onDestroy' listeners for the plugin. + */ + 'companion-device-plugin-stop': ( + deviceSerial: string, + pluginId: string, + ) => Promise; + /** + * Execute a command exposed via `export const api = () => ...` in a plugin. + */ + 'companion-device-plugin-exec': ( + deviceSerial: string, + pluginId: string, + api: string, + params?: SerializableFnArg[], + ) => Promise; +}; + +export class FlipperServerCompanion { + /** + * A map of headless clients. Once a client is started, you can start plugins for the client. + * Headless client manages a connection from desktop to Flipper server. Device client manages a connection from a device to Flipper server. + * + * --- YOUR DESKTOP --- --------- Node.js process ---------------------------------- --- Your iPhone ----- + * | --------------- | | --- Flipper Server Companion --- ------------------ | | | + * | | Flipper CLI | | <-- WebSocket conection --> | | ------------------- | | | | | ----------------- | + * | --------------- | | | | Headless client | | <--> | Flipper Server | | <-- WebSocker conection --> | | Device client | | + * -------------------- | | ------------------- | | | | | ----------------- | + * | -------------------------------- ------------------ | | | + * ------------------------------------------------------------ --------------------| + */ + private readonly clients = new Map(); + + private readonly devices = new Map(); + private readonly loadablePlugins = new Map(); + + constructor( + private readonly flipperServer: FlipperServer, + private readonly logger: Logger, + loadablePluginsArr: ReadonlyArray<_SandyPluginDefinition>, + ) { + for (const loadablePlugin of loadablePluginsArr) { + this.loadablePlugins.set(loadablePlugin.id, loadablePlugin); + } + } + + canHandleCommand(command: string): boolean { + return !!this.commandHandler[command as keyof FlipperCompanionCommands]; + } + + getClient(clientId: string) { + return this.clients.get(clientId); + } + + destroyClient(clientId: string) { + const client = this.clients.get(clientId); + if (!client) { + throw new Error( + 'FlipperServerCompanion.destroyClient -> client not found', + ); + } + client.destroy(); + this.clients.delete(clientId); + } + + getDevice(deviceSerial: string) { + return this.devices.get(deviceSerial); + } + + destroyDevice(deviceSerial: string) { + const device = this.devices.get(deviceSerial); + if (!device) { + throw new Error( + 'FlipperServerCompanion.destroyDevice -> device not found', + ); + } + device.destroy(); + this.devices.delete(deviceSerial); + } + + destroyAll() { + this.clients.forEach((client) => client.destroy()); + this.clients.clear(); + this.devices.forEach((device) => device.destroy()); + this.devices.clear(); + } + + private async createHeadlessClientIfNeeded(clientId: string) { + const existingClient = this.clients.get(clientId); + if (existingClient) { + return existingClient; + } + + const clientInfo = await this.flipperServer.exec('client-find', clientId); + if (!clientInfo) { + throw new Error( + 'FlipperServerCompanion.createHeadlessClientIfNeeded -> client not found', + ); + } + + const device = await this.createHeadlessDeviceIfNeeded( + clientInfo.query.device_id, + ); + + const newClient = new HeadlessClient( + clientInfo.id, + clientInfo.query, + { + send: (data: any) => { + this.flipperServer.exec('client-request', clientInfo.id, data); + }, + sendExpectResponse: (data: any) => + this.flipperServer.exec( + 'client-request-response', + clientInfo.id, + data, + ), + }, + this.logger, + undefined, + device, + this.flipperServer, + this.loadablePlugins, + ); + + await newClient.init(); + + this.clients.set(clientInfo.id, newClient); + return newClient; + } + + private async createHeadlessDeviceIfNeeded(deviceSerial: string) { + const existingDevice = this.devices.get(deviceSerial); + if (existingDevice) { + return existingDevice; + } + + const deviceInfo = await this.flipperServer.exec( + 'device-find', + deviceSerial, + ); + if (!deviceInfo) { + throw new Error( + 'FlipperServerCompanion.createHeadlessDeviceIfNeeded -> device not found', + ); + } + + const newDevice = new BaseDevice(this.flipperServer, deviceInfo); + this.devices.set(newDevice.serial, newDevice); + return newDevice; + } + + exec( + event: Event, + ...args: Parameters + ): ReturnType; + async exec( + event: Event, + ...args: any[] + ): Promise { + try { + const handler: (...args: any[]) => Promise = + this.commandHandler[event]; + if (!handler) { + throw new Error( + `Unimplemented FlipperServerCompanion command: ${event}`, + ); + } + const result = await handler(...args); + console.debug(`[FlipperServerCompanion] command '${event}' - OK`); + return result; + } catch (e) { + console.debug( + `[FlipperServerCompanion] command '${event}' - ERROR: ${e} `, + ); + throw e; + } + } + + private commandHandler: FlipperCompanionCommands = { + 'companion-plugin-list': async (clientId) => { + const client = await this.createHeadlessClientIfNeeded(clientId); + return [...client.plugins].map((pluginId) => { + const pluginInstance = client.sandyPluginStates.get(pluginId); + + let state: AvailablePlugin['state'] = 'unavailable'; + if (pluginInstance) { + state = 'ready'; + if (pluginInstance.activated) { + state = 'active'; + } + } + return { + pluginId, + state, + }; + }); + }, + 'companion-plugin-start': async (clientId, pluginId) => { + const client = await this.createHeadlessClientIfNeeded(clientId); + + const pluginInstance = client.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-start -> plugin not found', + ); + } + + if (!client.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-start -> client not connected', + ); + } + + pluginInstance.activate(); + }, + 'companion-plugin-stop': async (clientId, pluginId) => { + const client = this.clients.get(clientId); + if (!client) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-stop -> client not found', + ); + } + + const pluginInstance = client.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-stop -> plugin not found', + ); + } + + if (!client.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-stop -> client not connected', + ); + } + + if (!pluginInstance.activated) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-stop -> plugin not activated', + ); + } + + client.stopPluginIfNeeded(pluginId); + }, + 'companion-plugin-exec': async (clientId, pluginId, api, params) => { + const client = this.clients.get(clientId); + if (!client) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> client not found', + ); + } + + const pluginInstance = client.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> plugin not found', + ); + } + + if (!client.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> client not connected', + ); + } + + if (!pluginInstance.companionApi) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> plugin does not expose API', + ); + } + + if (typeof pluginInstance.companionApi[api] !== 'function') { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> plugin does not expose requested API method or it is not callable', + ); + } + + return pluginInstance.companionApi[api](...(params ?? [])); + }, + 'companion-device-plugin-list': async (deviceSerial) => { + const device = await this.createHeadlessDeviceIfNeeded(deviceSerial); + + const supportedDevicePlugins = [...this.loadablePlugins.values()].filter( + (plugin) => device.supportsPlugin(plugin), + ); + return supportedDevicePlugins.map((plugin) => { + const pluginInstance = device.sandyPluginStates.get(plugin.id); + + let state: AvailablePlugin['state'] = 'ready'; + if (pluginInstance) { + state = 'active'; + } + return { + pluginId: plugin.id, + state, + }; + }); + }, + 'companion-device-plugin-start': async (deviceSerial, pluginId) => { + const device = await this.createHeadlessDeviceIfNeeded(deviceSerial); + + const pluginDefinition = this.loadablePlugins.get(pluginId); + if (!pluginDefinition) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-start -> plugin definition not found', + ); + } + + if (!device.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-start -> device not connected', + ); + } + + if (!device.supportsPlugin(pluginDefinition)) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-start -> device does not support plugin', + ); + } + + device.loadDevicePlugin(pluginDefinition); + device.sandyPluginStates.get(pluginId)!.activate(); + }, + 'companion-device-plugin-stop': async (deviceSerial, pluginId) => { + const device = this.devices.get(deviceSerial); + if (!device) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-stop -> client not found', + ); + } + + const pluginInstance = device.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-stop -> plugin not found', + ); + } + + if (!device.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-stop -> device not connected', + ); + } + + if (!pluginInstance.activated) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-stop -> plugin not activated', + ); + } + + device.unloadDevicePlugin(pluginId); + }, + 'companion-device-plugin-exec': async ( + deviceSerial, + pluginId, + api, + params, + ) => { + const device = this.devices.get(deviceSerial); + if (!device) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-exec -> device not found', + ); + } + + const pluginInstance = device.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new Error( + 'FlipperServerCompanion.companion-device-plugin-exec -> plugin not found', + ); + } + + if (!device.connected.get()) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> client not connected', + ); + } + + if (!pluginInstance.companionApi) { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> plugin does not expose API', + ); + } + + if (typeof pluginInstance.companionApi[api] !== 'function') { + throw new Error( + 'FlipperServerCompanion.companion-plugin-exec -> plugin does not expose requested API method or it is not callable', + ); + } + + return pluginInstance.companionApi[api](...(params ?? [])); + }, + }; +}