diff --git a/desktop/flipper-common/src/companion-types.tsx b/desktop/flipper-common/src/companion-types.tsx new file mode 100644 index 000000000..67eacfa85 --- /dev/null +++ b/desktop/flipper-common/src/companion-types.tsx @@ -0,0 +1,111 @@ +/** + * 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 + */ + +type SerializableFnArg = + | null + | boolean + | number + | string + | {[prop: string]: SerializableFnArg | SerializableFnArg[]}; + +export interface FlipperCompanionAvailablePlugin { + 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; + /** + * Subscribe to state updates via `export const API = () => ...` in a plugin. Returns the current state. + */ + 'companion-plugin-subscribe': ( + clientId: string, + pluginId: string, + api: string, + ) => 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; + /** + * Subscribe to state updates via `export const API = () => ...` in a plugin. Returns the current state. + */ + 'companion-device-plugin-subscribe': ( + clientId: string, + pluginId: string, + api: string, + ) => Promise; +}; + +export type FlipperCompanionEvents = { + 'companion-plugin-state-update': { + clientId: string; + pluginId: string; + api: string; + data: unknown; + }; + 'companion-device-plugin-state-update': { + deviceSerial: string; + pluginId: string; + api: string; + data: unknown; + }; +}; diff --git a/desktop/flipper-common/src/index.tsx b/desktop/flipper-common/src/index.tsx index 47534b2f8..0adc56355 100644 --- a/desktop/flipper-common/src/index.tsx +++ b/desktop/flipper-common/src/index.tsx @@ -17,6 +17,7 @@ export { NoopLogger, } from './utils/Logger'; export * from './server-types'; +export * from './companion-types'; export * from './ServerAddOn'; export {sleep} from './utils/sleep'; export {timeout} from './utils/timeout'; diff --git a/desktop/flipper-common/src/transport.tsx b/desktop/flipper-common/src/transport.tsx index 83ae40931..215a077b1 100644 --- a/desktop/flipper-common/src/transport.tsx +++ b/desktop/flipper-common/src/transport.tsx @@ -7,9 +7,10 @@ * @format */ +import {FlipperCompanionEvents} from './companion-types'; import {FlipperServerCommands, FlipperServerEvents} from './server-types'; -type GenericWebSocketMessage = { +export type GenericWebSocketMessage = { event: E; payload: T; }; @@ -47,6 +48,16 @@ export type ServerEventWebSocketMessage = GenericWebSocketMessage< }[keyof FlipperServerEvents] >; +export type CompanionEventWebSocketMessage = GenericWebSocketMessage< + 'companion-event', + { + [K in keyof FlipperCompanionEvents]: { + event: K; + data: FlipperCompanionEvents[K]; + }; + }[keyof FlipperCompanionEvents] +>; + export type ClientWebSocketMessage = ExecWebSocketMessage; export type ServerWebSocketMessage = | ExecResponseWebSocketMessage diff --git a/desktop/flipper-server-companion/src/companion.tsx b/desktop/flipper-server-companion/src/companion.tsx index f46b69c0f..23203291a 100644 --- a/desktop/flipper-server-companion/src/companion.tsx +++ b/desktop/flipper-server-companion/src/companion.tsx @@ -7,80 +7,25 @@ * @format */ -import {FlipperServer, Logger, UserError, SystemError} from 'flipper-common'; +import EventEmitter from 'events'; +import { + FlipperServer, + Logger, + UserError, + SystemError, + FlipperCompanionCommands, + FlipperCompanionEvents, + FlipperCompanionAvailablePlugin, +} from 'flipper-common'; import {BaseDevice} from 'flipper-frontend-core'; import {_SandyPluginDefinition} from 'flipper-plugin'; +import {isAtom} 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; -}; +const companionEvents: Array = [ + 'companion-plugin-state-update', + 'companion-device-plugin-state-update', +]; export class FlipperServerCompanion { /** @@ -96,9 +41,9 @@ export class FlipperServerCompanion { * ------------------------------------------------------------ --------------------| */ private readonly clients = new Map(); - private readonly devices = new Map(); private readonly loadablePlugins = new Map(); + private readonly eventBus = new EventEmitter(); constructor( private readonly flipperServer: FlipperServer, @@ -220,6 +165,24 @@ export class FlipperServerCompanion { return newDevice; } + private emit( + event: T, + data: FlipperCompanionEvents[T], + ) { + this.eventBus.emit(event, data); + } + + onAny( + cb: ( + event: T, + data: FlipperCompanionEvents[T], + ) => void, + ) { + for (const eventName of companionEvents) { + this.eventBus.on(eventName, (data) => cb(eventName, data)); + } + } + exec( event: Event, ...args: Parameters @@ -254,7 +217,7 @@ export class FlipperServerCompanion { return [...client.plugins].map((pluginId) => { const pluginInstance = client.sandyPluginStates.get(pluginId); - let state: AvailablePlugin['state'] = 'unavailable'; + let state: FlipperCompanionAvailablePlugin['state'] = 'unavailable'; if (pluginInstance) { state = 'ready'; if (pluginInstance.activated) { @@ -379,6 +342,66 @@ export class FlipperServerCompanion { return pluginInstance.companionApi[api](...(params ?? [])); }, + 'companion-plugin-subscribe': async (clientId, pluginId, api) => { + const client = this.clients.get(clientId); + if (!client) { + throw new UserError( + 'FlipperServerCompanion.companion-plugin-subscribe -> client not found', + clientId, + pluginId, + api, + ); + } + + const pluginInstance = client.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new UserError( + 'FlipperServerCompanion.companion-plugin-subscribe -> plugin not found', + clientId, + pluginId, + api, + ); + } + + if (!client.connected.get()) { + throw new UserError( + 'FlipperServerCompanion.companion-plugin-subscribe -> client not connected', + clientId, + pluginId, + api, + ); + } + + if (!pluginInstance.companionApi) { + throw new UserError( + 'FlipperServerCompanion.companion-plugin-subscribe -> plugin does not expose API', + clientId, + pluginId, + api, + ); + } + + const stateAtom = pluginInstance.companionApi[api]; + if (!isAtom(stateAtom)) { + throw new SystemError( + 'FlipperServerCompanion.companion-plugin-subscribe -> plugin does not expose requested state or it is not an Atom (created with `createState`)', + clientId, + pluginId, + api, + ); + } + + stateAtom.subscribe((data) => + this.emit('companion-plugin-state-update', { + clientId, + pluginId, + api, + data, + }), + ); + + return stateAtom.get(); + }, 'companion-device-plugin-list': async (deviceSerial) => { const device = await this.createHeadlessDeviceIfNeeded(deviceSerial); @@ -388,7 +411,7 @@ export class FlipperServerCompanion { return supportedDevicePlugins.map((plugin) => { const pluginInstance = device.sandyPluginStates.get(plugin.id); - let state: AvailablePlugin['state'] = 'ready'; + let state: FlipperCompanionAvailablePlugin['state'] = 'ready'; if (pluginInstance) { state = 'active'; } @@ -526,5 +549,69 @@ export class FlipperServerCompanion { return pluginInstance.companionApi[api](...(params ?? [])); }, + 'companion-device-plugin-subscribe': async ( + deviceSerial, + pluginId, + api, + ) => { + const device = this.devices.get(deviceSerial); + if (!device) { + throw new UserError( + 'FlipperServerCompanion.companion-device-plugin-subscribe -> device not found', + deviceSerial, + pluginId, + api, + ); + } + + const pluginInstance = device.sandyPluginStates.get(pluginId); + if (!pluginInstance) { + throw new UserError( + 'FlipperServerCompanion.companion-device-plugin-subscribe -> plugin not found', + deviceSerial, + pluginId, + api, + ); + } + + if (!device.connected.get()) { + throw new UserError( + 'FlipperServerCompanion.companion-device-plugin-subscribe -> client not connected', + deviceSerial, + pluginId, + api, + ); + } + + if (!pluginInstance.companionApi) { + throw new UserError( + 'FlipperServerCompanion.companion-device-plugin-subscribe -> plugin does not expose API', + deviceSerial, + pluginId, + api, + ); + } + + const stateAtom = pluginInstance.companionApi[api]; + if (!isAtom(stateAtom)) { + throw new SystemError( + 'FlipperServerCompanion.companion-device-plugin-subscribe -> plugin does not expose requested state or it is not an Atom (created with `createState`)', + deviceSerial, + pluginId, + api, + ); + } + + stateAtom.subscribe((data) => + this.emit('companion-device-plugin-state-update', { + deviceSerial, + pluginId, + api, + data, + }), + ); + + return stateAtom.get(); + }, }; }