/** * 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 */ // We're using `deviceSync` here on purpose which is triggering a lot of warnings. /* eslint-disable node/no-sync */ import { Logger, FlipperServer, ClientQuery, ClientErrorType, } from 'flipper-common'; import {Store} from './reducers/index'; import {NoLongerConnectedToClientError} from 'flipper-common'; import {defaultEnabledBackgroundPlugins} from './utils/pluginUtils'; import {processMessagesLater} from './utils/messageQueue'; import {emitBytesReceived} from './dispatcher/tracking'; import {debounce} from 'lodash'; import {batch} from 'react-redux'; import {_SandyPluginInstance, _SandyPluginDefinition} from 'flipper-plugin'; import {message} from 'antd'; import { isFlipperMessageDebuggingEnabled, registerFlipperDebugMessage, } from './chrome/FlipperMessages'; import {waitFor} from './utils/waitFor'; import { AbstractClient, Params, ClientConnection, BaseDevice, RequestMetadata, getPluginKey, } from 'flipper-frontend-core'; export type ClientExport = { id: string; query: ClientQuery; }; const handleError = ( store: Store, device: BaseDevice, error: ClientErrorType, ) => { if (store.getState().settingsState.suppressPluginErrors) { return; } const crashReporterPlugin = device.sandyPluginStates.get('CrashReporter'); if (!crashReporterPlugin) { return; } if (!crashReporterPlugin.instanceApi.reportCrash) { console.error('CrashReporterPlugin persistedStateReducer broken'); return; } const isCrashReport: boolean = Boolean(error.name || error.message); const payload = isCrashReport ? { name: error.name, reason: error.message, callstack: error.stacktrace, } : { name: 'Plugin Error', reason: JSON.stringify(error), }; crashReporterPlugin.instanceApi.reportCrash(payload); }; export default class Client extends AbstractClient { store: Store; broadcastCallbacks: Map>>; messageBuffer: Record< string /*pluginKey*/, { plugin: _SandyPluginInstance; messages: Params[]; } > = {}; constructor( id: string, query: ClientQuery, conn: ClientConnection | null | undefined, logger: Logger, store: Store, plugins: Set | null | undefined, device: BaseDevice, flipperServer: FlipperServer, ) { super(id, query, conn, logger, plugins, device, flipperServer); this.store = store; this.broadcastCallbacks = new Map(); this.on('flipper-debug-message', (message) => { if (isFlipperMessageDebuggingEnabled()) { registerFlipperDebugMessage(message); } }); this.on('bytes-received', (api, bytes) => emitBytesReceived(api, bytes)); this.on('error', (error) => handleError(this.store, this.device, error)); } supportsPlugin(pluginId: string): boolean { return this.plugins.has(pluginId); } isEnabledPlugin(pluginId: string) { return this.store .getState() .connections.enabledPlugins[this.query.app]?.includes(pluginId); } shouldConnectAsBackgroundPlugin(pluginId: string) { return ( defaultEnabledBackgroundPlugins.includes(pluginId) || this.isEnabledPlugin(pluginId) ); } async initFromImport( initialStates: Record>, ): Promise { await Promise.all( [...this.plugins].map(async (pluginId) => { const plugin = await this.getPlugin(pluginId); if (plugin) { this.loadPlugin(plugin, initialStates[pluginId]); } }), ); this.emit('plugins-change'); return this; } // get the supported plugins async loadPlugins(phase: 'init' | 'refresh'): Promise> { const plugins = await super.loadPlugins(phase); if (phase === 'init') { // if a client arrives before all plugins are loaded, we'll have to wait await waitFor(this.store, (state) => state.plugins.initialized); } return plugins; } startPluginIfNeeded( plugin: _SandyPluginDefinition | undefined, isEnabled = plugin ? this.isEnabledPlugin(plugin.id) : false, ) { // start a plugin on start if it is a SandyPlugin, which is enabled, and doesn't have persisted state yet if ( plugin && (isEnabled || defaultEnabledBackgroundPlugins.includes(plugin.id)) ) { super.startPluginIfNeeded(plugin); } } stopPluginIfNeeded(pluginId: string, force = false) { if (defaultEnabledBackgroundPlugins.includes(pluginId) && !force) { return; } const pluginKey = getPluginKey( this.id, {serial: this.query.device_id}, pluginId, ); delete this.messageBuffer[pluginKey]; return super.stopPluginIfNeeded(pluginId, force); } // gets a plugin by pluginId protected async getPlugin( pluginId: string, ): Promise<_SandyPluginDefinition | undefined> { const plugins = this.store.getState().plugins; return ( plugins.clientPlugins.get(pluginId) || plugins.devicePlugins.get(pluginId) ); } onMessage(msg: string) { batch(() => { super.onMessage(msg); }); } protected handleExecuteMessage(params: Params): boolean { const persistingPlugin: _SandyPluginDefinition | undefined = this.store.getState().plugins.clientPlugins.get(params.api) || this.store.getState().plugins.devicePlugins.get(params.api); let handled = false; // This is just for analysis if ( persistingPlugin && ((persistingPlugin as any).persistedStateReducer || // only send messages to enabled sandy plugins this.sandyPluginStates.has(params.api)) ) { handled = true; const pluginKey = getPluginKey( this.id, {serial: this.query.device_id}, params.api, ); if (!this.messageBuffer[pluginKey]) { this.messageBuffer[pluginKey] = { plugin: (this.sandyPluginStates.get(params.api) ?? persistingPlugin) as any, messages: [params], }; } else { this.messageBuffer[pluginKey].messages.push(params); } this.flushMessageBufferDebounced(); } const apiCallbacks = this.broadcastCallbacks.get(params.api); if (apiCallbacks) { const methodCallbacks = apiCallbacks.get(params.method); if (methodCallbacks) { for (const callback of methodCallbacks) { handled = true; callback(params.params); } } } return handled; } toJSON(): ClientExport { return {id: this.id, query: this.query}; } subscribe(api: string, method: string, callback: (params: Object) => void) { let apiCallbacks = this.broadcastCallbacks.get(api); if (!apiCallbacks) { apiCallbacks = new Map(); this.broadcastCallbacks.set(api, apiCallbacks); } let methodCallbacks = apiCallbacks.get(method); if (!methodCallbacks) { methodCallbacks = new Set(); apiCallbacks.set(method, methodCallbacks); } methodCallbacks.add(callback); } unsubscribe(api: string, method: string, callback: Function) { const apiCallbacks = this.broadcastCallbacks.get(api); if (!apiCallbacks) { return; } const methodCallbacks = apiCallbacks.get(method); if (!methodCallbacks) { return; } methodCallbacks.delete(callback); } rawCall(method: string, fromPlugin: boolean, params?: Params): Promise { return super.rawCall(method, fromPlugin, params).catch((error) => { if (error instanceof NoLongerConnectedToClientError) { message.warn({ content: 'Not connected', key: 'appnotconnectedwarning', duration: 0.5, }); } throw error; }); } flushMessageBuffer = () => { // batch to make sure that Redux collapsed the dispatches batch(() => { for (const pluginKey in this.messageBuffer) { processMessagesLater( this.store, pluginKey, this.messageBuffer[pluginKey].plugin, this.messageBuffer[pluginKey].messages, ); } this.messageBuffer = {}; }); }; flushMessageBufferDebounced = debounce(this.flushMessageBuffer, 200, { leading: true, trailing: true, }); startTimingRequestResponse(data: RequestMetadata) { performance.mark(this.getPerformanceMark(data)); } finishTimingRequestResponse(data: RequestMetadata) { const mark = this.getPerformanceMark(data); const logEventName = this.getLogEventName(data); this.logger.trackTimeSince(mark, logEventName); } }