/** * Copyright 2018-present Facebook. * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * @format */ import {FlipperPlugin, FlipperDevicePlugin} from './plugin'; import BaseDevice, {OS} from './devices/BaseDevice'; import {App} from './App.js'; import {Logger} from './fb-interfaces/Logger.js'; import {Store} from './reducers/index'; import {setPluginState} from './reducers/pluginStates'; import {RSocketClientSocket} from 'rsocket-core/RSocketClient'; import {performance} from 'perf_hooks'; import {reportPlatformFailures, reportPluginFailures} from './utils/metrics'; import {default as isProduction} from './utils/isProduction'; import {registerPlugins} from './reducers/plugins'; import createTableNativePlugin from './plugins/TableNativePlugin'; import EventEmitter from 'events'; import invariant from 'invariant'; import {Responder} from 'rsocket-types/ReactiveSocketTypes'; type Plugins = Array; export type ClientQuery = { app: string; os: OS; device: string; device_id: string; sdk_version?: number; }; export type ClientExport = { id: string; query: ClientQuery; }; type ErrorType = {message: string; stacktrace: string; name: string}; type Params = { api: string; method: string; params?: Object; }; type RequestMetadata = {method: string; id: number; params: Params | undefined}; const handleError = ( store: Store, deviceSerial: string | undefined, error: ErrorType, ) => { if (isProduction()) { return; } const crashReporterPlugin = store .getState() .plugins.devicePlugins.get('CrashReporter'); if (!crashReporterPlugin) { return; } const pluginKey = `${deviceSerial || ''}#CrashReporter`; const persistedState = { ...crashReporterPlugin.defaultPersistedState, ...store.getState().pluginStates[pluginKey], }; 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), }; const newPluginState = crashReporterPlugin.persistedStateReducer( persistedState, 'flipper-crash-report', payload, ); if (persistedState !== newPluginState) { store.dispatch( setPluginState({ pluginKey, state: newPluginState, }), ); } }; export default class Client extends EventEmitter { app: App; connected: boolean; id: string; query: ClientQuery; sdkVersion: number; messageIdCounter: number; plugins: Plugins; connection: RSocketClientSocket | null | undefined; responder: Partial>; store: Store; activePlugins: Set; device: Promise; logger: Logger; lastSeenDeviceList: Array; broadcastCallbacks: Map>>; requestCallbacks: Map< number, { resolve: (data: any) => void; reject: (err: Error) => void; metadata: RequestMetadata; // eslint-disable-next-line prettier/prettier } >; constructor( id: string, query: ClientQuery, conn: RSocketClientSocket | null | undefined, logger: Logger, store: Store, plugins?: Plugins | null | undefined, ) { super(); this.connected = true; this.plugins = plugins ? plugins : []; this.connection = conn; this.id = id; this.query = query; this.sdkVersion = query.sdk_version || 0; this.messageIdCounter = 0; this.logger = logger; this.store = store; this.broadcastCallbacks = new Map(); this.requestCallbacks = new Map(); this.activePlugins = new Set(); this.lastSeenDeviceList = []; const client = this; // node.js doesn't support requestIdleCallback const rIC = typeof window === 'undefined' ? (cb, _) => { cb(); } : window.requestIdleCallback; this.responder = { fireAndForget: (payload: {data: string}) => rIC(() => client.onMessage(payload.data), { timeout: 500, }), }; if (conn) { conn.connectionStatus().subscribe({ onNext(payload) { if (payload.kind == 'ERROR' || payload.kind == 'CLOSED') { client.connected = false; } }, onSubscribe(subscription) { subscription.request(Number.MAX_SAFE_INTEGER); }, }); } } /* All clients should have a corresponding Device in the store. However, clients can connect before a device is registered, so wait a while for the device to be registered if it isn't already. */ setMatchingDevice(): void { if (this.device) { return; } this.device = reportPlatformFailures( new Promise((resolve, reject) => { const device = this.store .getState() .connections.devices.find( device => device.serial === this.query.device_id, ); if (device) { resolve(device); return; } const unsubscribe = this.store.subscribe(() => { const newDeviceList = this.store.getState().connections.devices; if (newDeviceList === this.lastSeenDeviceList) { return; } this.lastSeenDeviceList = this.store.getState().connections.devices; const matchingDevice = newDeviceList.find( device => device.serial === this.query.device_id, ); if (matchingDevice) { resolve(matchingDevice); unsubscribe(); } }); setTimeout(() => { unsubscribe(); const error = `Timed out waiting for device for client ${this.id}`; console.error(error); reject(error); }, 5000); }), 'client-setMatchingDevice', ); } supportsPlugin(Plugin: typeof FlipperPlugin): boolean { return this.plugins.includes(Plugin.id); } async init() { this.setMatchingDevice(); await this.getPlugins(); } // get the supported plugins async getPlugins(): Promise { const plugins = await this.rawCall<{plugins: Plugins}>( 'getPlugins', false, ).then(data => data.plugins); this.plugins = plugins; const nativeplugins = plugins .map(plugin => /_nativeplugin_([^_]+)_([^_]+)/.exec(plugin)) .filter(Boolean) .map(([id, type, title]) => { // TODO put this in another component, and make the "types" registerable switch (type) { case 'Table': return createTableNativePlugin(id, title); default: { return null; } } }) .filter(Boolean); this.store.dispatch(registerPlugins(nativeplugins as any)); return plugins; } // get the plugins, and update the UI async refreshPlugins() { await this.getPlugins(); this.emit('plugins-change'); } async deviceSerial(): Promise { try { const device = await this.device; return device.serial; } catch (e) { console.error( 'Using "" for deviceId because client has no matching device', ); return ''; } } onMessage(msg: string) { if (typeof msg !== 'string') { return; } let rawData; try { rawData = JSON.parse(msg); } catch (err) { console.error(`Invalid JSON: ${msg}`, 'clientMessage'); return; } const data: { id?: number; method?: string; params?: Params; success?: Object; error?: ErrorType; } = rawData; console.debug(data, 'message:receive'); const {id, method} = data; if (id == null) { const {error} = data; if (error != null) { console.error( `Error received from device ${ method ? `when calling ${method}` : '' }: ${error.message} + \nDevice Stack Trace: ${error.stacktrace}`, 'deviceError', ); this.deviceSerial().then(serial => handleError(this.store, serial, error), ); } else if (method === 'refreshPlugins') { this.refreshPlugins(); } else if (method === 'execute') { const params = data.params; invariant(params, 'expected params'); const persistingPlugin: | typeof FlipperPlugin | typeof FlipperDevicePlugin = this.store.getState().plugins.clientPlugins.get(params.api) || this.store.getState().plugins.devicePlugins.get(params.api); if (persistingPlugin && persistingPlugin.persistedStateReducer) { let pluginKey = `${this.id}#${params.api}`; //$FlowFixMe if (persistingPlugin.prototype instanceof FlipperDevicePlugin) { // For device plugins, we are just using the device id instead of client id as the prefix. this.deviceSerial().then( serial => (pluginKey = `${serial}#${params.api}`), ); } const persistedState = { ...persistingPlugin.defaultPersistedState, ...this.store.getState().pluginStates[pluginKey], }; const newPluginState = persistingPlugin.persistedStateReducer( persistedState, params.method, params.params, ); if (persistedState !== newPluginState) { this.store.dispatch( setPluginState({ pluginKey, state: newPluginState, }), ); } } const apiCallbacks = this.broadcastCallbacks.get(params.api); if (!apiCallbacks) { return; } const methodCallbacks = apiCallbacks.get(params.method); if (methodCallbacks) { for (const callback of methodCallbacks) { callback(params.params); } } } return; // method === 'execute' } if (this.sdkVersion < 1) { const callbacks = this.requestCallbacks.get(id); if (!callbacks) { return; } this.requestCallbacks.delete(id); this.finishTimingRequestResponse(callbacks.metadata); this.onResponse(data, callbacks.resolve, callbacks.reject); } } onResponse( data: { success?: Object; error?: ErrorType; }, resolve: (a: Object) => any, reject: (error: ErrorType) => any, ) { if (data.success) { resolve(data.success); } else if (data.error) { reject(data.error); const {error} = data; if (error) { this.deviceSerial().then(serial => handleError(this.store, serial, error), ); } } else { // ??? } } toJSON(): ClientExport { return {id: this.id, query: this.query}; } subscribe( api: string | null = null, 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 | null = null, 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 new Promise((resolve, reject) => { const id = this.messageIdCounter++; const metadata: RequestMetadata = { method, id, params, }; if (this.sdkVersion < 1) { this.requestCallbacks.set(id, {reject, resolve, metadata}); } const data = { id, method, params, }; const plugin = params ? params.api : undefined; console.debug(data, 'message:call'); if (this.sdkVersion < 1) { this.startTimingRequestResponse({method, id, params}); if (this.connection) { this.connection.fireAndForget({data: JSON.stringify(data)}); } return; } const mark = this.getPerformanceMark(metadata); performance.mark(mark); if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) { this.connection && this.connection .requestResponse({data: JSON.stringify(data)}) .subscribe({ onComplete: payload => { if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) { const logEventName = this.getLogEventName(data); this.logger.trackTimeSince(mark, logEventName); const response: { success?: Object; error?: ErrorType; } = JSON.parse(payload.data); this.onResponse(response, resolve, reject); } }, // Open fresco then layout and you get errors because responses come back after deinit. onError: e => { if (this.isAcceptingMessagesFromPlugin(plugin)) { reject(e); } }, }); } }); } 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); } isAcceptingMessagesFromPlugin(plugin: string | null | undefined) { return this.connection && (!plugin || this.activePlugins.has(plugin)); } getPerformanceMark(data: RequestMetadata): string { const {method, id} = data; return `request_response_${method}_${id}`; } getLogEventName(data: RequestMetadata): string { const {method, params} = data; return params && params.api && params.method ? `request_response_${method}_${params.api}_${params.method}` : `request_response_${method}`; } initPlugin(pluginId: string) { this.activePlugins.add(pluginId); this.rawSend('init', {plugin: pluginId}); } deinitPlugin(pluginId: string) { this.activePlugins.delete(pluginId); this.rawSend('deinit', {plugin: pluginId}); } rawSend(method: string, params?: Object): void { const data = { method, params, }; console.debug(data, 'message:send'); if (this.connection) { this.connection.fireAndForget({data: JSON.stringify(data)}); } } call( api: string, method: string, fromPlugin: boolean, params?: Object, ): Promise { return reportPluginFailures( this.rawCall('execute', fromPlugin, {api, method, params}), `Call-${method}`, api, ); } send(api: string, method: string, params?: Object): void { if (!isProduction()) { console.warn( `${api}:${method || ''} client.send() is deprecated. Please use call() instead so you can handle errors.`, ); } return this.rawSend('execute', {api, method, params}); } async supportsMethod(api: string, method: string): Promise { if (this.sdkVersion < 2) { return Promise.resolve(false); } const response = await this.rawCall<{ isSupported: boolean; }>('isMethodSupported', true, { api, method, }); return response.isSupported; } }