diff --git a/desktop/app/package.json b/desktop/app/package.json index e67d79352..826ac0134 100644 --- a/desktop/app/package.json +++ b/desktop/app/package.json @@ -23,6 +23,7 @@ "deep-equal": "^2.0.1", "emotion": "^10.0.23", "expand-tilde": "^2.0.2", + "flipper-client-sdk": "^0.0.2", "flipper-plugin": "0.50.0", "flipper-doctor": "0.50.0", "flipper-plugin-lib": "0.50.0", diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index b6491577c..aff361228 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -39,6 +39,7 @@ import {emitBytesReceived} from './dispatcher/tracking'; import {debounce} from 'lodash'; import {batch} from 'react-redux'; import {SandyPluginInstance} from 'flipper-plugin'; +import {flipperMessagesClientPlugin} from './utils/self-inspection/plugins/FlipperMessagesClientPlugin'; type Plugins = Array; @@ -131,6 +132,7 @@ export default class Client extends EventEmitter { activePlugins: Set; device: Promise; _deviceResolve: (device: BaseDevice) => void = (_) => {}; + _deviceResolved: BaseDevice | undefined; logger: Logger; lastSeenDeviceList: Array; broadcastCallbacks: Map>>; @@ -187,6 +189,10 @@ export default class Client extends EventEmitter { this._deviceResolve = resolve; }); + if (device != null) { + this._deviceResolved = device; + } + const client = this; if (conn) { conn.connectionStatus().subscribe({ @@ -219,6 +225,7 @@ export default class Client extends EventEmitter { (device) => device.serial === this.query.device_id, ); if (device) { + this._deviceResolved = device; resolve(device); return; } @@ -251,6 +258,7 @@ export default class Client extends EventEmitter { }), 'client-setMatchingDevice', ).then((device) => { + this._deviceResolved = device; this._deviceResolve(device); }); } @@ -456,6 +464,21 @@ export default class Client extends EventEmitter { const {id, method} = data; + if ( + data.params?.api != 'flipper-messages' && + flipperMessagesClientPlugin.isConnected() + ) { + flipperMessagesClientPlugin.newMessage({ + device: this._deviceResolved?.displayTitle(), + app: this.query.app, + flipperInternalMethod: method, + plugin: data.params?.api, + pluginMethod: data.params?.method, + payload: data.params?.params, + direction: 'toFlipper:message', + }); + } + if (id == null) { const {error} = data; if (error != null) { @@ -635,6 +658,18 @@ export default class Client extends EventEmitter { } = JSON.parse(payload.data); this.onResponse(response, resolve, reject); + + if (flipperMessagesClientPlugin.isConnected()) { + flipperMessagesClientPlugin.newMessage({ + device: this._deviceResolved?.displayTitle(), + app: this.query.app, + flipperInternalMethod: method, + payload: response, + plugin, + pluginMethod: params?.method, + direction: 'toFlipper:response', + }); + } } }, // Open fresco then layout and you get errors because responses come back after deinit. @@ -645,6 +680,18 @@ export default class Client extends EventEmitter { }, }); } + + if (flipperMessagesClientPlugin.isConnected()) { + flipperMessagesClientPlugin.newMessage({ + device: this._deviceResolved?.displayTitle(), + app: this.query.app, + flipperInternalMethod: method, + plugin: params?.api, + pluginMethod: params?.method, + payload: params?.params, + direction: 'toClient:call', + }); + } }); } @@ -717,6 +764,16 @@ export default class Client extends EventEmitter { if (this.connection) { this.connection.fireAndForget({data: JSON.stringify(data)}); } + + if (flipperMessagesClientPlugin.isConnected()) { + flipperMessagesClientPlugin.newMessage({ + device: this._deviceResolved?.displayTitle(), + app: this.query.app, + flipperInternalMethod: method, + payload: params, + direction: 'toClient:send', + }); + } } call( diff --git a/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx b/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx new file mode 100644 index 000000000..be93bc4d0 --- /dev/null +++ b/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 BaseDevice, {OS, DeviceType} from './BaseDevice'; + +export default class FlipperSelfInspectionDevice extends BaseDevice { + constructor(serial: string, deviceType: DeviceType, title: string, os: OS) { + super(serial, deviceType, title, os); + this.devicePlugins = []; + } +} diff --git a/desktop/app/src/server.tsx b/desktop/app/src/server.tsx index 64a4b776c..348b66f71 100644 --- a/desktop/app/src/server.tsx +++ b/desktop/app/src/server.tsx @@ -33,6 +33,7 @@ import {WebsocketClientFlipperConnection} from './utils/js-client-server-utils/w import querystring from 'querystring'; import {IncomingMessage} from 'http'; import ws from 'ws'; +import {initSelfInpector} from './utils/self-inspection/selfInspectionUtils'; type ClientInfo = { connection: FlipperClientConnection | null | undefined; @@ -84,6 +85,13 @@ class Server extends EventEmitter { } init() { + if ( + process.env.NODE_ENV === 'development' && + GK.get('flipper_self_inspection') + ) { + initSelfInpector(this.store, this.logger, this, this.connections); + } + const {insecure, secure} = this.store.getState().application.serverPorts; this.initialisePromise = this.certificateProvider .loadSecureServerConfig() diff --git a/desktop/app/src/utils/self-inspection/plugins/FlipperMessagesClientPlugin.tsx b/desktop/app/src/utils/self-inspection/plugins/FlipperMessagesClientPlugin.tsx new file mode 100644 index 000000000..f4e76d0bc --- /dev/null +++ b/desktop/app/src/utils/self-inspection/plugins/FlipperMessagesClientPlugin.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {FlipperConnection, FlipperPlugin} from 'flipper-client-sdk'; + +export type MessageInfo = { + device?: string; + app: string; + flipperInternalMethod?: string; + plugin?: string; + pluginMethod?: string; + payload?: any; + direction: + | 'toClient:call' + | 'toClient:send' + | 'toFlipper:message' + | 'toFlipper:response'; +}; + +export class FlipperMessagesClientPlugin implements FlipperPlugin { + protected connection: FlipperConnection | null = null; + + onConnect(connection: FlipperConnection): void { + this.connection = connection; + } + + onDisconnect(): void { + this.connection = null; + } + + getId(): string { + return 'flipper-messages'; + } + + runInBackground(): boolean { + return true; + } + + newMessage(message: MessageInfo) { + this.connection?.send('newMessage', message); + } + + isConnected() { + return this.connection != null; + } +} + +export const flipperMessagesClientPlugin = new FlipperMessagesClientPlugin(); diff --git a/desktop/app/src/utils/self-inspection/selfInspectionClient.tsx b/desktop/app/src/utils/self-inspection/selfInspectionClient.tsx new file mode 100644 index 000000000..6e9233b49 --- /dev/null +++ b/desktop/app/src/utils/self-inspection/selfInspectionClient.tsx @@ -0,0 +1,115 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {FlipperClientConnection} from '../../Client'; +import {Flowable, Single} from 'rsocket-flowable'; +import {Payload, ConnectionStatus, ISubscriber} from 'rsocket-types'; + +import {FlipperClient} from 'flipper-client-sdk'; + +// somehow linter isn't happy with next import so type definitions are copied +// import {IFutureSubject} from 'rsocket-flowable/Single'; + +type CancelCallback = () => void; + +interface IFutureSubject { + onComplete: (value: T) => void; + onError: (error: Error) => void; + onSubscribe: (cancel: CancelCallback | null | undefined) => void; +} + +export class SelfInspectionFlipperClient extends FlipperClient + implements FlipperClientConnection { + connStatusSubscribers: Set> = new Set(); + connStatus: ConnectionStatus = {kind: 'CONNECTED'}; + + connectionStatus(): Flowable { + return new Flowable((subscriber) => { + subscriber.onSubscribe({ + cancel: () => { + this.connStatusSubscribers.delete(subscriber); + }, + request: (_) => { + this.connStatusSubscribers.add(subscriber); + subscriber.onNext(this.connStatus); + }, + }); + }); + } + + close(): void { + this.connStatus = {kind: 'CLOSED'}; + this.connStatusSubscribers.forEach((subscriber) => { + subscriber.onNext(this.connStatus); + }); + } + + fireAndForget(payload: Payload): void { + if (payload.data == null) { + return; + } + const message = JSON.parse(payload.data) as { + method: string; + id: number; + params: any; + }; + this.onMessageReceived(message); + } + + activeRequests = new Map>>(); + + requestResponse(payload: Payload): Single> { + return new Single((subscriber) => { + subscriber.onSubscribe(() => {}); + if (payload.data == null) { + subscriber.onError(new Error('empty payload')); + return; + } + const message = JSON.parse(payload.data) as { + method: string; + id: number; + params: any; + }; + this.activeRequests.set(message.id, subscriber); + this.onMessageReceived(message); + }); + } + + // Client methods + + messagesHandler: ((message: any) => void) | undefined; + + start(_appName: string): void { + this.onConnect(); + } + + stop(): void {} + + sendData(payload: any): void { + if (payload['success'] != null) { + const message = payload as {id: number; success: unknown}; + const sub = this.activeRequests.get(message.id); + sub?.onComplete({data: JSON.stringify(message)}); + this.activeRequests.delete(message.id); + return; + } + + this.messagesHandler && this.messagesHandler(payload); + } + + isAvailable(): boolean { + return true; + } + + subscibeForClientMessages(handler: (message: any) => void) { + this.messagesHandler = handler; + } +} + +export const selfInspectionClient = new SelfInspectionFlipperClient(); diff --git a/desktop/app/src/utils/self-inspection/selfInspectionUtils.tsx b/desktop/app/src/utils/self-inspection/selfInspectionUtils.tsx new file mode 100644 index 000000000..373ada4ab --- /dev/null +++ b/desktop/app/src/utils/self-inspection/selfInspectionUtils.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 Client, {ClientQuery} from '../../Client'; +import {FlipperClientConnection} from '../../Client'; +import FlipperSelfInspectionDevice from '../../devices/FlipperSelfInspectionDevice'; +import {Store} from '../../reducers'; +import {Logger} from '../../fb-interfaces/Logger'; + +import Server from '../../server'; +import {buildClientId} from '../clientUtils'; +import {selfInspectionClient} from './selfInspectionClient'; +import {flipperMessagesClientPlugin} from './plugins/FlipperMessagesClientPlugin'; + +export function initSelfInpector( + store: Store, + logger: Logger, + flipperServer: Server, + flipperConnections: Map< + string, + { + connection: FlipperClientConnection | null | undefined; + client: Client; + } + >, +) { + const appName = 'Flipper'; + const device_id = 'FlipperSelfInspectionDevice'; + store.dispatch({ + type: 'REGISTER_DEVICE', + payload: new FlipperSelfInspectionDevice( + device_id, + 'emulator', + appName, + 'JSWebApp', + ), + }); + + selfInspectionClient.addPlugin(flipperMessagesClientPlugin); + + const query: ClientQuery = { + app: appName, + os: 'JSWebApp', + device: 'emulator', + device_id, + sdk_version: 4, + }; + const clientId = buildClientId(query); + + const client = new Client( + clientId, + query, + selfInspectionClient, + logger, + store, + ); + + flipperConnections.set(clientId, { + connection: selfInspectionClient, + client: client, + }); + + selfInspectionClient.connectionStatus().subscribe({ + onNext(payload) { + if (payload.kind == 'ERROR' || payload.kind == 'CLOSED') { + console.debug(`Device disconnected ${client.id}`, 'server'); + flipperServer.removeConnection(client.id); + const toUnregister = new Set(); + store.dispatch({ + type: 'UNREGISTER_DEVICES', + payload: toUnregister, + }); + } + }, + onSubscribe(subscription) { + subscription.request(Number.MAX_SAFE_INTEGER); + }, + }); + + client.init().then(() => { + flipperServer.emit('new-client', client); + flipperServer.emit('clients-change'); + client.emit('plugins-change'); + + selfInspectionClient.subscibeForClientMessages((payload: any) => { + // let's break the possible recursion problems here + // for example we want to send init plugin message, but store state is being updated when we enable plugins + setImmediate(() => { + client.onMessage(JSON.stringify(payload)); + }); + }); + }); +} diff --git a/desktop/plugins/flipper-messages/index.tsx b/desktop/plugins/flipper-messages/index.tsx new file mode 100644 index 000000000..adf8f436a --- /dev/null +++ b/desktop/plugins/flipper-messages/index.tsx @@ -0,0 +1,238 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 { + Button, + colors, + DetailSidebar, + FlexCenter, + FlexColumn, + FlipperPlugin, + ManagedDataInspector, + Panel, + SearchableTable, + styled, + TableHighlightedRows, +} from 'flipper'; +import React from 'react'; + +type MessageInfo = { + device?: string; + app: string; + flipperInternalMethod?: string; + plugin?: string; + pluginMethod?: string; + payload?: any; + direction: 'toClient' | 'toFlipper'; +}; + +type MessageRow = { + columns: { + time: { + value: string; + }; + device: { + value?: string; + isFilterable: true; + }; + app: { + value: string; + isFilterable: true; + }; + internalMethod: { + value?: string; + isFilterable: true; + }; + plugin: { + value?: string; + isFilterable: true; + }; + pluginMethod: { + value?: string; + isFilterable: true; + }; + direction: { + value: string; + isFilterable: true; + }; + }; + timestamp: number; + payload?: any; + key: string; +}; + +type State = { + selectedId: string | null; +}; + +type PersistedState = { + messageRows: Array; +}; + +const Placeholder = styled(FlexCenter)({ + fontSize: 18, + color: colors.macOSTitleBarIcon, +}); + +const COLUMNS = { + time: { + value: 'Time', + }, + device: { + value: 'Device', + }, + app: { + value: 'App', + }, + internalMethod: { + value: 'Flipper internal method', + }, + plugin: { + value: 'Plugin', + }, + pluginMethod: { + value: 'Method', + }, + direction: { + value: 'Direction', + }, +}; + +const COLUMN_SIZES = { + time: 'flex', + device: 'flex', + app: 'flex', + internalMethod: 'flex', + plugin: 'flex', + pluginMethod: 'flex', + direction: 'flex', +}; + +let rowId = 0; + +function createRow(message: MessageInfo): MessageRow { + return { + columns: { + time: { + value: new Date().toLocaleTimeString(), + }, + device: { + value: message.device, + isFilterable: true, + }, + app: { + value: message.app, + isFilterable: true, + }, + internalMethod: { + value: message.flipperInternalMethod, + isFilterable: true, + }, + plugin: { + value: message.plugin, + isFilterable: true, + }, + pluginMethod: { + value: message.pluginMethod, + isFilterable: true, + }, + direction: { + value: message.direction, + isFilterable: true, + }, + }, + timestamp: Date.now(), + payload: message.payload, + key: '' + rowId++, + }; +} + +export default class extends FlipperPlugin { + static defaultPersistedState = { + messageRows: [], + }; + + state: State = { + selectedId: null, + }; + + static persistedStateReducer = ( + persistedState: PersistedState, + method: string, + payload: any, + ): PersistedState => { + if (method === 'newMessage') { + return { + ...persistedState, + messageRows: [...persistedState.messageRows, createRow(payload)].filter( + (row) => Date.now() - row.timestamp < 5 * 60 * 1000, + ), + }; + } + return persistedState; + }; + + render() { + const clearTableButton = ( + + ); + + return ( + + + {this.renderSidebar()} + + ); + } + + onRowHighlighted = (keys: TableHighlightedRows) => { + if (keys.length > 0) { + this.setState({ + selectedId: keys[0], + }); + } + }; + + renderSidebar() { + const {selectedId} = this.state; + const {messageRows} = this.props.persistedState; + if (selectedId !== null) { + const message = messageRows.find((row) => row.key == selectedId); + if (message != null) { + return this.renderExtra(message.payload); + } + } + return Select a message to view details; + } + + renderExtra(extra: any) { + return ( + + + + ); + } + + clear = () => { + this.setState({selectedId: null}); + this.props.setPersistedState({messageRows: []}); + }; +} diff --git a/desktop/plugins/flipper-messages/package.json b/desktop/plugins/flipper-messages/package.json new file mode 100644 index 000000000..00707d20b --- /dev/null +++ b/desktop/plugins/flipper-messages/package.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://fbflipper.com/schemas/plugin-package/v2.json", + "name": "flipper-plugin-flipper-messages", + "id": "flipper-messages", + "title": "Flipper Messages", + "icon": "bird", + "version": "0.50.0", + "description": "Flipper self inspection: Messages to and from client", + "main": "dist/bundle.js", + "flipperBundlerEntry": "index.tsx", + "license": "MIT", + "keywords": [ + "flipper-plugin" + ], + "bugs": { + "url": "https://fbflipper.com/" + }, + "scripts": { + "lint": "flipper-pkg lint", + "build": "flipper-pkg bundle", + "watch": "flipper-pkg bundle --watch", + "prepack": "flipper-pkg lint && flipper-pkg bundle --production" + }, + "peerDependencies": { + "flipper": "0.50.0" + }, + "devDependencies": { + "flipper": "0.50.0", + "flipper-pkg": "0.50.0" + } +} diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 0be2ef153..d69628c56 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -5683,6 +5683,11 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== +flipper-client-sdk@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/flipper-client-sdk/-/flipper-client-sdk-0.0.2.tgz#cb970908b9a948d1671d39e2ca050191753c99f7" + integrity sha512-7l0yM9uaUEfi179iq9TEvZ2fzDF3t0EymTpxkP/scQWVSjw+bgHYv5G66EbBbqWdJhlTA7zWki5gTV+OylzghQ== + flow-bin@0.128.0: version "0.128.0" resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.128.0.tgz#fd1232a64dc46874d8d499f16a1934b964f4c2ae"