From d28e763cca4c2adad9b776e0f0648abd161c87e2 Mon Sep 17 00:00:00 2001 From: Timur Valiev Date: Fri, 17 Jul 2020 04:53:09 -0700 Subject: [PATCH] Self inspection Summary: let's finally inspect flipper with flipper! Here we have: 1) a self inspection client which implements FlipperClient interface from js sdk and FlipperClientConnection. It links back and front parts of self inspection 2) simple plugin (UI) to show messages 3) back part of that plugin - it sends all received messages to UI part via client 4) we initialize self inspection for dev builds only P. S. filesystem dependency will be replaced with npm one before I ship it (need to publish to npm first) Reviewed By: mweststrate Differential Revision: D22524533 fbshipit-source-id: 5c77e2f7b50e24ff7314e791a4dfe3c349dccdee --- desktop/app/package.json | 1 + desktop/app/src/Client.tsx | 57 +++++ .../devices/FlipperSelfInspectionDevice.tsx | 17 ++ desktop/app/src/server.tsx | 8 + .../plugins/FlipperMessagesClientPlugin.tsx | 54 ++++ .../self-inspection/selfInspectionClient.tsx | 115 +++++++++ .../self-inspection/selfInspectionUtils.tsx | 99 ++++++++ desktop/plugins/flipper-messages/index.tsx | 238 ++++++++++++++++++ desktop/plugins/flipper-messages/package.json | 31 +++ desktop/yarn.lock | 5 + 10 files changed, 625 insertions(+) create mode 100644 desktop/app/src/devices/FlipperSelfInspectionDevice.tsx create mode 100644 desktop/app/src/utils/self-inspection/plugins/FlipperMessagesClientPlugin.tsx create mode 100644 desktop/app/src/utils/self-inspection/selfInspectionClient.tsx create mode 100644 desktop/app/src/utils/self-inspection/selfInspectionUtils.tsx create mode 100644 desktop/plugins/flipper-messages/index.tsx create mode 100644 desktop/plugins/flipper-messages/package.json 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"